mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Compare commits
62 Commits
feat/inter
...
v0.10.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3f5d05349 | ||
|
|
0c4b833b07 | ||
|
|
fbab1d323f | ||
|
|
f50520c93f | ||
|
|
cebf57ffd3 | ||
|
|
6020219fda | ||
|
|
8094941385 | ||
|
|
9ce3cfee7d | ||
|
|
6184440441 | ||
|
|
0cff4cf510 | ||
|
|
b152f119c5 | ||
|
|
9f936c6968 | ||
|
|
b8531cf7e8 | ||
|
|
edcc4e789b | ||
|
|
20cc401238 | ||
|
|
70204a2d36 | ||
|
|
e38325c27f | ||
|
|
5e4b422315 | ||
|
|
6c5206daf4 | ||
|
|
ed65f70315 | ||
|
|
f41a42010c | ||
|
|
a0669d4262 | ||
|
|
a4a792c6b1 | ||
|
|
6842e4c7f7 | ||
|
|
6638c35945 | ||
|
|
53f5c2b2bb | ||
|
|
6e13cdd516 | ||
|
|
a48c67d271 | ||
|
|
43fc3de2e1 | ||
|
|
80081b60bf | ||
|
|
cbca9b68e6 | ||
|
|
b9b3695497 | ||
|
|
1b9acb1395 | ||
|
|
01cf81a105 | ||
|
|
6d267ce0fa | ||
|
|
8b0b565282 | ||
|
|
a046d1232e | ||
|
|
d724e782dd | ||
|
|
a266d85ecd | ||
|
|
a4a111fad0 | ||
|
|
2a98de85a8 | ||
|
|
fb3a8499f3 | ||
|
|
33dd9ae347 | ||
|
|
ac87594b5d | ||
|
|
32656a9662 | ||
|
|
785a4d2c3b | ||
|
|
41a6c7f712 | ||
|
|
7e5d915b60 | ||
|
|
8321c06e16 | ||
|
|
f60c18d31a | ||
|
|
e171b6a049 | ||
|
|
4a59e901e6 | ||
|
|
8ed2fa07a0 | ||
|
|
385e7f5c1e | ||
|
|
861fff1aae | ||
|
|
09527b3b67 | ||
|
|
d98ff16c8f | ||
|
|
e902e8ea4c | ||
|
|
aeb5bd829f | ||
|
|
c24e6207d0 | ||
|
|
6c412cd367 | ||
|
|
05d96a7d6e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -95,3 +95,4 @@ check-sync.sh
|
||||
# API key files
|
||||
data/.api-key
|
||||
data/credentials.json
|
||||
data/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/server",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.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());
|
||||
app.use('/api/worktree', createWorktreeRoutes(events));
|
||||
app.use('/api/git', createGitRoutes());
|
||||
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
||||
app.use('/api/models', createModelsRoutes());
|
||||
|
||||
@@ -21,6 +21,12 @@ 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,11 +12,22 @@ const featureLoader = new FeatureLoader();
|
||||
export function createApplyHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, plan } = req.body as {
|
||||
const {
|
||||
projectPath,
|
||||
plan,
|
||||
branchName: rawBranchName,
|
||||
} = 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;
|
||||
@@ -82,6 +93,7 @@ export function createApplyHandler() {
|
||||
dependencies: change.feature.dependencies,
|
||||
priority: change.feature.priority,
|
||||
status: 'backlog',
|
||||
branchName,
|
||||
});
|
||||
|
||||
appliedChanges.push(`added:${newFeature.id}`);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { resolveModelString } from '@automaker/model-resolver';
|
||||
import {
|
||||
CLAUDE_MODEL_MAP,
|
||||
isCursorModel,
|
||||
isOpencodeModel,
|
||||
stripProviderPrefix,
|
||||
ThinkingLevel,
|
||||
getThinkingTokenBudget,
|
||||
@@ -91,13 +92,13 @@ async function extractTextFromStream(
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute enhancement using Cursor provider
|
||||
* Execute enhancement using a provider (Cursor, OpenCode, etc.)
|
||||
*
|
||||
* @param prompt - The enhancement prompt
|
||||
* @param model - The Cursor model to use
|
||||
* @param model - The model to use
|
||||
* @returns The enhanced text
|
||||
*/
|
||||
async function executeWithCursor(prompt: string, model: string): Promise<string> {
|
||||
async function executeWithProvider(prompt: string, model: string): Promise<string> {
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
// Strip provider prefix - providers expect bare model IDs
|
||||
const bareModel = stripProviderPrefix(model);
|
||||
@@ -110,7 +111,11 @@ async function executeWithCursor(prompt: string, model: string): Promise<string>
|
||||
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 === 'assistant' && msg.message?.content) {
|
||||
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) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
@@ -212,7 +217,14 @@ export function createEnhanceHandler(
|
||||
|
||||
// Cursor doesn't have a separate system prompt concept, so combine them
|
||||
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
|
||||
enhancedText = await executeWithCursor(combinedPrompt, resolvedModel);
|
||||
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);
|
||||
} else {
|
||||
// Use Claude SDK for Claude models
|
||||
logger.info(`Using Claude provider for model: ${resolvedModel}`);
|
||||
|
||||
@@ -10,14 +10,21 @@ 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 } =
|
||||
req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
updates: Partial<Feature>;
|
||||
descriptionHistorySource?: 'enhance' | 'edit';
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
|
||||
};
|
||||
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;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId || !updates) {
|
||||
res.status(400).json({
|
||||
@@ -32,7 +39,8 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
featureId,
|
||||
updates,
|
||||
descriptionHistorySource,
|
||||
enhancementMode
|
||||
enhancementMode,
|
||||
preEnhancementDescription
|
||||
);
|
||||
res.json({ success: true, feature: updated });
|
||||
} catch (error) {
|
||||
|
||||
@@ -24,6 +24,12 @@ 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,
|
||||
@@ -65,6 +71,12 @@ 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());
|
||||
|
||||
189
apps/server/src/routes/setup/routes/opencode-models.ts
Normal file
189
apps/server/src/routes/setup/routes/opencode-models.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 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,15 +3,51 @@
|
||||
*/
|
||||
|
||||
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);
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
// ============================================================================
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
@@ -99,18 +135,6 @@ 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,6 +3,7 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
@@ -24,14 +25,22 @@ 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(): Router {
|
||||
export function createWorktreeRoutes(events: EventEmitter): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
|
||||
@@ -45,7 +54,7 @@ export function createWorktreeRoutes(): Router {
|
||||
requireValidProject,
|
||||
createMergeHandler()
|
||||
);
|
||||
router.post('/create', validatePathParams('projectPath'), createCreateHandler());
|
||||
router.post('/create', validatePathParams('projectPath'), createCreateHandler(events));
|
||||
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
|
||||
router.post('/create-pr', createCreatePRHandler());
|
||||
router.post('/pr-info', createPRInfoHandler());
|
||||
@@ -77,6 +86,8 @@ export function createWorktreeRoutes(): 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(
|
||||
@@ -87,5 +98,15 @@ export function createWorktreeRoutes(): 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,7 +3,8 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { isGitRepo, hasCommits } from './common.js';
|
||||
import { isGitRepo } from '@automaker/git-utils';
|
||||
import { hasCommits } from './common.js';
|
||||
|
||||
interface ValidationOptions {
|
||||
/** Check if the path is a git repository (default: true) */
|
||||
|
||||
@@ -12,15 +12,19 @@ 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');
|
||||
|
||||
@@ -77,7 +81,7 @@ async function findExistingWorktreeForBranch(
|
||||
}
|
||||
}
|
||||
|
||||
export function createCreateHandler() {
|
||||
export function createCreateHandler(events: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, branchName, baseBranch } = req.body as {
|
||||
@@ -94,6 +98,26 @@ export function createCreateHandler() {
|
||||
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,
|
||||
@@ -143,30 +167,28 @@ export function createCreateHandler() {
|
||||
// Create worktrees directory if it doesn't exist
|
||||
await secureFs.mkdir(worktreesDir, { recursive: true });
|
||||
|
||||
// Check if branch exists
|
||||
// Check if branch exists (using array arguments to prevent injection)
|
||||
let branchExists = false;
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${branchName}`, {
|
||||
cwd: projectPath,
|
||||
});
|
||||
await execGitCommand(['rev-parse', '--verify', branchName], projectPath);
|
||||
branchExists = true;
|
||||
} catch {
|
||||
// Branch doesn't exist
|
||||
}
|
||||
|
||||
// Create worktree
|
||||
let createCmd: string;
|
||||
// Create worktree (using array arguments to prevent injection)
|
||||
if (branchExists) {
|
||||
// Use existing branch
|
||||
createCmd = `git worktree add "${worktreePath}" ${branchName}`;
|
||||
await execGitCommand(['worktree', 'add', worktreePath, branchName], projectPath);
|
||||
} else {
|
||||
// Create new branch from base or HEAD
|
||||
const base = baseBranch || 'HEAD';
|
||||
createCmd = `git worktree add -b ${branchName} "${worktreePath}" ${base}`;
|
||||
await execGitCommand(
|
||||
['worktree', 'add', '-b', branchName, worktreePath, base],
|
||||
projectPath
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -177,6 +199,8 @@ export function createCreateHandler() {
|
||||
// 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: {
|
||||
@@ -185,6 +209,17 @@ export function createCreateHandler() {
|
||||
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,9 +6,11 @@ import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { isGitRepo } from '@automaker/git-utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('Worktree');
|
||||
|
||||
export function createDeleteHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -46,22 +48,28 @@ export function createDeleteHandler() {
|
||||
// Could not get branch name
|
||||
}
|
||||
|
||||
// Remove the worktree
|
||||
// Remove the worktree (using array arguments to prevent injection)
|
||||
try {
|
||||
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
||||
cwd: projectPath,
|
||||
});
|
||||
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
|
||||
} catch (error) {
|
||||
// Try with prune if remove fails
|
||||
await execAsync('git worktree prune', { cwd: projectPath });
|
||||
await execGitCommand(['worktree', 'prune'], projectPath);
|
||||
}
|
||||
|
||||
// Optionally delete the branch
|
||||
let branchDeleted = false;
|
||||
if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') {
|
||||
try {
|
||||
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
|
||||
} catch {
|
||||
// Branch deletion failed, not critical
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +77,8 @@ export function createDeleteHandler() {
|
||||
success: true,
|
||||
deleted: {
|
||||
worktreePath,
|
||||
branch: deleteBranch ? branchName : null,
|
||||
branch: branchDeleted ? branchName : null,
|
||||
branchDeleted,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
280
apps/server/src/routes/worktree/routes/init-script.ts
Normal file
280
apps/server/src/routes/worktree/routes/init-script.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 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,78 +1,40 @@
|
||||
/**
|
||||
* 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 { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { isAbsolute } from 'path';
|
||||
import {
|
||||
clearEditorCache,
|
||||
detectAllEditors,
|
||||
detectDefaultEditor,
|
||||
openInEditor,
|
||||
openInFileManager,
|
||||
} from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('open-in-editor');
|
||||
|
||||
// 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 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createGetDefaultEditorHandler() {
|
||||
@@ -93,11 +55,41 @@ 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 } = req.body as {
|
||||
const { worktreePath, editorCommand } = req.body as {
|
||||
worktreePath: string;
|
||||
editorCommand?: string;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
@@ -108,42 +100,44 @@ export function createOpenInEditorHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = await detectDefaultEditor();
|
||||
// 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;
|
||||
}
|
||||
|
||||
try {
|
||||
await execAsync(`${editor.command} "${worktreePath}"`);
|
||||
// Use the platform utility to open in editor
|
||||
const result = await openInEditor(worktreePath, editorCommand);
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened ${worktreePath} in ${editor.name}`,
|
||||
editorName: editor.name,
|
||||
message: `Opened ${worktreePath} in ${result.editorName}`,
|
||||
editorName: result.editorName,
|
||||
},
|
||||
});
|
||||
} catch (editorError) {
|
||||
// If the detected editor fails, try opening in default file manager as fallback
|
||||
const platform = process.platform;
|
||||
let openCommand: string;
|
||||
let fallbackName: string;
|
||||
// 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 (platform === 'darwin') {
|
||||
openCommand = `open "${worktreePath}"`;
|
||||
fallbackName = 'Finder';
|
||||
} else if (platform === 'win32') {
|
||||
openCommand = `explorer "${worktreePath}"`;
|
||||
fallbackName = 'Explorer';
|
||||
} else {
|
||||
openCommand = `xdg-open "${worktreePath}"`;
|
||||
fallbackName = 'File Manager';
|
||||
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;
|
||||
}
|
||||
|
||||
await execAsync(openCommand);
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened ${worktreePath} in ${fallbackName}`,
|
||||
editorName: fallbackName,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Open in editor failed');
|
||||
|
||||
@@ -308,13 +308,15 @@ 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'
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||
preEnhancementDescription?: string
|
||||
): Promise<Feature> {
|
||||
const feature = await this.get(projectPath, featureId);
|
||||
if (!feature) {
|
||||
@@ -338,9 +340,31 @@ 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: new Date().toISOString(),
|
||||
timestamp,
|
||||
source: descriptionHistorySource || 'edit',
|
||||
...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}),
|
||||
};
|
||||
|
||||
360
apps/server/src/services/init-script-service.ts
Normal file
360
apps/server/src/services/init-script-service.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* 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,6 +17,14 @@ 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', () => {
|
||||
@@ -184,8 +192,7 @@ describe('enhancement-prompts.ts', () => {
|
||||
});
|
||||
|
||||
it('should work with all enhancement modes', () => {
|
||||
const modes: EnhancementMode[] = ['improve', 'technical', 'simplify', 'acceptance'];
|
||||
modes.forEach((mode) => {
|
||||
ENHANCEMENT_MODES.forEach((mode) => {
|
||||
const prompt = buildUserPrompt(mode, testText);
|
||||
expect(prompt).toContain(testText);
|
||||
expect(prompt.length).toBeGreaterThan(100);
|
||||
@@ -205,6 +212,7 @@ 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', () => {
|
||||
@@ -216,13 +224,12 @@ describe('enhancement-prompts.ts', () => {
|
||||
});
|
||||
|
||||
describe('getAvailableEnhancementModes', () => {
|
||||
it('should return all four enhancement modes', () => {
|
||||
it('should return all enhancement modes', () => {
|
||||
const modes = getAvailableEnhancementModes();
|
||||
expect(modes).toHaveLength(4);
|
||||
expect(modes).toContain('improve');
|
||||
expect(modes).toContain('technical');
|
||||
expect(modes).toContain('simplify');
|
||||
expect(modes).toContain('acceptance');
|
||||
expect(modes).toHaveLength(ENHANCEMENT_MODES.length);
|
||||
ENHANCEMENT_MODES.forEach((mode) => {
|
||||
expect(modes).toContain(mode);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an array', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
OpencodeProvider,
|
||||
resetToolUseIdCounter,
|
||||
} from '../../../src/providers/opencode-provider.js';
|
||||
import type { ProviderMessage } from '@automaker/types';
|
||||
import type { ProviderMessage, ModelDefinition } from '@automaker/types';
|
||||
import { collectAsyncGenerator } from '../../utils/helpers.js';
|
||||
import { spawnJSONLProcess, getOpenCodeAuthIndicators } from '@automaker/platform';
|
||||
|
||||
@@ -51,63 +51,38 @@ describe('opencode-provider.ts', () => {
|
||||
});
|
||||
|
||||
describe('getAvailableModels', () => {
|
||||
it('should return 10 models', () => {
|
||||
it('should return 5 models', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
expect(models).toHaveLength(10);
|
||||
expect(models).toHaveLength(5);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('should include Big Pickle as default', () => {
|
||||
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 DeepSeek R1 (Bedrock)', () => {
|
||||
it('should include free tier GLM model', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
const deepseek = models.find((m) => m.id === 'amazon-bedrock/deepseek.r1-v1:0');
|
||||
const glm = models.find((m) => m.id === 'opencode/glm-4.7-free');
|
||||
|
||||
expect(deepseek).toBeDefined();
|
||||
expect(deepseek?.name).toBe('DeepSeek R1 (Bedrock)');
|
||||
expect(deepseek?.tier).toBe('premium');
|
||||
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');
|
||||
});
|
||||
|
||||
it('should have all models support tools', () => {
|
||||
@@ -128,6 +103,24 @@ 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);
|
||||
@@ -1243,7 +1236,7 @@ describe('opencode-provider.ts', () => {
|
||||
const defaultModels = models.filter((m) => m.default === true);
|
||||
|
||||
expect(defaultModels).toHaveLength(1);
|
||||
expect(defaultModels[0].id).toBe('amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0');
|
||||
expect(defaultModels[0].id).toBe('opencode/big-pickle');
|
||||
});
|
||||
|
||||
it('should have valid tier values for all models', () => {
|
||||
|
||||
@@ -5,59 +5,61 @@ 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(false, null);
|
||||
setRunningState(TEST_PROJECT_PATH, false, null);
|
||||
});
|
||||
|
||||
describe('setRunningState', () => {
|
||||
it('should set isRunning to true when running is true', () => {
|
||||
setRunningState(true);
|
||||
expect(getSpecRegenerationStatus().isRunning).toBe(true);
|
||||
setRunningState(TEST_PROJECT_PATH, true);
|
||||
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true);
|
||||
});
|
||||
|
||||
it('should set isRunning to false when running is false', () => {
|
||||
setRunningState(true);
|
||||
setRunningState(false);
|
||||
expect(getSpecRegenerationStatus().isRunning).toBe(false);
|
||||
setRunningState(TEST_PROJECT_PATH, true);
|
||||
setRunningState(TEST_PROJECT_PATH, false);
|
||||
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('should set currentAbortController when provided', () => {
|
||||
const controller = new AbortController();
|
||||
setRunningState(true, controller);
|
||||
expect(getSpecRegenerationStatus().currentAbortController).toBe(controller);
|
||||
setRunningState(TEST_PROJECT_PATH, true, controller);
|
||||
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller);
|
||||
});
|
||||
|
||||
it('should set currentAbortController to null when not provided', () => {
|
||||
const controller = new AbortController();
|
||||
setRunningState(true, controller);
|
||||
setRunningState(false);
|
||||
expect(getSpecRegenerationStatus().currentAbortController).toBe(null);
|
||||
setRunningState(TEST_PROJECT_PATH, true, controller);
|
||||
setRunningState(TEST_PROJECT_PATH, false);
|
||||
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(null);
|
||||
});
|
||||
|
||||
it('should set currentAbortController to null when explicitly passed null', () => {
|
||||
it('should keep currentAbortController when explicitly passed null while running', () => {
|
||||
const controller = new AbortController();
|
||||
setRunningState(true, controller);
|
||||
setRunningState(true, null);
|
||||
expect(getSpecRegenerationStatus().currentAbortController).toBe(null);
|
||||
setRunningState(TEST_PROJECT_PATH, true, controller);
|
||||
setRunningState(TEST_PROJECT_PATH, true, null);
|
||||
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller);
|
||||
});
|
||||
|
||||
it('should update state multiple times correctly', () => {
|
||||
const controller1 = new AbortController();
|
||||
const controller2 = new AbortController();
|
||||
|
||||
setRunningState(true, controller1);
|
||||
expect(getSpecRegenerationStatus().isRunning).toBe(true);
|
||||
expect(getSpecRegenerationStatus().currentAbortController).toBe(controller1);
|
||||
setRunningState(TEST_PROJECT_PATH, true, controller1);
|
||||
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true);
|
||||
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller1);
|
||||
|
||||
setRunningState(true, controller2);
|
||||
expect(getSpecRegenerationStatus().isRunning).toBe(true);
|
||||
expect(getSpecRegenerationStatus().currentAbortController).toBe(controller2);
|
||||
setRunningState(TEST_PROJECT_PATH, true, controller2);
|
||||
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true);
|
||||
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller2);
|
||||
|
||||
setRunningState(false, null);
|
||||
expect(getSpecRegenerationStatus().isRunning).toBe(false);
|
||||
expect(getSpecRegenerationStatus().currentAbortController).toBe(null);
|
||||
setRunningState(TEST_PROJECT_PATH, false, null);
|
||||
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(false);
|
||||
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.9.0",
|
||||
"version": "0.10.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,6 +42,8 @@
|
||||
"@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",
|
||||
|
||||
220
apps/ui/src/components/icons/editor-icons.tsx
Normal file
220
apps/ui/src/components/icons/editor-icons.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
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,110 +257,122 @@ export function Sidebar() {
|
||||
};
|
||||
|
||||
return (
|
||||
<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'
|
||||
<>
|
||||
{/* Mobile overlay backdrop */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||
onClick={toggleSidebar}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
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
|
||||
<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
|
||||
sidebarOpen={sidebarOpen}
|
||||
isProjectPickerOpen={isProjectPickerOpen}
|
||||
setIsProjectPickerOpen={setIsProjectPickerOpen}
|
||||
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
|
||||
toggleSidebar={toggleSidebar}
|
||||
shortcut={shortcuts.toggleSidebar}
|
||||
/>
|
||||
|
||||
<SidebarNavigation
|
||||
currentProject={currentProject}
|
||||
<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
|
||||
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>
|
||||
|
||||
<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}
|
||||
/>
|
||||
{/* 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."
|
||||
/>
|
||||
|
||||
{/* 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."
|
||||
/>
|
||||
<OnboardingDialog
|
||||
open={showOnboardingDialog}
|
||||
onOpenChange={setShowOnboardingDialog}
|
||||
newProjectName={newProjectName}
|
||||
onSkip={handleOnboardingSkip}
|
||||
onGenerateSpec={handleOnboardingGenerateSpec}
|
||||
/>
|
||||
|
||||
<OnboardingDialog
|
||||
open={showOnboardingDialog}
|
||||
onOpenChange={setShowOnboardingDialog}
|
||||
newProjectName={newProjectName}
|
||||
onSkip={handleOnboardingSkip}
|
||||
onGenerateSpec={handleOnboardingGenerateSpec}
|
||||
/>
|
||||
{/* Delete Project Confirmation Dialog */}
|
||||
<DeleteProjectDialog
|
||||
open={showDeleteProjectDialog}
|
||||
onOpenChange={setShowDeleteProjectDialog}
|
||||
project={currentProject}
|
||||
onConfirm={moveProjectToTrash}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
{/* 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 - shown when sidebar is closed OR on small screens when sidebar is open */}
|
||||
{/* Collapsed logo - only shown when sidebar is closed */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center rounded-lg gap-0.5',
|
||||
sidebarOpen ? 'flex lg:hidden' : 'flex'
|
||||
sidebarOpen ? 'hidden' : 'flex'
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
@@ -90,16 +90,16 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Expanded logo - only shown when sidebar is open on large screens */}
|
||||
{/* Expanded logo - shown when sidebar is open */}
|
||||
{sidebarOpen && (
|
||||
<div className="hidden lg:flex flex-col">
|
||||
<div className="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-[36.8px] w-[36.8px] group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||
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"
|
||||
>
|
||||
<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-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
|
||||
<span className="font-bold text-foreground text-xl lg: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-[38.8px]">
|
||||
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium ml-9 lg:ml-[38.8px]">
|
||||
v{appVersion} {versionSuffix}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,9 @@ export function CollapseToggleButton({
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
'hidden lg:flex absolute top-[68px] -right-3 z-9999',
|
||||
// Show on desktop always, show on mobile only when sidebar is open
|
||||
sidebarOpen ? 'flex' : '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 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"
|
||||
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"
|
||||
data-testid="project-picker-shortcut"
|
||||
>
|
||||
{formatShortcut(shortcuts.projectPicker, true)}
|
||||
@@ -219,7 +219,7 @@ export function ProjectSelectorWithOptions({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'hidden lg:flex items-center justify-center w-[42px] h-[42px] rounded-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 ? 'hidden lg:block' : 'hidden'
|
||||
sidebarOpen ? 'block' : 'hidden'
|
||||
)}
|
||||
>
|
||||
Wiki
|
||||
@@ -148,7 +148,7 @@ export function SidebarFooter({
|
||||
<span
|
||||
className={cn(
|
||||
'ml-3 font-medium text-sm flex-1 text-left',
|
||||
sidebarOpen ? 'hidden lg:block' : 'hidden'
|
||||
sidebarOpen ? 'block' : 'hidden'
|
||||
)}
|
||||
>
|
||||
Running Agents
|
||||
@@ -157,7 +157,7 @@ export function SidebarFooter({
|
||||
{sidebarOpen && runningAgentsCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'hidden lg:flex items-center justify-center',
|
||||
'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 ? 'hidden lg:block' : 'hidden'
|
||||
sidebarOpen ? 'block' : 'hidden'
|
||||
)}
|
||||
>
|
||||
Settings
|
||||
@@ -235,7 +235,7 @@ export function SidebarFooter({
|
||||
{sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
'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',
|
||||
'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',
|
||||
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-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',
|
||||
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',
|
||||
// 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="hidden lg:block px-3 mb-2">
|
||||
<div className="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 ? 'hidden lg:block' : 'hidden'
|
||||
sidebarOpen ? 'block' : 'hidden'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
@@ -124,7 +124,7 @@ export function SidebarNavigation({
|
||||
{item.count !== undefined && item.count > 0 && sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
'hidden lg:flex items-center justify-center',
|
||||
'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 lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
||||
'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',
|
||||
isActive
|
||||
? 'bg-brand-500/20 text-brand-400'
|
||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||
|
||||
276
apps/ui/src/components/ui/ansi-output.tsx
Normal file
276
apps/ui/src/components/ui/ansi-output.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
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,6 +6,7 @@ import { getProviderFromModel } from '@/lib/utils';
|
||||
const PROVIDER_ICON_KEYS = {
|
||||
anthropic: 'anthropic',
|
||||
openai: 'openai',
|
||||
openrouter: 'openrouter',
|
||||
cursor: 'cursor',
|
||||
gemini: 'gemini',
|
||||
grok: 'grok',
|
||||
@@ -41,6 +42,12 @@ 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
|
||||
@@ -151,6 +158,10 @@ 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} />;
|
||||
}
|
||||
@@ -395,6 +406,11 @@ 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
|
||||
@@ -442,6 +458,71 @@ 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';
|
||||
@@ -475,6 +556,7 @@ export function getProviderIconForModel(
|
||||
const iconMap: Record<ProviderIconKey, ComponentType<{ className?: string }>> = {
|
||||
anthropic: AnthropicIcon,
|
||||
openai: OpenAIIcon,
|
||||
openrouter: OpenRouterIcon,
|
||||
cursor: CursorIcon,
|
||||
gemini: GeminiIcon,
|
||||
grok: GrokIcon,
|
||||
|
||||
142
apps/ui/src/components/ui/shell-syntax-editor.tsx
Normal file
142
apps/ui/src/components/ui/shell-syntax-editor.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -75,6 +75,8 @@ import {
|
||||
} from './board-view/hooks';
|
||||
import { SelectionActionBar } 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';
|
||||
|
||||
// Stable empty array to avoid infinite loop in selector
|
||||
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
|
||||
@@ -99,6 +101,8 @@ export function BoardView() {
|
||||
useWorktrees,
|
||||
enableDependencyBlocking,
|
||||
skipVerificationInAutoMode,
|
||||
planUseSelectedWorktreeBranch,
|
||||
addFeatureUseSelectedWorktreeBranch,
|
||||
isPrimaryWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
setPipelineConfig,
|
||||
@@ -107,6 +111,12 @@ 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,
|
||||
@@ -161,12 +171,14 @@ export function BoardView() {
|
||||
followUpPrompt,
|
||||
followUpImagePaths,
|
||||
followUpPreviewMap,
|
||||
followUpPromptHistory,
|
||||
setShowFollowUpDialog,
|
||||
setFollowUpFeature,
|
||||
setFollowUpPrompt,
|
||||
setFollowUpImagePaths,
|
||||
setFollowUpPreviewMap,
|
||||
handleFollowUpDialogChange,
|
||||
addToPromptHistory,
|
||||
} = useFollowUpState();
|
||||
|
||||
// Selection mode hook for mass editing
|
||||
@@ -250,6 +262,9 @@ 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
|
||||
@@ -1287,6 +1302,8 @@ export function BoardView() {
|
||||
selectedFeatureIds={selectedFeatureIds}
|
||||
onToggleFeatureSelection={toggleFeatureSelection}
|
||||
onToggleSelectionMode={toggleSelectionMode}
|
||||
isDragging={activeFeature !== null}
|
||||
onAiSuggest={() => setShowPlanDialog(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1357,6 +1374,14 @@ 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 */}
|
||||
@@ -1422,6 +1447,8 @@ export function BoardView() {
|
||||
onPreviewMapChange={setFollowUpPreviewMap}
|
||||
onSend={handleSendFollowUp}
|
||||
isMaximized={isMaximized}
|
||||
promptHistory={followUpPromptHistory}
|
||||
onHistoryAdd={addToPromptHistory}
|
||||
/>
|
||||
|
||||
{/* Backlog Plan Dialog */}
|
||||
@@ -1434,6 +1461,7 @@ export function BoardView() {
|
||||
setPendingPlanResult={setPendingBacklogPlan}
|
||||
isGeneratingPlan={isGeneratingPlan}
|
||||
setIsGeneratingPlan={setIsGeneratingPlan}
|
||||
currentBranch={planUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined}
|
||||
/>
|
||||
|
||||
{/* Plan Approval Dialog */}
|
||||
@@ -1501,6 +1529,7 @@ 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) => {
|
||||
@@ -1568,6 +1597,11 @@ export function BoardView() {
|
||||
setSelectedWorktreeForAction(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Init Script Indicator - floating overlay for worktree init script status */}
|
||||
{getShowInitScriptIndicator(currentProject.path) && (
|
||||
<InitScriptIndicator projectPath={currentProject.path} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ 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';
|
||||
@@ -55,10 +57,22 @@ export function BoardHeader({
|
||||
completedCount,
|
||||
}: 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)
|
||||
@@ -132,9 +146,25 @@ 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>
|
||||
@@ -209,15 +239,33 @@ export function BoardHeader({
|
||||
onSkipVerificationChange={setSkipVerificationInAutoMode}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onOpenPlanDialog}
|
||||
data-testid="plan-backlog-button"
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
Plan
|
||||
</Button>
|
||||
{/* 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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
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,3 +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';
|
||||
|
||||
@@ -3,6 +3,69 @@ 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;
|
||||
|
||||
@@ -21,11 +21,9 @@ import {
|
||||
FeatureTextFilePath as DescriptionTextFilePath,
|
||||
ImagePreviewMap,
|
||||
} from '@/components/ui/description-image-dropzone';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Sparkles, ChevronDown, ChevronRight, Play, Cpu, FolderKanban } from 'lucide-react';
|
||||
import { 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,
|
||||
@@ -43,16 +41,12 @@ 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,
|
||||
@@ -62,6 +56,32 @@ 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;
|
||||
@@ -95,6 +115,23 @@ 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({
|
||||
@@ -111,6 +148,8 @@ export function AddFeatureDialog({
|
||||
isMaximized,
|
||||
parentFeature = null,
|
||||
allFeatures = [],
|
||||
selectedNonMainWorktreeBranch,
|
||||
forceCurrentBranchMode,
|
||||
}: AddFeatureDialogProps) {
|
||||
const isSpawnMode = !!parentFeature;
|
||||
const [workMode, setWorkMode] = useState<WorkMode>('current');
|
||||
@@ -139,21 +178,16 @@ export function AddFeatureDialog({
|
||||
// UI state
|
||||
const [previewMap, setPreviewMap] = useState<ImagePreviewMap>(() => new Map());
|
||||
const [descriptionError, setDescriptionError] = useState(false);
|
||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
||||
const [enhancementMode, setEnhancementMode] = useState<
|
||||
'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
||||
>('improve');
|
||||
const [enhanceOpen, setEnhanceOpen] = useState(false);
|
||||
|
||||
// Description history state
|
||||
const [descriptionHistory, setDescriptionHistory] = useState<DescriptionHistoryEntry[]>([]);
|
||||
|
||||
// Spawn mode state
|
||||
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
|
||||
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Get defaults from store
|
||||
const { defaultPlanningMode, defaultRequirePlanApproval } = useAppStore();
|
||||
|
||||
// Enhancement model override
|
||||
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
|
||||
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
|
||||
|
||||
// Track previous open state to detect when dialog opens
|
||||
const wasOpenRef = useRef(false);
|
||||
@@ -165,12 +199,19 @@ export function AddFeatureDialog({
|
||||
|
||||
if (justOpened) {
|
||||
setSkipTests(defaultSkipTests);
|
||||
setBranchName(defaultBranch || '');
|
||||
setWorkMode('current');
|
||||
// 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)
|
||||
);
|
||||
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);
|
||||
@@ -187,6 +228,9 @@ export function AddFeatureDialog({
|
||||
defaultBranch,
|
||||
defaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
useWorktrees,
|
||||
selectedNonMainWorktreeBranch,
|
||||
forceCurrentBranchMode,
|
||||
parentFeature,
|
||||
allFeatures,
|
||||
]);
|
||||
@@ -271,15 +315,18 @@ export function AddFeatureDialog({
|
||||
setImagePaths([]);
|
||||
setTextFilePaths([]);
|
||||
setSkipTests(defaultSkipTests);
|
||||
setBranchName('');
|
||||
// When a non-main worktree is selected, use its branch name for custom mode
|
||||
setBranchName(selectedNonMainWorktreeBranch || '');
|
||||
setPriority(2);
|
||||
setModelEntry({ model: 'opus' });
|
||||
setWorkMode('current');
|
||||
setWorkMode(
|
||||
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
|
||||
);
|
||||
setPlanningMode(defaultPlanningMode);
|
||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||
setPreviewMap(new Map());
|
||||
setDescriptionError(false);
|
||||
setEnhanceOpen(false);
|
||||
setDescriptionHistory([]);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -302,33 +349,6 @@ 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';
|
||||
@@ -380,7 +400,18 @@ export function AddFeatureDialog({
|
||||
{/* Task Details Section */}
|
||||
<div className={cardClass}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<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>
|
||||
<DescriptionImageDropZone
|
||||
value={description}
|
||||
onChange={(value) => {
|
||||
@@ -409,75 +440,35 @@ export function AddFeatureDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{/* 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;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI & Execution Section */}
|
||||
|
||||
@@ -63,6 +63,8 @@ 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';
|
||||
@@ -76,6 +78,7 @@ export function BacklogPlanDialog({
|
||||
setPendingPlanResult,
|
||||
isGeneratingPlan,
|
||||
setIsGeneratingPlan,
|
||||
currentBranch,
|
||||
}: BacklogPlanDialogProps) {
|
||||
const [mode, setMode] = useState<DialogMode>('input');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
@@ -167,7 +170,11 @@ export function BacklogPlanDialog({
|
||||
}) || [],
|
||||
};
|
||||
|
||||
const result = await api.backlogPlan.apply(projectPath, filteredPlanResult);
|
||||
const result = await api.backlogPlan.apply(
|
||||
projectPath,
|
||||
filteredPlanResult,
|
||||
currentBranch ?? 'main'
|
||||
);
|
||||
if (result.success) {
|
||||
toast.success(`Applied ${result.appliedChanges?.length || 0} changes`);
|
||||
setPendingPlanResult(null);
|
||||
@@ -184,6 +191,7 @@ export function BacklogPlanDialog({
|
||||
setPendingPlanResult,
|
||||
onPlanApplied,
|
||||
onClose,
|
||||
currentBranch,
|
||||
]);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
|
||||
@@ -10,10 +10,73 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { GitBranch, Loader2 } from 'lucide-react';
|
||||
import { GitBranch, Loader2, AlertCircle } 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;
|
||||
@@ -34,20 +97,21 @@ export function CreateWorktreeDialog({
|
||||
}: CreateWorktreeDialogProps) {
|
||||
const [branchName, setBranchName] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<{ title: string; description?: string } | null>(null);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!branchName.trim()) {
|
||||
setError('Branch name is required');
|
||||
setError({ title: 'Branch name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate branch name (git-compatible)
|
||||
const validBranchRegex = /^[a-zA-Z0-9._/-]+$/;
|
||||
if (!validBranchRegex.test(branchName)) {
|
||||
setError(
|
||||
'Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes.'
|
||||
);
|
||||
setError({
|
||||
title: 'Invalid branch name',
|
||||
description: 'Use only letters, numbers, dots, underscores, hyphens, and slashes.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,7 +121,7 @@ export function CreateWorktreeDialog({
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.create) {
|
||||
setError('Worktree API not available');
|
||||
setError({ title: 'Worktree API not available' });
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.create(projectPath, branchName);
|
||||
@@ -70,10 +134,12 @@ export function CreateWorktreeDialog({
|
||||
onOpenChange(false);
|
||||
setBranchName('');
|
||||
} else {
|
||||
setError(result.error || 'Failed to create worktree');
|
||||
setError(parseWorktreeError(result.error || 'Failed to create worktree'));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create worktree');
|
||||
setError(
|
||||
parseWorktreeError(err instanceof Error ? err.message : 'Failed to create worktree')
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -114,7 +180,17 @@ export function CreateWorktreeDialog({
|
||||
className="font-mono text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -30,6 +30,8 @@ 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({
|
||||
@@ -39,10 +41,18 @@ export function DeleteWorktreeDialog({
|
||||
worktree,
|
||||
onDeleted,
|
||||
affectedFeatureCount = 0,
|
||||
defaultDeleteBranch = false,
|
||||
}: DeleteWorktreeDialogProps) {
|
||||
const [deleteBranch, setDeleteBranch] = useState(false);
|
||||
const [deleteBranch, setDeleteBranch] = useState(defaultDeleteBranch);
|
||||
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,18 +21,8 @@ import {
|
||||
FeatureTextFilePath as DescriptionTextFilePath,
|
||||
ImagePreviewMap,
|
||||
} from '@/components/ui/description-image-dropzone';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import {
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
GitBranch,
|
||||
History,
|
||||
Cpu,
|
||||
FolderKanban,
|
||||
} from 'lucide-react';
|
||||
import { GitBranch, 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';
|
||||
@@ -41,17 +31,12 @@ 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';
|
||||
@@ -79,7 +64,8 @@ interface EditFeatureDialogProps {
|
||||
requirePlanApproval: boolean;
|
||||
},
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
||||
enhancementMode?: EnhancementMode,
|
||||
preEnhancementDescription?: string
|
||||
) => void;
|
||||
categorySuggestions: string[];
|
||||
branchSuggestions: string[];
|
||||
@@ -110,11 +96,6 @@ 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(
|
||||
@@ -133,15 +114,16 @@ export function EditFeatureDialog({
|
||||
|
||||
// Track the source of description changes for history
|
||||
const [descriptionChangeSource, setDescriptionChangeSource] = useState<
|
||||
{ source: 'enhance'; mode: 'improve' | 'technical' | 'simplify' | 'acceptance' } | 'edit' | null
|
||||
{ source: 'enhance'; mode: EnhancementMode } | 'edit' | null
|
||||
>(null);
|
||||
// Track the original description when the dialog opened for comparison
|
||||
const [originalDescription, setOriginalDescription] = useState(feature?.description ?? '');
|
||||
// Track if history dropdown is open
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
// Enhancement model override
|
||||
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
|
||||
// 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 ?? []
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEditingFeature(feature);
|
||||
@@ -153,8 +135,8 @@ export function EditFeatureDialog({
|
||||
// Reset history tracking state
|
||||
setOriginalDescription(feature.description ?? '');
|
||||
setDescriptionChangeSource(null);
|
||||
setShowHistory(false);
|
||||
setEnhanceOpen(false);
|
||||
setPreEnhancementDescription(null);
|
||||
setLocalHistory(feature.descriptionHistory ?? []);
|
||||
// Reset model entry
|
||||
setModelEntry({
|
||||
model: (feature.model as ModelAlias) || 'opus',
|
||||
@@ -164,7 +146,8 @@ export function EditFeatureDialog({
|
||||
} else {
|
||||
setEditFeaturePreviewMap(new Map());
|
||||
setDescriptionChangeSource(null);
|
||||
setShowHistory(false);
|
||||
setPreEnhancementDescription(null);
|
||||
setLocalHistory([]);
|
||||
}
|
||||
}, [feature]);
|
||||
|
||||
@@ -226,7 +209,13 @@ export function EditFeatureDialog({
|
||||
}
|
||||
}
|
||||
|
||||
onUpdate(editingFeature.id, updates, historySource, historyEnhancementMode);
|
||||
onUpdate(
|
||||
editingFeature.id,
|
||||
updates,
|
||||
historySource,
|
||||
historyEnhancementMode,
|
||||
preEnhancementDescription ?? undefined
|
||||
);
|
||||
setEditFeaturePreviewMap(new Map());
|
||||
onClose();
|
||||
};
|
||||
@@ -237,36 +226,6 @@ 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;
|
||||
}
|
||||
@@ -304,95 +263,18 @@ export function EditFeatureDialog({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="edit-description">Description</Label>
|
||||
{/* 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>
|
||||
)}
|
||||
{/* 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"
|
||||
/>
|
||||
</div>
|
||||
<DescriptionImageDropZone
|
||||
value={editingFeature.description}
|
||||
@@ -443,75 +325,40 @@ export function EditFeatureDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{/* 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);
|
||||
|
||||
<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>
|
||||
// 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;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI & Execution Section */}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -17,6 +18,21 @@ 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;
|
||||
@@ -30,6 +46,10 @@ 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({
|
||||
@@ -44,9 +64,11 @@ export function FollowUpDialog({
|
||||
onPreviewMapChange,
|
||||
onSend,
|
||||
isMaximized,
|
||||
promptHistory = [],
|
||||
onHistoryAdd,
|
||||
}: FollowUpDialogProps) {
|
||||
const handleClose = (open: boolean) => {
|
||||
if (!open) {
|
||||
const handleClose = (openState: boolean) => {
|
||||
if (!openState) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
@@ -77,7 +99,18 @@ export function FollowUpDialog({
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="follow-up-prompt">Instructions</Label>
|
||||
<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>
|
||||
<DescriptionImageDropZone
|
||||
value={prompt}
|
||||
onChange={onPromptChange}
|
||||
@@ -88,6 +121,33 @@ 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 } from './follow-up-dialog';
|
||||
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
|
||||
export { PlanApprovalDialog } from './plan-approval-dialog';
|
||||
export { MassEditDialog } from './mass-edit-dialog';
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,8 @@ interface UseBoardActionsProps {
|
||||
featureId: string,
|
||||
updates: Partial<Feature>,
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||
preEnhancementDescription?: string
|
||||
) => Promise<void>;
|
||||
persistFeatureDelete: (featureId: string) => Promise<void>;
|
||||
saveCategory: (category: string) => Promise<void>;
|
||||
@@ -251,7 +252,8 @@ export function useBoardActions({
|
||||
workMode?: 'current' | 'auto' | 'custom';
|
||||
},
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||
preEnhancementDescription?: string
|
||||
) => {
|
||||
const workMode = updates.workMode || 'current';
|
||||
|
||||
@@ -308,7 +310,13 @@ export function useBoardActions({
|
||||
};
|
||||
|
||||
updateFeature(featureId, finalUpdates);
|
||||
persistFeatureUpdate(featureId, finalUpdates, descriptionHistorySource, enhancementMode);
|
||||
persistFeatureUpdate(
|
||||
featureId,
|
||||
finalUpdates,
|
||||
descriptionHistorySource,
|
||||
enhancementMode,
|
||||
preEnhancementDescription
|
||||
);
|
||||
if (updates.category) {
|
||||
saveCategory(updates.category);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
featureId: string,
|
||||
updates: Partial<Feature>,
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||
preEnhancementDescription?: string
|
||||
) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
@@ -35,7 +36,8 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
featureId,
|
||||
updates,
|
||||
descriptionHistorySource,
|
||||
enhancementMode
|
||||
enhancementMode,
|
||||
preEnhancementDescription
|
||||
);
|
||||
if (result.success && result.feature) {
|
||||
updateFeature(result.feature.id, result.feature);
|
||||
|
||||
@@ -4,13 +4,18 @@ 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);
|
||||
@@ -18,6 +23,7 @@ export function useFollowUpState() {
|
||||
setFollowUpPrompt('');
|
||||
setFollowUpImagePaths([]);
|
||||
setFollowUpPreviewMap(new Map());
|
||||
setFollowUpPromptHistory([]);
|
||||
}, []);
|
||||
|
||||
const handleFollowUpDialogChange = useCallback(
|
||||
@@ -31,6 +37,13 @@ export function useFollowUpState() {
|
||||
[resetFollowUpState]
|
||||
);
|
||||
|
||||
/**
|
||||
* Adds a new entry to the prompt history
|
||||
*/
|
||||
const addToPromptHistory = useCallback((entry: FollowUpHistoryEntry) => {
|
||||
setFollowUpPromptHistory((prev) => [...prev, entry]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
showFollowUpDialog,
|
||||
@@ -38,14 +51,17 @@ export function useFollowUpState() {
|
||||
followUpPrompt,
|
||||
followUpImagePaths,
|
||||
followUpPreviewMap,
|
||||
followUpPromptHistory,
|
||||
// Setters
|
||||
setShowFollowUpDialog,
|
||||
setFollowUpFeature,
|
||||
setFollowUpPrompt,
|
||||
setFollowUpImagePaths,
|
||||
setFollowUpPreviewMap,
|
||||
setFollowUpPromptHistory,
|
||||
// Helpers
|
||||
resetFollowUpState,
|
||||
handleFollowUpDialogChange,
|
||||
addToPromptHistory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
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 } from './components';
|
||||
import { KanbanColumn, KanbanCard, EmptyStateCard } 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,6 +51,12 @@ 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({
|
||||
@@ -86,6 +92,9 @@ 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]);
|
||||
@@ -211,6 +220,26 @@ 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;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** 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',
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './enhancement-constants';
|
||||
export * from './enhance-with-ai';
|
||||
export * from './enhancement-history-button';
|
||||
@@ -10,3 +10,4 @@ export * from './planning-mode-selector';
|
||||
export * from './planning-mode-select';
|
||||
export * from './ancestor-context-section';
|
||||
export * from './work-mode-selector';
|
||||
export * from './enhancement';
|
||||
|
||||
@@ -6,13 +6,15 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Trash2,
|
||||
MoreHorizontal,
|
||||
GitCommit,
|
||||
GitPullRequest,
|
||||
ExternalLink,
|
||||
Download,
|
||||
Upload,
|
||||
Play,
|
||||
@@ -21,15 +23,19 @@ 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;
|
||||
@@ -41,7 +47,7 @@ interface WorktreeActionsDropdownProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
@@ -50,12 +56,13 @@ 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,
|
||||
@@ -76,7 +83,23 @@ 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;
|
||||
|
||||
@@ -200,10 +223,60 @@ export function WorktreeActionsDropdown({
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onOpenInEditor(worktree)} className="text-xs">
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-2" />
|
||||
Open in {defaultEditorName}
|
||||
</DropdownMenuItem>
|
||||
{/* 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>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{worktree.hasChanges && (
|
||||
<TooltipWrapper
|
||||
|
||||
@@ -17,7 +17,6 @@ interface WorktreeTabProps {
|
||||
isActivating: boolean;
|
||||
isDevServerRunning: boolean;
|
||||
devServerInfo?: DevServerInfo;
|
||||
defaultEditorName: string;
|
||||
branches: BranchInfo[];
|
||||
filteredBranches: BranchInfo[];
|
||||
branchFilter: string;
|
||||
@@ -37,7 +36,7 @@ interface WorktreeTabProps {
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
@@ -46,6 +45,8 @@ interface WorktreeTabProps {
|
||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
}
|
||||
|
||||
export function WorktreeTab({
|
||||
@@ -58,7 +59,6 @@ export function WorktreeTab({
|
||||
isActivating,
|
||||
isDevServerRunning,
|
||||
devServerInfo,
|
||||
defaultEditorName,
|
||||
branches,
|
||||
filteredBranches,
|
||||
branchFilter,
|
||||
@@ -87,6 +87,8 @@ export function WorktreeTab({
|
||||
onStartDevServer,
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
onRunInitScript,
|
||||
hasInitScript,
|
||||
}: WorktreeTabProps) {
|
||||
let prBadge: JSX.Element | null = null;
|
||||
if (worktree.pr) {
|
||||
@@ -315,7 +317,6 @@ export function WorktreeTab({
|
||||
<WorktreeActionsDropdown
|
||||
worktree={worktree}
|
||||
isSelected={isSelected}
|
||||
defaultEditorName={defaultEditorName}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
isPulling={isPulling}
|
||||
@@ -336,6 +337,8 @@ 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';
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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) => {
|
||||
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => {
|
||||
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);
|
||||
const result = await api.worktree.openInEditor(worktree.path, editorCommand);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
} else if (result.error) {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useCallback, useState } 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';
|
||||
@@ -75,13 +76,33 @@ 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);
|
||||
@@ -116,6 +137,33 @@ 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);
|
||||
|
||||
@@ -137,7 +185,6 @@ export function WorktreePanel({
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(mainWorktree)}
|
||||
devServerInfo={getDevServerInfo(mainWorktree)}
|
||||
defaultEditorName={defaultEditorName}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
@@ -166,6 +213,8 @@ export function WorktreePanel({
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -192,7 +241,6 @@ export function WorktreePanel({
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(worktree)}
|
||||
devServerInfo={getDevServerInfo(worktree)}
|
||||
defaultEditorName={defaultEditorName}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
@@ -221,6 +269,8 @@ 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-8 py-4 flex items-center justify-between">
|
||||
<div className="px-4 sm:px-8 py-4 flex items-center justify-between">
|
||||
<div
|
||||
className="flex items-center gap-3 cursor-pointer group titlebar-no-drag"
|
||||
className="flex items-center gap-2 sm: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-10 group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||
className="size-8 sm: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-2xl tracking-tight leading-none">
|
||||
<span className="font-bold text-foreground text-xl sm: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,30 +541,32 @@ export function DashboardView() {
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<div className="flex-1 overflow-y-auto p-4 sm: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-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">
|
||||
<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">
|
||||
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-6 max-w-3xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm: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-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 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>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-1.5">
|
||||
@@ -578,7 +580,7 @@ export function DashboardView() {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
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"
|
||||
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"
|
||||
data-testid="create-new-project"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
@@ -613,10 +615,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-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 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>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-1.5">
|
||||
@@ -629,7 +631,7 @@ export function DashboardView() {
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full mt-5 bg-secondary/80 hover:bg-secondary text-foreground border border-border hover:border-blue-500/30 transition-all"
|
||||
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"
|
||||
data-testid="open-existing-project"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 mr-2" />
|
||||
@@ -643,21 +645,26 @@ export function DashboardView() {
|
||||
|
||||
{/* Has projects - show project list */}
|
||||
{hasProjects && (
|
||||
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="space-y-6 sm:space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
{/* Quick actions header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h2 className="text-2xl font-bold text-foreground">Your Projects</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleOpenProject}>
|
||||
<FolderOpen className="w-4 h-4 mr-2" />
|
||||
Open Folder
|
||||
<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>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<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 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>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
@@ -677,13 +684,15 @@ export function DashboardView() {
|
||||
{/* Favorites section */}
|
||||
{favoriteProjects.length > 0 && (
|
||||
<div>
|
||||
<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 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>
|
||||
<h3 className="text-lg font-semibold text-foreground">Favorites</h3>
|
||||
<h3 className="text-base sm:text-lg font-semibold text-foreground">
|
||||
Favorites
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
|
||||
{favoriteProjects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
@@ -692,40 +701,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-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 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>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-foreground truncate group-hover:text-yellow-500 transition-colors duration-300">
|
||||
<p className="text-sm sm:text-base 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-1">
|
||||
<p className="text-xs text-muted-foreground/70 truncate mt-0.5 sm:mt-1">
|
||||
{project.path}
|
||||
</p>
|
||||
{project.lastOpened && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
<p className="text-xs text-muted-foreground mt-1 sm:mt-1.5">
|
||||
{new Date(project.lastOpened).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-0.5 sm:gap-1">
|
||||
<button
|
||||
onClick={(e) => handleToggleFavorite(e, project.id)}
|
||||
className="p-1.5 rounded-lg hover:bg-yellow-500/20 transition-colors"
|
||||
className="p-1 sm:p-1.5 rounded-lg hover:bg-yellow-500/20 transition-colors"
|
||||
title="Remove from favorites"
|
||||
>
|
||||
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
|
||||
<Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-yellow-500 fill-yellow-500" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
|
||||
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="More options"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4 text-muted-foreground" />
|
||||
<MoreVertical className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
@@ -750,13 +759,15 @@ export function DashboardView() {
|
||||
{/* Recent projects section */}
|
||||
{recentProjects.length > 0 && (
|
||||
<div>
|
||||
<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 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>
|
||||
<h3 className="text-lg font-semibold text-foreground">Recent Projects</h3>
|
||||
<h3 className="text-base sm: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-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
|
||||
{recentProjects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
@@ -765,40 +776,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-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 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>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-foreground truncate group-hover:text-brand-500 transition-colors duration-300">
|
||||
<p className="text-sm sm:text-base 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-1">
|
||||
<p className="text-xs text-muted-foreground/70 truncate mt-0.5 sm:mt-1">
|
||||
{project.path}
|
||||
</p>
|
||||
{project.lastOpened && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
<p className="text-xs text-muted-foreground mt-1 sm:mt-1.5">
|
||||
{new Date(project.lastOpened).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-0.5 sm:gap-1">
|
||||
<button
|
||||
onClick={(e) => handleToggleFavorite(e, project.id)}
|
||||
className="p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
|
||||
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Add to favorites"
|
||||
>
|
||||
<Star className="w-4 h-4 text-muted-foreground hover:text-yellow-500" />
|
||||
<Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground hover:text-yellow-500" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
|
||||
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="More options"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4 text-muted-foreground" />
|
||||
<MoreVertical className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
|
||||
@@ -13,7 +13,15 @@
|
||||
|
||||
import { useReducer, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { login, getHttpApiClient, getServerUrlSync } from '@/lib/http-api-client';
|
||||
import {
|
||||
login,
|
||||
getHttpApiClient,
|
||||
getServerUrlSync,
|
||||
getApiKey,
|
||||
getSessionToken,
|
||||
initApiKey,
|
||||
waitForApiKeyInit,
|
||||
} 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';
|
||||
@@ -92,6 +100,7 @@ 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)
|
||||
@@ -102,7 +111,9 @@ const BACKOFF_BASE_MS = 400;
|
||||
* Unlike the httpClient methods, this does NOT call handleUnauthorized()
|
||||
* which would navigate us away to /logged-out.
|
||||
*
|
||||
* Relies on HTTP-only session cookie being sent via credentials: 'include'.
|
||||
* Supports both:
|
||||
* - Electron mode: Uses X-API-Key header (API key from IPC)
|
||||
* - Web mode: Uses HTTP-only session cookie
|
||||
*
|
||||
* Returns: { authenticated: true } or { authenticated: false }
|
||||
* Throws: on network errors (for retry logic)
|
||||
@@ -110,9 +121,31 @@ const BACKOFF_BASE_MS = 400;
|
||||
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`, {
|
||||
credentials: 'include', // Send HTTP-only session cookie
|
||||
headers,
|
||||
credentials: 'include',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
cache: NO_STORE_CACHE_MODE,
|
||||
});
|
||||
|
||||
// Any response means server is reachable
|
||||
@@ -246,6 +279,14 @@ 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,6 +15,7 @@ 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';
|
||||
@@ -149,17 +150,19 @@ 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,15 +1,51 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LogOut, User } from 'lucide-react';
|
||||
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 { 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 {
|
||||
@@ -43,6 +79,81 @@ 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,9 +1,40 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle2, AlertCircle, RefreshCw, Bot, Cloud } 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
|
||||
@@ -38,6 +69,7 @@ function getAuthMethodLabel(method: OpencodeAuthMethod): string {
|
||||
interface OpencodeCliStatusProps {
|
||||
status: CliStatus | null;
|
||||
authStatus?: OpencodeAuthStatus | null;
|
||||
providers?: OpenCodeProviderInfo[];
|
||||
isChecking: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
@@ -152,9 +184,11 @@ export function OpencodeModelConfigSkeleton() {
|
||||
export function OpencodeCliStatus({
|
||||
status,
|
||||
authStatus,
|
||||
providers = [],
|
||||
isChecking,
|
||||
onRefresh,
|
||||
}: OpencodeCliStatusProps) {
|
||||
const authenticatedProviders = providers.filter((p) => p.authenticated);
|
||||
if (!status) return <OpencodeCliStatusSkeleton />;
|
||||
|
||||
return (
|
||||
@@ -223,8 +257,8 @@ export function OpencodeCliStatus({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication Status */}
|
||||
{authStatus?.authenticated ? (
|
||||
{/* Authentication Status - consider both direct auth and provider auth */}
|
||||
{authStatus?.authenticated || 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">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||
@@ -232,24 +266,71 @@ 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">
|
||||
<p>
|
||||
Method:{' '}
|
||||
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
|
||||
</p>
|
||||
{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}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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 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>
|
||||
<div className="flex-1">
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,8 @@ 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';
|
||||
@@ -37,6 +39,7 @@ 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,7 +3,6 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
FlaskConical,
|
||||
TestTube,
|
||||
GitBranch,
|
||||
AlertCircle,
|
||||
Zap,
|
||||
ClipboardList,
|
||||
@@ -27,13 +26,11 @@ 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;
|
||||
}
|
||||
@@ -42,13 +39,11 @@ export function FeatureDefaultsSection({
|
||||
defaultSkipTests,
|
||||
enableDependencyBlocking,
|
||||
skipVerificationInAutoMode,
|
||||
useWorktrees,
|
||||
defaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
onDefaultSkipTestsChange,
|
||||
onEnableDependencyBlockingChange,
|
||||
onSkipVerificationInAutoModeChange,
|
||||
onUseWorktreesChange,
|
||||
onDefaultPlanningModeChange,
|
||||
onDefaultRequirePlanApprovalChange,
|
||||
}: FeatureDefaultsSectionProps) {
|
||||
@@ -256,33 +251,6 @@ 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,6 +16,7 @@ export type SettingsViewId =
|
||||
| 'keyboard'
|
||||
| 'audio'
|
||||
| 'defaults'
|
||||
| 'worktrees'
|
||||
| 'account'
|
||||
| 'security'
|
||||
| 'danger';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type {
|
||||
@@ -25,18 +25,13 @@ 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';
|
||||
@@ -51,6 +46,80 @@ 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;
|
||||
@@ -95,6 +164,7 @@ export function PhaseModelSelector({
|
||||
codexModels,
|
||||
codexModelsLoading,
|
||||
fetchCodexModels,
|
||||
dynamicOpencodeModels,
|
||||
} = useAppStore();
|
||||
|
||||
// Extract model and thinking/reasoning levels from value
|
||||
@@ -235,12 +305,30 @@ export function PhaseModelSelector({
|
||||
const codexModel = transformedCodexModels.find((m) => m.id === selectedModel);
|
||||
if (codexModel) return { ...codexModel, icon: OpenAIIcon };
|
||||
|
||||
// Check OpenCode models
|
||||
// Check OpenCode models (static) - use dynamic icon resolution for provider-specific icons
|
||||
const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel);
|
||||
if (opencodeModel) return { ...opencodeModel, icon: OpenCodeIcon };
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [selectedModel, selectedThinkingLevel, availableCursorModels, transformedCodexModels]);
|
||||
}, [
|
||||
selectedModel,
|
||||
selectedThinkingLevel,
|
||||
availableCursorModels,
|
||||
transformedCodexModels,
|
||||
dynamicOpencodeModels,
|
||||
]);
|
||||
|
||||
// Compute grouped vs standalone Cursor models
|
||||
const { groupedModels, standaloneCursorModels } = useMemo(() => {
|
||||
@@ -275,13 +363,35 @@ 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: typeof OPENCODE_MODELS = [];
|
||||
const ocModels: ModelOption[] = [];
|
||||
|
||||
// Process Claude Models
|
||||
CLAUDE_MODELS.forEach((model) => {
|
||||
@@ -310,8 +420,8 @@ export function PhaseModelSelector({
|
||||
}
|
||||
});
|
||||
|
||||
// Process OpenCode Models
|
||||
OPENCODE_MODELS.forEach((model) => {
|
||||
// Process OpenCode Models (including dynamic)
|
||||
allOpencodeModels.forEach((model) => {
|
||||
if (favoriteModels.includes(model.id)) {
|
||||
favs.push(model);
|
||||
} else {
|
||||
@@ -326,7 +436,95 @@ export function PhaseModelSelector({
|
||||
codex: codModels,
|
||||
opencode: ocModels,
|
||||
};
|
||||
}, [favoriteModels, availableCursorModels, transformedCodexModels]);
|
||||
}, [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]);
|
||||
|
||||
// Render Codex model item with secondary popover for reasoning effort (only for models that support it)
|
||||
const renderCodexModelItem = (model: (typeof transformedCodexModels)[0]) => {
|
||||
@@ -992,9 +1190,32 @@ export function PhaseModelSelector({
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{opencode.length > 0 && (
|
||||
<CommandGroup heading="OpenCode Models">
|
||||
{opencode.map((model) => renderOpencodeModelItem(model))}
|
||||
{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>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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,
|
||||
@@ -8,20 +9,32 @@ 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 type { OpencodeModelId, OpencodeProvider, OpencodeModelConfig } from '@automaker/types';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type {
|
||||
OpencodeModelId,
|
||||
OpencodeProvider,
|
||||
OpencodeModelConfig,
|
||||
ModelDefinition,
|
||||
} 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 type { ComponentType } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState, type ComponentType } from 'react';
|
||||
|
||||
interface OpencodeModelConfigurationProps {
|
||||
enabledOpencodeModels: OpencodeModelId[];
|
||||
@@ -29,6 +42,12 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,31 +64,144 @@ 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 models by provider for organized display
|
||||
// Group static models by provider for organized display
|
||||
const modelsByProvider = OPENCODE_MODELS.reduce(
|
||||
(acc, model) => {
|
||||
if (!acc[model.provider]) {
|
||||
@@ -81,17 +213,141 @@ 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',
|
||||
'amazon-bedrock-anthropic',
|
||||
'amazon-bedrock-deepseek',
|
||||
'amazon-bedrock-amazon',
|
||||
'amazon-bedrock-meta',
|
||||
'amazon-bedrock-mistral',
|
||||
'amazon-bedrock-qwen',
|
||||
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 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(
|
||||
@@ -146,7 +402,21 @@ export function OpencodeModelConfiguration({
|
||||
|
||||
{/* Available Models grouped by provider */}
|
||||
<div className="space-y-4">
|
||||
<Label>Available Models</Label>
|
||||
<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) */}
|
||||
{providerOrder.map((provider) => {
|
||||
const models = modelsByProvider[provider];
|
||||
if (!models || models.length === 0) return null;
|
||||
@@ -217,6 +487,196 @@ 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,19 +1,18 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import {
|
||||
OpencodeCliStatus,
|
||||
OpencodeCliStatusSkeleton,
|
||||
OpencodeModelConfigSkeleton,
|
||||
} from '../cli-status/opencode-cli-status';
|
||||
import { OpencodeCliStatus, OpencodeCliStatusSkeleton } 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 } from '../cli-status/opencode-cli-status';
|
||||
import type { OpencodeAuthStatus, OpenCodeProviderInfo } 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 {
|
||||
@@ -21,15 +20,22 @@ export function OpencodeSettingsTab() {
|
||||
opencodeDefaultModel,
|
||||
setOpencodeDefaultModel,
|
||||
toggleOpencodeModel,
|
||||
setDynamicOpencodeModels,
|
||||
dynamicOpencodeModels,
|
||||
enabledDynamicModelIds,
|
||||
toggleDynamicModel,
|
||||
cachedOpencodeProviders,
|
||||
setCachedOpencodeProviders,
|
||||
} = useAppStore();
|
||||
|
||||
const [isCheckingOpencodeCli, setIsCheckingOpencodeCli] = useState(false);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [isLoadingDynamicModels, setIsLoadingDynamicModels] = useState(false);
|
||||
const [cliStatus, setCliStatus] = useState<SharedCliStatus | null>(null);
|
||||
const [authStatus, setAuthStatus] = useState<OpencodeAuthStatus | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const providerRefreshSignatureRef = useRef<string>('');
|
||||
|
||||
// Load OpenCode CLI status on mount
|
||||
// Phase 1: Load CLI status quickly on mount
|
||||
useEffect(() => {
|
||||
const checkOpencodeStatus = async () => {
|
||||
setIsCheckingOpencodeCli(true);
|
||||
@@ -46,7 +52,6 @@ export function OpencodeSettingsTab() {
|
||||
recommendation: result.recommendation,
|
||||
installCommands: result.installCommands,
|
||||
});
|
||||
// Set auth status if available
|
||||
if (result.auth) {
|
||||
setAuthStatus({
|
||||
authenticated: result.auth.authenticated,
|
||||
@@ -57,7 +62,6 @@ export function OpencodeSettingsTab() {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback for web mode or when API is not available
|
||||
setCliStatus({
|
||||
success: false,
|
||||
status: 'not_installed',
|
||||
@@ -73,14 +77,111 @@ 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) {
|
||||
@@ -94,7 +195,6 @@ export function OpencodeSettingsTab() {
|
||||
recommendation: result.recommendation,
|
||||
installCommands: result.installCommands,
|
||||
});
|
||||
// Update auth status if available
|
||||
if (result.auth) {
|
||||
setAuthStatus({
|
||||
authenticated: result.auth.authenticated,
|
||||
@@ -104,14 +204,35 @@ 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) => {
|
||||
@@ -142,12 +263,25 @@ export function OpencodeSettingsTab() {
|
||||
[toggleOpencodeModel]
|
||||
);
|
||||
|
||||
// Show loading skeleton during initial load
|
||||
if (isInitialLoading) {
|
||||
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) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<OpencodeCliStatusSkeleton />
|
||||
<OpencodeModelConfigSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -159,6 +293,7 @@ export function OpencodeSettingsTab() {
|
||||
<OpencodeCliStatus
|
||||
status={cliStatus}
|
||||
authStatus={authStatus}
|
||||
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
|
||||
isChecking={isCheckingOpencodeCli}
|
||||
onRefresh={handleRefreshOpencodeCli}
|
||||
/>
|
||||
@@ -171,6 +306,11 @@ export function OpencodeSettingsTab() {
|
||||
isSaving={isSaving}
|
||||
onDefaultModelChange={handleDefaultModelChange}
|
||||
onModelToggle={handleModelToggle}
|
||||
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
|
||||
dynamicModels={dynamicOpencodeModels}
|
||||
enabledDynamicModelIds={enabledDynamicModelIds}
|
||||
onDynamicModelToggle={handleDynamicModelToggle}
|
||||
isLoadingDynamicModels={isLoadingDynamicModels}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { WorktreesSection } from './worktrees-section';
|
||||
@@ -0,0 +1,430 @@
|
||||
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 AWS Bedrock models. You
|
||||
Configure OpenCode CLI for access to free tier models and connected providers. 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 and AWS Bedrock models.
|
||||
Install the OpenCode CLI to use free tier models and connected providers.
|
||||
</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 and AWS Bedrock models.
|
||||
Install the OpenCode CLI for free tier models and connected providers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,7 @@ 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;
|
||||
@@ -504,6 +505,7 @@ export function TerminalPanel({
|
||||
const response = await fetch(`${serverUrl}/api/auth/token`, {
|
||||
headers,
|
||||
credentials: 'include',
|
||||
cache: NO_STORE_CACHE_MODE,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
79
apps/ui/src/hooks/use-init-script-events.ts
Normal file
79
apps/ui/src/hooks/use-init-script-events.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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,6 +18,11 @@ 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);
|
||||
@@ -78,6 +83,27 @@ 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,7 +28,11 @@ 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 type { GlobalSettings } from '@automaker/types';
|
||||
import {
|
||||
DEFAULT_OPENCODE_MODEL,
|
||||
getAllOpencodeModelIds,
|
||||
type GlobalSettings,
|
||||
} from '@automaker/types';
|
||||
|
||||
const logger = createLogger('SettingsMigration');
|
||||
|
||||
@@ -152,6 +156,10 @@ 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'],
|
||||
@@ -495,6 +503,27 @@ 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) => ({
|
||||
@@ -517,8 +546,9 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
}
|
||||
|
||||
// Save theme to localStorage for fallback when server settings aren't available
|
||||
if (settings.theme) {
|
||||
setItem(THEME_STORAGE_KEY, settings.theme);
|
||||
const storedTheme = (currentProject?.theme as string | undefined) || settings.theme;
|
||||
if (storedTheme) {
|
||||
setItem(THEME_STORAGE_KEY, storedTheme);
|
||||
}
|
||||
|
||||
useAppStore.setState({
|
||||
@@ -538,6 +568,9 @@ 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: {
|
||||
@@ -591,6 +624,7 @@ 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,7 +19,11 @@ 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 type { GlobalSettings } from '@automaker/types';
|
||||
import {
|
||||
DEFAULT_OPENCODE_MODEL,
|
||||
getAllOpencodeModelIds,
|
||||
type GlobalSettings,
|
||||
} from '@automaker/types';
|
||||
|
||||
const logger = createLogger('SettingsSync');
|
||||
|
||||
@@ -44,9 +48,13 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'phaseModels',
|
||||
'enabledCursorModels',
|
||||
'cursorDefaultModel',
|
||||
'enabledOpencodeModels',
|
||||
'opencodeDefaultModel',
|
||||
'enabledDynamicModelIds',
|
||||
'autoLoadClaudeMd',
|
||||
'keyboardShortcuts',
|
||||
'mcpServers',
|
||||
'defaultEditorCommand',
|
||||
'promptCustomization',
|
||||
'projects',
|
||||
'trashedProjects',
|
||||
@@ -89,6 +97,7 @@ 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>('');
|
||||
@@ -117,9 +126,17 @@ export function useSettingsSync(): SettingsSyncState {
|
||||
// Debounced sync function
|
||||
const syncToServer = useCallback(async () => {
|
||||
try {
|
||||
// Never sync when not authenticated (prevents overwriting server settings during logout/login transitions)
|
||||
// Never sync when not authenticated or settings not loaded
|
||||
// The settingsLoaded flag ensures we don't sync default empty state before hydration
|
||||
const auth = useAuthStore.getState();
|
||||
if (!auth.authChecked || !auth.isAuthenticated) {
|
||||
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');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -127,6 +144,8 @@ 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) {
|
||||
@@ -147,10 +166,13 @@ 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;
|
||||
@@ -184,11 +206,20 @@ export function useSettingsSync(): SettingsSyncState {
|
||||
void syncToServer();
|
||||
}, [syncToServer]);
|
||||
|
||||
// Initialize sync - WAIT for migration to complete first
|
||||
// Initialize sync - WAIT for settings to be loaded and migration to complete
|
||||
useEffect(() => {
|
||||
// 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;
|
||||
// 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;
|
||||
if (isInitializedRef.current) return;
|
||||
isInitializedRef.current = true;
|
||||
|
||||
@@ -198,14 +229,26 @@ export function useSettingsSync(): SettingsSyncState {
|
||||
await waitForApiKeyInit();
|
||||
|
||||
// CRITICAL: Wait for migration/hydration to complete before we start syncing
|
||||
// This prevents overwriting server data with empty/default state
|
||||
// This is a backup to the settingsLoaded flag for extra safety
|
||||
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') {
|
||||
@@ -214,7 +257,6 @@ 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];
|
||||
}
|
||||
@@ -233,16 +275,33 @@ export function useSettingsSync(): SettingsSyncState {
|
||||
}
|
||||
|
||||
initializeSync();
|
||||
}, [authChecked, isAuthenticated]);
|
||||
}, [authChecked, isAuthenticated, settingsLoaded]);
|
||||
|
||||
// Subscribe to store changes and sync to server
|
||||
useEffect(() => {
|
||||
if (!state.loaded || !authChecked || !isAuthenticated) return;
|
||||
if (!state.loaded || !authChecked || !isAuthenticated || !settingsLoaded) 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;
|
||||
}
|
||||
@@ -266,6 +325,7 @@ export function useSettingsSync(): SettingsSyncState {
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
logger.debug('Store changed, scheduling sync');
|
||||
scheduleSyncToServer();
|
||||
}
|
||||
});
|
||||
@@ -294,11 +354,11 @@ export function useSettingsSync(): SettingsSyncState {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [state.loaded, authChecked, isAuthenticated, scheduleSyncToServer, syncNow]);
|
||||
}, [state.loaded, authChecked, isAuthenticated, settingsLoaded, scheduleSyncToServer, syncNow]);
|
||||
|
||||
// Best-effort flush on tab close / backgrounding
|
||||
useEffect(() => {
|
||||
if (!state.loaded || !authChecked || !isAuthenticated) return;
|
||||
if (!state.loaded || !authChecked || !isAuthenticated || !settingsLoaded) return;
|
||||
|
||||
const handleBeforeUnload = () => {
|
||||
// Fire-and-forget; may not complete in all browsers, but helps in Electron/webview
|
||||
@@ -318,7 +378,7 @@ export function useSettingsSync(): SettingsSyncState {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [state.loaded, authChecked, isAuthenticated, syncNow]);
|
||||
}, [state.loaded, authChecked, isAuthenticated, settingsLoaded, syncNow]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -368,6 +428,27 @@ 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) {
|
||||
@@ -391,6 +472,9 @@ 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,
|
||||
@@ -399,6 +483,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
>),
|
||||
},
|
||||
mcpServers: serverSettings.mcpServers,
|
||||
defaultEditorCommand: serverSettings.defaultEditorCommand ?? null,
|
||||
promptCustomization: serverSettings.promptCustomization ?? {},
|
||||
projects: serverSettings.projects,
|
||||
trashedProjects: serverSettings.trashedProjects,
|
||||
|
||||
@@ -13,6 +13,7 @@ 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';
|
||||
|
||||
@@ -80,7 +81,7 @@ export async function apiFetch(
|
||||
method: HttpMethod = 'GET',
|
||||
options: ApiFetchOptions = {}
|
||||
): Promise<Response> {
|
||||
const { headers: additionalHeaders, body, skipAuth, ...restOptions } = options;
|
||||
const { headers: additionalHeaders, body, skipAuth, cache, ...restOptions } = options;
|
||||
|
||||
const headers = skipAuth
|
||||
? { 'Content-Type': 'application/json', ...additionalHeaders }
|
||||
@@ -90,6 +91,7 @@ export async function apiFetch(
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
cache: cache ?? DEFAULT_CACHE_MODE,
|
||||
...restOptions,
|
||||
};
|
||||
|
||||
|
||||
@@ -462,7 +462,8 @@ export interface FeaturesAPI {
|
||||
featureId: string,
|
||||
updates: Partial<Feature>,
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||
preEnhancementDescription?: string
|
||||
) => Promise<{ success: boolean; feature?: Feature; error?: string }>;
|
||||
delete: (projectPath: string, featureId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
getAgentOutput: (
|
||||
@@ -612,7 +613,8 @@ export interface ElectronAPI {
|
||||
enhance: (
|
||||
originalText: string,
|
||||
enhancementMode: string,
|
||||
model?: string
|
||||
model?: string,
|
||||
thinkingLevel?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
enhancedText?: string;
|
||||
@@ -649,7 +651,8 @@ export interface ElectronAPI {
|
||||
removedDependencies: string[];
|
||||
addedDependencies: string[];
|
||||
}>;
|
||||
}
|
||||
},
|
||||
branchName?: string
|
||||
) => Promise<{ success: boolean; appliedChanges?: string[]; error?: string }>;
|
||||
onEvent: (callback: (data: unknown) => void) => () => void;
|
||||
};
|
||||
@@ -1643,13 +1646,34 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
};
|
||||
},
|
||||
|
||||
openInEditor: async (worktreePath: string) => {
|
||||
console.log('[Mock] Opening in editor:', worktreePath);
|
||||
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);
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened ${worktreePath} in VS Code`,
|
||||
editorName: 'VS Code',
|
||||
message: `Opened ${worktreePath} in ${editorName}`,
|
||||
editorName,
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -1665,6 +1689,32 @@ 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 {
|
||||
@@ -1720,6 +1770,47 @@ 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,6 +39,7 @@ 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;
|
||||
@@ -69,6 +70,7 @@ const handleUnauthorized = (): void => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: '{}',
|
||||
cache: NO_STORE_CACHE_MODE,
|
||||
}).catch(() => {});
|
||||
notifyLoggedOut();
|
||||
};
|
||||
@@ -296,6 +298,7 @@ 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 {
|
||||
@@ -322,6 +325,7 @@ 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();
|
||||
|
||||
@@ -361,6 +365,7 @@ 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) {
|
||||
@@ -391,6 +396,7 @@ 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
|
||||
@@ -439,6 +445,7 @@ 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),
|
||||
});
|
||||
@@ -475,6 +482,7 @@ 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),
|
||||
});
|
||||
|
||||
@@ -499,7 +507,10 @@ type EventType =
|
||||
| 'issue-validation:event'
|
||||
| 'backlog-plan:event'
|
||||
| 'ideation:stream'
|
||||
| 'ideation:analysis';
|
||||
| 'ideation:analysis'
|
||||
| 'worktree:init-started'
|
||||
| 'worktree:init-output'
|
||||
| 'worktree:init-completed';
|
||||
|
||||
type EventCallback = (payload: unknown) => void;
|
||||
|
||||
@@ -556,6 +567,7 @@ 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) {
|
||||
@@ -587,6 +599,17 @@ 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
|
||||
@@ -771,6 +794,7 @@ 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) {
|
||||
@@ -825,13 +849,14 @@ export class HttpApiClient implements ElectronAPI {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private async httpDelete<T>(endpoint: string): Promise<T> {
|
||||
private async httpDelete<T>(endpoint: string, body?: unknown): 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) {
|
||||
@@ -1415,6 +1440,67 @@ 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);
|
||||
},
|
||||
@@ -1459,7 +1545,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
featureId: string,
|
||||
updates: Partial<Feature>,
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||
preEnhancementDescription?: string
|
||||
) =>
|
||||
this.post('/api/features/update', {
|
||||
projectPath,
|
||||
@@ -1467,6 +1554,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
updates,
|
||||
descriptionHistorySource,
|
||||
enhancementMode,
|
||||
preEnhancementDescription,
|
||||
}),
|
||||
delete: (projectPath: string, featureId: string) =>
|
||||
this.post('/api/features/delete', { projectPath, featureId }),
|
||||
@@ -1612,9 +1700,11 @@ 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) =>
|
||||
this.post('/api/worktree/open-in-editor', { worktreePath }),
|
||||
openInEditor: (worktreePath: string, editorCommand?: string) =>
|
||||
this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }),
|
||||
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 }),
|
||||
@@ -1622,6 +1712,37 @@ 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
|
||||
@@ -2142,9 +2263,10 @@ 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 }),
|
||||
this.post('/api/backlog-plan/apply', { projectPath, plan, branchName }),
|
||||
|
||||
onEvent: (callback: (data: unknown) => void): (() => void) => {
|
||||
return this.subscribeToEvent('backlog-plan:event', callback as EventCallback);
|
||||
|
||||
@@ -12,12 +12,14 @@ 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,
|
||||
@@ -25,13 +27,29 @@ 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
|
||||
@@ -60,15 +78,90 @@ 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();
|
||||
@@ -76,6 +169,7 @@ 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
|
||||
@@ -85,6 +179,22 @@ 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';
|
||||
@@ -218,9 +328,11 @@ 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' });
|
||||
}
|
||||
};
|
||||
@@ -236,6 +348,7 @@ 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
|
||||
@@ -266,6 +379,12 @@ 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 {
|
||||
@@ -302,13 +421,28 @@ function RootLayoutContent() {
|
||||
// Hydrate store with the final settings (merged if migration occurred)
|
||||
hydrateStoreFromSettings(finalSettings);
|
||||
|
||||
// Signal that settings hydration is complete so useSettingsSync can start
|
||||
// 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.
|
||||
signalMigrationComplete();
|
||||
|
||||
// Mark auth as checked only after settings hydration succeeded.
|
||||
useAuthStore
|
||||
.getState()
|
||||
.setAuthState({ isAuthenticated: true, authChecked: true });
|
||||
// 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,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -373,17 +507,38 @@ 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) return;
|
||||
if (!authChecked) {
|
||||
logger.debug('Auth not checked yet, skipping routing');
|
||||
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' });
|
||||
@@ -394,7 +549,46 @@ function RootLayoutContent() {
|
||||
if (setupComplete && location.pathname === '/setup') {
|
||||
navigate({ to: '/dashboard' });
|
||||
}
|
||||
}, [authChecked, isAuthenticated, setupComplete, location.pathname, navigate]);
|
||||
}, [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]);
|
||||
|
||||
useEffect(() => {
|
||||
setGlobalFileBrowser(openFileBrowser);
|
||||
@@ -428,7 +622,10 @@ function RootLayoutContent() {
|
||||
|
||||
// Redirect from welcome page based on project state
|
||||
useEffect(() => {
|
||||
if (isMounted && location.pathname === '/') {
|
||||
if (isMounted && isRootRoute) {
|
||||
if (!settingsLoaded || shouldAutoOpen) {
|
||||
return;
|
||||
}
|
||||
if (currentProject) {
|
||||
// Project is selected, go to board
|
||||
navigate({ to: '/board' });
|
||||
@@ -437,14 +634,66 @@ function RootLayoutContent() {
|
||||
navigate({ to: '/dashboard' });
|
||||
}
|
||||
}
|
||||
}, [isMounted, currentProject, location.pathname, navigate]);
|
||||
}, [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,
|
||||
]);
|
||||
|
||||
// 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?.auth?.authenticated;
|
||||
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.hasApiKey;
|
||||
if (!isCodexAvailable) return;
|
||||
|
||||
// Fetch models in the background
|
||||
@@ -512,6 +761,22 @@ 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 (
|
||||
@@ -549,6 +814,16 @@ 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' }}
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
PipelineConfig,
|
||||
PipelineStep,
|
||||
PromptCustomization,
|
||||
ModelDefinition,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
getAllCursorModelIds,
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
} from '@automaker/types';
|
||||
|
||||
const logger = createLogger('AppStore');
|
||||
const OPENCODE_BEDROCK_PROVIDER_ID = 'amazon-bedrock';
|
||||
const OPENCODE_BEDROCK_MODEL_PREFIX = `${OPENCODE_BEDROCK_PROVIDER_ID}/`;
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
@@ -80,6 +83,9 @@ export type ThemeMode =
|
||||
// LocalStorage key for theme persistence (fallback when server settings aren't available)
|
||||
export const THEME_STORAGE_KEY = 'automaker:theme';
|
||||
|
||||
// Maximum number of output lines to keep in init script state (prevents unbounded memory growth)
|
||||
export const MAX_INIT_OUTPUT_LINES = 500;
|
||||
|
||||
/**
|
||||
* Get the theme from localStorage as a fallback
|
||||
* Used before server settings are loaded (e.g., on login/setup pages)
|
||||
@@ -114,6 +120,12 @@ function saveThemeToStorage(theme: ThemeMode): void {
|
||||
setItem(THEME_STORAGE_KEY, theme);
|
||||
}
|
||||
|
||||
function persistEffectiveThemeForProject(project: Project | null, fallbackTheme: ThemeMode): void {
|
||||
const projectTheme = project?.theme as ThemeMode | undefined;
|
||||
const themeToStore = projectTheme ?? fallbackTheme;
|
||||
saveThemeToStorage(themeToStore);
|
||||
}
|
||||
|
||||
export type BoardViewMode = 'kanban' | 'graph';
|
||||
|
||||
export interface ApiKeys {
|
||||
@@ -463,6 +475,14 @@ export interface PersistedTerminalSettings {
|
||||
maxSessions: number;
|
||||
}
|
||||
|
||||
/** State for worktree init script execution */
|
||||
export interface InitScriptState {
|
||||
status: 'idle' | 'running' | 'success' | 'failed';
|
||||
branch: string;
|
||||
output: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
// Project state
|
||||
projects: Project[];
|
||||
@@ -516,6 +536,8 @@ export interface AppState {
|
||||
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
||||
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
|
||||
skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running)
|
||||
planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch
|
||||
addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch
|
||||
|
||||
// Worktree Settings
|
||||
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: true)
|
||||
@@ -564,8 +586,19 @@ export interface AppState {
|
||||
codexEnableImages: boolean; // Enable image processing
|
||||
|
||||
// OpenCode CLI Settings (global)
|
||||
enabledOpencodeModels: OpencodeModelId[]; // Which OpenCode models are available in feature modal
|
||||
// Static OpenCode settings are persisted via SETTINGS_FIELDS_TO_SYNC
|
||||
enabledOpencodeModels: OpencodeModelId[]; // Which static OpenCode models are available
|
||||
opencodeDefaultModel: OpencodeModelId; // Default OpenCode model selection
|
||||
// Dynamic models are session-only (not persisted) because they're discovered at runtime
|
||||
// from `opencode models` CLI and depend on current provider authentication state
|
||||
dynamicOpencodeModels: ModelDefinition[]; // Dynamically discovered models from OpenCode CLI
|
||||
enabledDynamicModelIds: string[]; // Which dynamic models are enabled
|
||||
cachedOpencodeProviders: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
authenticated: boolean;
|
||||
authMethod?: string;
|
||||
}>; // Cached providers
|
||||
|
||||
// Claude Agent SDK Settings
|
||||
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
|
||||
@@ -574,6 +607,9 @@ export interface AppState {
|
||||
// MCP Servers
|
||||
mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use
|
||||
|
||||
// Editor Configuration
|
||||
defaultEditorCommand: string | null; // Default editor for "Open In" action
|
||||
|
||||
// Skills Configuration
|
||||
enableSkills: boolean; // Enable Skills functionality (loads from .claude/skills/ directories)
|
||||
skillsSources: Array<'user' | 'project'>; // Which directories to load Skills from
|
||||
@@ -661,6 +697,18 @@ export interface AppState {
|
||||
// Whether the worktree panel row is visible (default: true)
|
||||
worktreePanelVisibleByProject: Record<string, boolean>;
|
||||
|
||||
// Init Script Indicator Visibility (per-project, keyed by project path)
|
||||
// Whether to show the floating init script indicator panel (default: true)
|
||||
showInitScriptIndicatorByProject: Record<string, boolean>;
|
||||
|
||||
// Default Delete Branch With Worktree (per-project, keyed by project path)
|
||||
// Whether to default the "delete branch" checkbox when deleting a worktree (default: false)
|
||||
defaultDeleteBranchByProject: Record<string, boolean>;
|
||||
|
||||
// Auto-dismiss Init Script Indicator (per-project, keyed by project path)
|
||||
// Whether to auto-dismiss the indicator after completion (default: true)
|
||||
autoDismissInitScriptIndicatorByProject: Record<string, boolean>;
|
||||
|
||||
// UI State (previously in localStorage, now synced via API)
|
||||
/** Whether worktree panel is collapsed in board view */
|
||||
worktreePanelCollapsed: boolean;
|
||||
@@ -668,6 +716,9 @@ export interface AppState {
|
||||
lastProjectDir: string;
|
||||
/** Recently accessed folders for quick access */
|
||||
recentFolders: string[];
|
||||
|
||||
// Init Script State (keyed by "projectPath::branch" to support concurrent scripts)
|
||||
initScriptState: Record<string, InitScriptState>;
|
||||
}
|
||||
|
||||
// Claude Usage interface matching the server response
|
||||
@@ -881,6 +932,8 @@ export interface AppActions {
|
||||
setDefaultSkipTests: (skip: boolean) => void;
|
||||
setEnableDependencyBlocking: (enabled: boolean) => void;
|
||||
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
|
||||
setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
||||
setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
||||
|
||||
// Worktree Settings actions
|
||||
setUseWorktrees: (enabled: boolean) => void;
|
||||
@@ -949,11 +1002,20 @@ export interface AppActions {
|
||||
setEnabledOpencodeModels: (models: OpencodeModelId[]) => void;
|
||||
setOpencodeDefaultModel: (model: OpencodeModelId) => void;
|
||||
toggleOpencodeModel: (model: OpencodeModelId, enabled: boolean) => void;
|
||||
setDynamicOpencodeModels: (models: ModelDefinition[]) => void;
|
||||
setEnabledDynamicModelIds: (ids: string[]) => void;
|
||||
toggleDynamicModel: (modelId: string, enabled: boolean) => void;
|
||||
setCachedOpencodeProviders: (
|
||||
providers: Array<{ id: string; name: string; authenticated: boolean; authMethod?: string }>
|
||||
) => void;
|
||||
|
||||
// Claude Agent SDK Settings actions
|
||||
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
||||
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
|
||||
|
||||
// Editor Configuration actions
|
||||
setDefaultEditorCommand: (command: string | null) => void;
|
||||
|
||||
// Prompt Customization actions
|
||||
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
|
||||
|
||||
@@ -1071,6 +1133,18 @@ export interface AppActions {
|
||||
setWorktreePanelVisible: (projectPath: string, visible: boolean) => void;
|
||||
getWorktreePanelVisible: (projectPath: string) => boolean;
|
||||
|
||||
// Init Script Indicator Visibility actions (per-project)
|
||||
setShowInitScriptIndicator: (projectPath: string, visible: boolean) => void;
|
||||
getShowInitScriptIndicator: (projectPath: string) => boolean;
|
||||
|
||||
// Default Delete Branch actions (per-project)
|
||||
setDefaultDeleteBranch: (projectPath: string, deleteBranch: boolean) => void;
|
||||
getDefaultDeleteBranch: (projectPath: string) => boolean;
|
||||
|
||||
// Auto-dismiss Init Script Indicator actions (per-project)
|
||||
setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) => void;
|
||||
getAutoDismissInitScriptIndicator: (projectPath: string) => boolean;
|
||||
|
||||
// UI State actions (previously in localStorage, now synced via API)
|
||||
setWorktreePanelCollapsed: (collapsed: boolean) => void;
|
||||
setLastProjectDir: (dir: string) => void;
|
||||
@@ -1099,6 +1173,19 @@ export interface AppActions {
|
||||
}>
|
||||
) => void;
|
||||
|
||||
// Init Script State actions (keyed by projectPath::branch to support concurrent scripts)
|
||||
setInitScriptState: (
|
||||
projectPath: string,
|
||||
branch: string,
|
||||
state: Partial<InitScriptState>
|
||||
) => void;
|
||||
appendInitScriptOutput: (projectPath: string, branch: string, content: string) => void;
|
||||
clearInitScriptState: (projectPath: string, branch: string) => void;
|
||||
getInitScriptState: (projectPath: string, branch: string) => InitScriptState | null;
|
||||
getInitScriptStatesForProject: (
|
||||
projectPath: string
|
||||
) => Array<{ key: string; state: InitScriptState }>;
|
||||
|
||||
// Reset
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -1131,6 +1218,8 @@ const initialState: AppState = {
|
||||
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
||||
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
||||
skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified)
|
||||
planUseSelectedWorktreeBranch: true, // Default to enabled (Plan creates features on selected worktree branch)
|
||||
addFeatureUseSelectedWorktreeBranch: false, // Default to disabled (Add Feature uses normal defaults)
|
||||
useWorktrees: true, // Default to enabled (git worktree isolation)
|
||||
currentWorktreeByProject: {},
|
||||
worktreesByProject: {},
|
||||
@@ -1150,10 +1239,14 @@ const initialState: AppState = {
|
||||
codexEnableWebSearch: false, // Default to disabled
|
||||
codexEnableImages: false, // Default to disabled
|
||||
enabledOpencodeModels: getAllOpencodeModelIds(), // All OpenCode models enabled by default
|
||||
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Default to Claude Sonnet 4.5
|
||||
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Default to OpenCode free tier
|
||||
dynamicOpencodeModels: [], // Empty until fetched from OpenCode CLI
|
||||
enabledDynamicModelIds: [], // Empty until user enables dynamic models
|
||||
cachedOpencodeProviders: [], // Empty until fetched from OpenCode CLI
|
||||
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
||||
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
|
||||
mcpServers: [], // No MCP servers configured by default
|
||||
defaultEditorCommand: null, // Auto-detect: Cursor > VS Code > first available
|
||||
enableSkills: true, // Skills enabled by default
|
||||
skillsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default
|
||||
enableSubagents: true, // Subagents enabled by default
|
||||
@@ -1195,10 +1288,14 @@ const initialState: AppState = {
|
||||
codexModelsLastFetched: null,
|
||||
pipelineConfigByProject: {},
|
||||
worktreePanelVisibleByProject: {},
|
||||
showInitScriptIndicatorByProject: {},
|
||||
defaultDeleteBranchByProject: {},
|
||||
autoDismissInitScriptIndicatorByProject: {},
|
||||
// UI State (previously in localStorage, now synced via API)
|
||||
worktreePanelCollapsed: false,
|
||||
lastProjectDir: '',
|
||||
recentFolders: [],
|
||||
initScriptState: {},
|
||||
};
|
||||
|
||||
export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
@@ -1241,13 +1338,16 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
};
|
||||
|
||||
const isCurrent = get().currentProject?.id === projectId;
|
||||
const nextCurrentProject = isCurrent ? null : get().currentProject;
|
||||
|
||||
set({
|
||||
projects: remainingProjects,
|
||||
trashedProjects: [trashedProject, ...existingTrash],
|
||||
currentProject: isCurrent ? null : get().currentProject,
|
||||
currentProject: nextCurrentProject,
|
||||
currentView: isCurrent ? 'welcome' : get().currentView,
|
||||
});
|
||||
|
||||
persistEffectiveThemeForProject(nextCurrentProject, get().theme);
|
||||
},
|
||||
|
||||
restoreTrashedProject: (projectId) => {
|
||||
@@ -1266,6 +1366,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
currentProject: samePathProject,
|
||||
currentView: 'board',
|
||||
});
|
||||
persistEffectiveThemeForProject(samePathProject, get().theme);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1283,6 +1384,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
currentProject: restoredProject,
|
||||
currentView: 'board',
|
||||
});
|
||||
persistEffectiveThemeForProject(restoredProject, get().theme);
|
||||
},
|
||||
|
||||
deleteTrashedProject: (projectId) => {
|
||||
@@ -1302,6 +1404,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
|
||||
setCurrentProject: (project) => {
|
||||
set({ currentProject: project });
|
||||
persistEffectiveThemeForProject(project, get().theme);
|
||||
if (project) {
|
||||
set({ currentView: 'board' });
|
||||
// Add to project history (MRU order)
|
||||
@@ -1385,6 +1488,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
projectHistoryIndex: newIndex,
|
||||
currentView: 'board',
|
||||
});
|
||||
persistEffectiveThemeForProject(targetProject, get().theme);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1418,6 +1522,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
projectHistoryIndex: newIndex,
|
||||
currentView: 'board',
|
||||
});
|
||||
persistEffectiveThemeForProject(targetProject, get().theme);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1477,12 +1582,14 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
// Also update currentProject if it's the same project
|
||||
const currentProject = get().currentProject;
|
||||
if (currentProject?.id === projectId) {
|
||||
const updatedTheme = theme === null ? undefined : theme;
|
||||
set({
|
||||
currentProject: {
|
||||
...currentProject,
|
||||
theme: theme === null ? undefined : theme,
|
||||
theme: updatedTheme,
|
||||
},
|
||||
});
|
||||
persistEffectiveThemeForProject({ ...currentProject, theme: updatedTheme }, get().theme);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1741,6 +1848,30 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
await syncSettingsToServer();
|
||||
},
|
||||
setPlanUseSelectedWorktreeBranch: async (enabled) => {
|
||||
const previous = get().planUseSelectedWorktreeBranch;
|
||||
set({ planUseSelectedWorktreeBranch: enabled });
|
||||
// Sync to server settings file
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
const ok = await syncSettingsToServer();
|
||||
if (!ok) {
|
||||
logger.error('Failed to sync planUseSelectedWorktreeBranch setting to server - reverting');
|
||||
set({ planUseSelectedWorktreeBranch: previous });
|
||||
}
|
||||
},
|
||||
setAddFeatureUseSelectedWorktreeBranch: async (enabled) => {
|
||||
const previous = get().addFeatureUseSelectedWorktreeBranch;
|
||||
set({ addFeatureUseSelectedWorktreeBranch: enabled });
|
||||
// Sync to server settings file
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
const ok = await syncSettingsToServer();
|
||||
if (!ok) {
|
||||
logger.error(
|
||||
'Failed to sync addFeatureUseSelectedWorktreeBranch setting to server - reverting'
|
||||
);
|
||||
set({ addFeatureUseSelectedWorktreeBranch: previous });
|
||||
}
|
||||
},
|
||||
|
||||
// Worktree Settings actions
|
||||
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
|
||||
@@ -1909,6 +2040,34 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
? [...state.enabledOpencodeModels, model]
|
||||
: state.enabledOpencodeModels.filter((m) => m !== model),
|
||||
})),
|
||||
setDynamicOpencodeModels: (models) => {
|
||||
// Dynamic models depend on CLI authentication state and are re-discovered each session.
|
||||
// Persist enabled model IDs, but do not auto-enable new models.
|
||||
const filteredModels = models.filter(
|
||||
(model) =>
|
||||
model.provider !== OPENCODE_BEDROCK_PROVIDER_ID &&
|
||||
!model.id.startsWith(OPENCODE_BEDROCK_MODEL_PREFIX)
|
||||
);
|
||||
const currentEnabled = get().enabledDynamicModelIds;
|
||||
const newModelIds = filteredModels.map((m) => m.id);
|
||||
const filteredEnabled = currentEnabled.filter((modelId) => newModelIds.includes(modelId));
|
||||
|
||||
const nextEnabled = currentEnabled.length === 0 ? [] : filteredEnabled;
|
||||
set({ dynamicOpencodeModels: filteredModels, enabledDynamicModelIds: nextEnabled });
|
||||
},
|
||||
setEnabledDynamicModelIds: (ids) => set({ enabledDynamicModelIds: ids }),
|
||||
toggleDynamicModel: (modelId, enabled) =>
|
||||
set((state) => ({
|
||||
enabledDynamicModelIds: enabled
|
||||
? [...state.enabledDynamicModelIds, modelId]
|
||||
: state.enabledDynamicModelIds.filter((id) => id !== modelId),
|
||||
})),
|
||||
setCachedOpencodeProviders: (providers) =>
|
||||
set({
|
||||
cachedOpencodeProviders: providers.filter(
|
||||
(provider) => provider.id !== OPENCODE_BEDROCK_PROVIDER_ID
|
||||
),
|
||||
}),
|
||||
|
||||
// Claude Agent SDK Settings actions
|
||||
setAutoLoadClaudeMd: async (enabled) => {
|
||||
@@ -1933,6 +2092,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
set({ skipSandboxWarning: previous });
|
||||
}
|
||||
},
|
||||
|
||||
// Editor Configuration actions
|
||||
setDefaultEditorCommand: (command) => set({ defaultEditorCommand: command }),
|
||||
// Prompt Customization actions
|
||||
setPromptCustomization: async (customization) => {
|
||||
set({ promptCustomization: customization });
|
||||
@@ -3110,6 +3272,51 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
return get().worktreePanelVisibleByProject[projectPath] ?? true;
|
||||
},
|
||||
|
||||
// Init Script Indicator Visibility actions (per-project)
|
||||
setShowInitScriptIndicator: (projectPath, visible) => {
|
||||
set({
|
||||
showInitScriptIndicatorByProject: {
|
||||
...get().showInitScriptIndicatorByProject,
|
||||
[projectPath]: visible,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
getShowInitScriptIndicator: (projectPath) => {
|
||||
// Default to true (visible) if not set
|
||||
return get().showInitScriptIndicatorByProject[projectPath] ?? true;
|
||||
},
|
||||
|
||||
// Default Delete Branch actions (per-project)
|
||||
setDefaultDeleteBranch: (projectPath, deleteBranch) => {
|
||||
set({
|
||||
defaultDeleteBranchByProject: {
|
||||
...get().defaultDeleteBranchByProject,
|
||||
[projectPath]: deleteBranch,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
getDefaultDeleteBranch: (projectPath) => {
|
||||
// Default to false (don't delete branch) if not set
|
||||
return get().defaultDeleteBranchByProject[projectPath] ?? false;
|
||||
},
|
||||
|
||||
// Auto-dismiss Init Script Indicator actions (per-project)
|
||||
setAutoDismissInitScriptIndicator: (projectPath, autoDismiss) => {
|
||||
set({
|
||||
autoDismissInitScriptIndicatorByProject: {
|
||||
...get().autoDismissInitScriptIndicatorByProject,
|
||||
[projectPath]: autoDismiss,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
getAutoDismissInitScriptIndicator: (projectPath) => {
|
||||
// Default to true (auto-dismiss enabled) if not set
|
||||
return get().autoDismissInitScriptIndicatorByProject[projectPath] ?? true;
|
||||
},
|
||||
|
||||
// UI State actions (previously in localStorage, now synced via API)
|
||||
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
|
||||
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),
|
||||
@@ -3123,6 +3330,62 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
set({ recentFolders: updated });
|
||||
},
|
||||
|
||||
// Init Script State actions (keyed by "projectPath::branch")
|
||||
setInitScriptState: (projectPath, branch, state) => {
|
||||
const key = `${projectPath}::${branch}`;
|
||||
const current = get().initScriptState[key] || {
|
||||
status: 'idle',
|
||||
branch,
|
||||
output: [],
|
||||
};
|
||||
set({
|
||||
initScriptState: {
|
||||
...get().initScriptState,
|
||||
[key]: { ...current, ...state },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
appendInitScriptOutput: (projectPath, branch, content) => {
|
||||
const key = `${projectPath}::${branch}`;
|
||||
// Initialize state if absent to avoid dropping output due to event-order races
|
||||
const current = get().initScriptState[key] || {
|
||||
status: 'idle' as const,
|
||||
branch,
|
||||
output: [],
|
||||
};
|
||||
// Append new content and enforce fixed-size buffer to prevent memory bloat
|
||||
const newOutput = [...current.output, content].slice(-MAX_INIT_OUTPUT_LINES);
|
||||
set({
|
||||
initScriptState: {
|
||||
...get().initScriptState,
|
||||
[key]: {
|
||||
...current,
|
||||
output: newOutput,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
clearInitScriptState: (projectPath, branch) => {
|
||||
const key = `${projectPath}::${branch}`;
|
||||
const { [key]: _, ...rest } = get().initScriptState;
|
||||
set({ initScriptState: rest });
|
||||
},
|
||||
|
||||
getInitScriptState: (projectPath, branch) => {
|
||||
const key = `${projectPath}::${branch}`;
|
||||
return get().initScriptState[key] || null;
|
||||
},
|
||||
|
||||
getInitScriptStatesForProject: (projectPath) => {
|
||||
const prefix = `${projectPath}::`;
|
||||
const states = get().initScriptState;
|
||||
return Object.entries(states)
|
||||
.filter(([key]) => key.startsWith(prefix))
|
||||
.map(([key, state]) => ({ key, state }));
|
||||
},
|
||||
|
||||
// Reset
|
||||
reset: () => set(initialState),
|
||||
}));
|
||||
|
||||
@@ -5,6 +5,8 @@ interface AuthState {
|
||||
authChecked: boolean;
|
||||
/** Whether the user is currently authenticated (web mode: valid session cookie) */
|
||||
isAuthenticated: boolean;
|
||||
/** Whether settings have been loaded and hydrated from server */
|
||||
settingsLoaded: boolean;
|
||||
}
|
||||
|
||||
interface AuthActions {
|
||||
@@ -15,15 +17,18 @@ interface AuthActions {
|
||||
const initialState: AuthState = {
|
||||
authChecked: false,
|
||||
isAuthenticated: false,
|
||||
settingsLoaded: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Web authentication state.
|
||||
*
|
||||
* Intentionally NOT persisted: source of truth is the server session cookie.
|
||||
* Intentionally NOT persisted: source of truth is server session cookie.
|
||||
*/
|
||||
export const useAuthStore = create<AuthState & AuthActions>((set) => ({
|
||||
...initialState,
|
||||
setAuthState: (state) => set(state),
|
||||
setAuthState: (state) => {
|
||||
set({ ...state });
|
||||
},
|
||||
resetAuth: () => set(initialState),
|
||||
}));
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface CliStatus {
|
||||
path: string | null;
|
||||
version: string | null;
|
||||
method: string;
|
||||
hasApiKey?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
73
apps/ui/src/types/electron.d.ts
vendored
73
apps/ui/src/types/electron.d.ts
vendored
@@ -884,7 +884,10 @@ export interface WorktreeAPI {
|
||||
}>;
|
||||
|
||||
// Open a worktree directory in the editor
|
||||
openInEditor: (worktreePath: string) => Promise<{
|
||||
openInEditor: (
|
||||
worktreePath: string,
|
||||
editorCommand?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
message: string;
|
||||
@@ -903,6 +906,30 @@ export interface WorktreeAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Get all available code editors
|
||||
getAvailableEditors: () => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
editors: Array<{
|
||||
name: string;
|
||||
command: string;
|
||||
}>;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Refresh editor cache and re-detect available editors
|
||||
refreshEditors: () => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
editors: Array<{
|
||||
name: string;
|
||||
command: string;
|
||||
}>;
|
||||
message: string;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
// Initialize git repository in a project
|
||||
initGit: (projectPath: string) => Promise<{
|
||||
success: boolean;
|
||||
@@ -988,6 +1015,50 @@ export interface WorktreeAPI {
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Get init script content for a project
|
||||
getInitScript: (projectPath: string) => Promise<{
|
||||
success: boolean;
|
||||
exists: boolean;
|
||||
content: string;
|
||||
path: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Set init script content for a project
|
||||
setInitScript: (
|
||||
projectPath: string,
|
||||
content: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Delete init script for a project
|
||||
deleteInitScript: (projectPath: string) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Run (or re-run) init script for a worktree
|
||||
runInitScript: (
|
||||
projectPath: string,
|
||||
worktreePath: string,
|
||||
branch: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Subscribe to init script events
|
||||
onInitScriptEvent: (
|
||||
callback: (event: {
|
||||
type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';
|
||||
payload: unknown;
|
||||
}) => void
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
export interface GitAPI {
|
||||
|
||||
@@ -3,9 +3,10 @@ import { routeTree } from '../routeTree.gen';
|
||||
|
||||
// Use browser history in web mode (for e2e tests and dev), memory history in Electron
|
||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined;
|
||||
const BOARD_ROUTE_PATH = '/board';
|
||||
|
||||
const history = isElectron
|
||||
? createMemoryHistory({ initialEntries: [window.location.pathname || '/'] })
|
||||
? createMemoryHistory({ initialEntries: [BOARD_ROUTE_PATH] })
|
||||
: createBrowserHistory();
|
||||
|
||||
export const router = createRouter({
|
||||
|
||||
30
docs/worktree-init-script-example.sh
Normal file
30
docs/worktree-init-script-example.sh
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# Example worktree init script for Automaker
|
||||
# Copy this content to Settings > Worktrees > Init Script
|
||||
# Or save directly as .automaker/worktree-init.sh in your project
|
||||
|
||||
echo "=========================================="
|
||||
echo " Worktree Init Script Starting..."
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Current directory: $(pwd)"
|
||||
echo "Branch: $(git branch --show-current 2>/dev/null || echo 'unknown')"
|
||||
echo ""
|
||||
|
||||
# Install dependencies
|
||||
echo "[1/1] Installing npm dependencies..."
|
||||
if [ -f "package.json" ]; then
|
||||
if npm install; then
|
||||
echo "Dependencies installed successfully!"
|
||||
else
|
||||
echo "ERROR: npm install failed with exit code $?"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "No package.json found, skipping npm install"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "=========================================="
|
||||
echo " Worktree initialization complete!"
|
||||
echo "=========================================="
|
||||
@@ -69,8 +69,12 @@ export function resolveModelString(
|
||||
return modelKey;
|
||||
}
|
||||
|
||||
// OpenCode model - pass through unchanged
|
||||
// Supports: opencode/big-pickle, opencode-sonnet, amazon-bedrock/anthropic.claude-*
|
||||
// OpenCode model (static or dynamic) - pass through unchanged
|
||||
// This handles models like:
|
||||
// - opencode-* (Automaker routing prefix)
|
||||
// - opencode/* (free tier models)
|
||||
// - amazon-bedrock/* (AWS Bedrock models)
|
||||
// - provider/model-name (dynamic models like github-copilot/gpt-4o, google/gemini-2.5-pro)
|
||||
if (isOpencodeModel(modelKey)) {
|
||||
console.log(`[ModelResolver] Using OpenCode model: ${modelKey}`);
|
||||
return modelKey;
|
||||
|
||||
343
libs/platform/src/editor.ts
Normal file
343
libs/platform/src/editor.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Cross-platform editor detection and launching utilities
|
||||
*
|
||||
* Handles:
|
||||
* - Detecting available code editors on the system
|
||||
* - Cross-platform editor launching (handles Windows .cmd files)
|
||||
* - Caching of detected editors for performance
|
||||
*/
|
||||
|
||||
import { execFile, spawn, type ChildProcess } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { access } from 'fs/promises';
|
||||
import type { EditorInfo } from '@automaker/types';
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Platform detection
|
||||
const isWindows = process.platform === 'win32';
|
||||
const isMac = process.platform === 'darwin';
|
||||
|
||||
// Cache with TTL for editor detection
|
||||
let cachedEditors: EditorInfo[] | null = null;
|
||||
let cacheTimestamp: number = 0;
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Check if the editor cache is still valid
|
||||
*/
|
||||
function isCacheValid(): boolean {
|
||||
return cachedEditors !== null && Date.now() - cacheTimestamp < CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the editor detection cache
|
||||
* Useful when editors may have been installed/uninstalled
|
||||
*/
|
||||
export function clearEditorCache(): void {
|
||||
cachedEditors = null;
|
||||
cacheTimestamp = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a CLI command exists in PATH
|
||||
* Uses platform-specific command lookup (where on Windows, which on Unix)
|
||||
*/
|
||||
export async function commandExists(cmd: string): Promise<boolean> {
|
||||
try {
|
||||
const whichCmd = isWindows ? 'where' : 'which';
|
||||
await execFileAsync(whichCmd, [cmd]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a macOS app bundle exists and return the path if found
|
||||
* Checks both /Applications and ~/Applications
|
||||
*/
|
||||
async function findMacApp(appName: string): Promise<string | null> {
|
||||
if (!isMac) return null;
|
||||
|
||||
// Check /Applications first
|
||||
const systemAppPath = join('/Applications', `${appName}.app`);
|
||||
try {
|
||||
await access(systemAppPath);
|
||||
return systemAppPath;
|
||||
} catch {
|
||||
// Not in /Applications
|
||||
}
|
||||
|
||||
// Check ~/Applications (used by JetBrains Toolbox and others)
|
||||
const userAppPath = join(homedir(), 'Applications', `${appName}.app`);
|
||||
try {
|
||||
await access(userAppPath);
|
||||
return userAppPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor definition with CLI command and macOS app bundle name
|
||||
*/
|
||||
interface EditorDefinition {
|
||||
name: string;
|
||||
cliCommand: string;
|
||||
cliAliases?: readonly string[];
|
||||
macAppName: string;
|
||||
/** If true, only available on macOS */
|
||||
macOnly?: boolean;
|
||||
}
|
||||
|
||||
const ANTIGRAVITY_CLI_COMMANDS = ['antigravity', 'agy'] as const;
|
||||
const [PRIMARY_ANTIGRAVITY_COMMAND, ...LEGACY_ANTIGRAVITY_COMMANDS] = ANTIGRAVITY_CLI_COMMANDS;
|
||||
|
||||
/**
|
||||
* List of supported editors in priority order
|
||||
*/
|
||||
const SUPPORTED_EDITORS: EditorDefinition[] = [
|
||||
{ name: 'Cursor', cliCommand: 'cursor', macAppName: 'Cursor' },
|
||||
{ name: 'VS Code', cliCommand: 'code', macAppName: 'Visual Studio Code' },
|
||||
{
|
||||
name: 'VS Code Insiders',
|
||||
cliCommand: 'code-insiders',
|
||||
macAppName: 'Visual Studio Code - Insiders',
|
||||
},
|
||||
{ name: 'Kiro', cliCommand: 'kiro', macAppName: 'Kiro' },
|
||||
{ name: 'Zed', cliCommand: 'zed', macAppName: 'Zed' },
|
||||
{ name: 'Sublime Text', cliCommand: 'subl', macAppName: 'Sublime Text' },
|
||||
{ name: 'Windsurf', cliCommand: 'windsurf', macAppName: 'Windsurf' },
|
||||
{ name: 'Trae', cliCommand: 'trae', macAppName: 'Trae' },
|
||||
{ name: 'Rider', cliCommand: 'rider', macAppName: 'Rider' },
|
||||
{ name: 'WebStorm', cliCommand: 'webstorm', macAppName: 'WebStorm' },
|
||||
{ name: 'Xcode', cliCommand: 'xed', macAppName: 'Xcode', macOnly: true },
|
||||
{ name: 'Android Studio', cliCommand: 'studio', macAppName: 'Android Studio' },
|
||||
{
|
||||
name: 'Antigravity',
|
||||
cliCommand: PRIMARY_ANTIGRAVITY_COMMAND,
|
||||
cliAliases: LEGACY_ANTIGRAVITY_COMMANDS,
|
||||
macAppName: 'Antigravity',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if Xcode is fully installed (not just Command Line Tools)
|
||||
* xed command requires full Xcode.app, not just CLT
|
||||
*/
|
||||
async function isXcodeFullyInstalled(): Promise<boolean> {
|
||||
if (!isMac) return false;
|
||||
|
||||
try {
|
||||
// Check if xcode-select points to full Xcode, not just CommandLineTools
|
||||
const { stdout } = await execFileAsync('xcode-select', ['-p']);
|
||||
const devPath = stdout.trim();
|
||||
|
||||
// Full Xcode path: /Applications/Xcode.app/Contents/Developer
|
||||
// Command Line Tools: /Library/Developer/CommandLineTools
|
||||
const isPointingToXcode = devPath.includes('Xcode.app');
|
||||
|
||||
if (!isPointingToXcode && devPath.includes('CommandLineTools')) {
|
||||
// Check if xed command exists (indicates CLT are installed)
|
||||
const xedExists = await commandExists('xed');
|
||||
|
||||
// Check if Xcode.app actually exists
|
||||
const xcodeAppPath = await findMacApp('Xcode');
|
||||
|
||||
if (xedExists && xcodeAppPath) {
|
||||
console.warn(
|
||||
'Xcode is installed but xcode-select is pointing to Command Line Tools. ' +
|
||||
'To use Xcode as an editor, run: sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return isPointingToXcode;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find an editor - checks CLI first, then macOS app bundle
|
||||
* Returns EditorInfo if found, null otherwise
|
||||
*/
|
||||
async function findEditor(definition: EditorDefinition): Promise<EditorInfo | null> {
|
||||
// Skip macOS-only editors on other platforms
|
||||
if (definition.macOnly && !isMac) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Special handling for Xcode: verify full installation, not just xed command
|
||||
if (definition.name === 'Xcode') {
|
||||
if (!(await isXcodeFullyInstalled())) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Try CLI command first (works on all platforms)
|
||||
const cliCandidates = [definition.cliCommand, ...(definition.cliAliases ?? [])];
|
||||
for (const cliCommand of cliCandidates) {
|
||||
if (await commandExists(cliCommand)) {
|
||||
return { name: definition.name, command: cliCommand };
|
||||
}
|
||||
}
|
||||
|
||||
// Try macOS app bundle (checks /Applications and ~/Applications)
|
||||
if (isMac) {
|
||||
const appPath = await findMacApp(definition.macAppName);
|
||||
if (appPath) {
|
||||
// Use 'open -a' with full path for apps not in /Applications
|
||||
return { name: definition.name, command: `open -a "${appPath}"` };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the platform-specific file manager
|
||||
*/
|
||||
function getFileManagerInfo(): EditorInfo {
|
||||
if (isMac) {
|
||||
return { name: 'Finder', command: 'open' };
|
||||
} else if (isWindows) {
|
||||
return { name: 'Explorer', command: 'explorer' };
|
||||
} else {
|
||||
return { name: 'File Manager', command: 'xdg-open' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all available code editors on the system
|
||||
* Results are cached for 5 minutes for performance
|
||||
*/
|
||||
export async function detectAllEditors(): Promise<EditorInfo[]> {
|
||||
// Return cached result if still valid
|
||||
if (isCacheValid() && cachedEditors) {
|
||||
return cachedEditors;
|
||||
}
|
||||
|
||||
// Check all editors in parallel for better performance
|
||||
const editorChecks = SUPPORTED_EDITORS.map((def) => findEditor(def));
|
||||
const results = await Promise.all(editorChecks);
|
||||
|
||||
// Filter out null results (editors not found)
|
||||
const editors = results.filter((e): e is EditorInfo => e !== null);
|
||||
|
||||
// Always add file manager as fallback
|
||||
editors.push(getFileManagerInfo());
|
||||
|
||||
// Update cache
|
||||
cachedEditors = editors;
|
||||
cacheTimestamp = Date.now();
|
||||
|
||||
return editors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the default (first available) code editor on the system
|
||||
* Returns the highest priority editor that is installed
|
||||
*/
|
||||
export async function detectDefaultEditor(): Promise<EditorInfo> {
|
||||
const editors = await detectAllEditors();
|
||||
// Return first editor (highest priority) - always exists due to file manager fallback
|
||||
return editors[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a specific editor by command
|
||||
* Returns the editor info if available, null otherwise
|
||||
*/
|
||||
export async function findEditorByCommand(command: string): Promise<EditorInfo | null> {
|
||||
const editors = await detectAllEditors();
|
||||
return editors.find((e) => e.command === command) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a path in the specified editor
|
||||
*
|
||||
* Handles cross-platform differences:
|
||||
* - On Windows, uses spawn with shell:true to handle .cmd batch scripts
|
||||
* - On macOS, handles 'open -a' style commands for app bundles
|
||||
* - On Linux, uses direct execution
|
||||
*
|
||||
* @param targetPath - The file or directory path to open
|
||||
* @param editorCommand - The editor command to use (optional, uses default if not specified)
|
||||
* @returns Promise that resolves with editor info when launched, rejects on error
|
||||
*/
|
||||
export async function openInEditor(
|
||||
targetPath: string,
|
||||
editorCommand?: string
|
||||
): Promise<{ editorName: string }> {
|
||||
// Determine which editor to use
|
||||
let editor: EditorInfo;
|
||||
|
||||
if (editorCommand) {
|
||||
const found = await findEditorByCommand(editorCommand);
|
||||
if (found) {
|
||||
editor = found;
|
||||
} else {
|
||||
// Fall back to default if specified editor not found
|
||||
editor = await detectDefaultEditor();
|
||||
}
|
||||
} else {
|
||||
editor = await detectDefaultEditor();
|
||||
}
|
||||
|
||||
// Execute the editor
|
||||
await executeEditorCommand(editor.command, targetPath);
|
||||
|
||||
return { editorName: editor.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an editor command with a path argument
|
||||
* Handles platform-specific differences in command execution
|
||||
*/
|
||||
async function executeEditorCommand(command: string, targetPath: string): Promise<void> {
|
||||
// Handle 'open -a "AppPath"' style commands (macOS app bundles)
|
||||
if (command.startsWith('open -a ')) {
|
||||
const appPath = command.replace('open -a ', '').replace(/"/g, '');
|
||||
await execFileAsync('open', ['-a', appPath, targetPath]);
|
||||
return;
|
||||
}
|
||||
|
||||
// On Windows, editor CLI commands are typically .cmd batch scripts
|
||||
// spawn with shell:true is required to execute them properly
|
||||
if (isWindows) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child: ChildProcess = spawn(command, [targetPath], {
|
||||
shell: true,
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
});
|
||||
|
||||
// Unref to allow the parent process to exit independently
|
||||
child.unref();
|
||||
|
||||
child.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// Resolve after a small delay to catch immediate spawn errors
|
||||
// Editors run in background, so we don't wait for them to exit
|
||||
setTimeout(() => resolve(), 100);
|
||||
});
|
||||
}
|
||||
|
||||
// Unix/macOS: use execFile for direct execution
|
||||
await execFileAsync(command, [targetPath]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a path in the platform's default file manager
|
||||
* Always available as a fallback option
|
||||
*/
|
||||
export async function openInFileManager(targetPath: string): Promise<{ editorName: string }> {
|
||||
const fileManager = getFileManagerInfo();
|
||||
await execFileAsync(fileManager.command, [targetPath]);
|
||||
return { editorName: fileManager.name };
|
||||
}
|
||||
@@ -97,6 +97,7 @@ export {
|
||||
getCodexCliPaths,
|
||||
getCodexConfigDir,
|
||||
getCodexAuthPath,
|
||||
getGitBashPaths,
|
||||
getOpenCodeCliPaths,
|
||||
getOpenCodeConfigDir,
|
||||
getOpenCodeAuthPath,
|
||||
@@ -130,6 +131,7 @@ export {
|
||||
findCodexCliPath,
|
||||
getCodexAuthIndicators,
|
||||
type CodexAuthIndicators,
|
||||
findGitBashPath,
|
||||
findOpenCodeCliPath,
|
||||
getOpenCodeAuthIndicators,
|
||||
type OpenCodeAuthIndicators,
|
||||
@@ -157,3 +159,14 @@ export {
|
||||
|
||||
// Port configuration
|
||||
export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './config/ports.js';
|
||||
|
||||
// Editor detection and launching (cross-platform)
|
||||
export {
|
||||
commandExists,
|
||||
clearEditorCache,
|
||||
detectAllEditors,
|
||||
detectDefaultEditor,
|
||||
findEditorByCommand,
|
||||
openInEditor,
|
||||
openInFileManager,
|
||||
} from './editor.js';
|
||||
|
||||
@@ -232,6 +232,87 @@ export function getClaudeProjectsDir(): string {
|
||||
return path.join(getClaudeConfigDir(), 'projects');
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate directories matching a prefix pattern and return full paths
|
||||
* Used to resolve dynamic directory names like version numbers
|
||||
*/
|
||||
function enumerateMatchingPaths(
|
||||
parentDir: string,
|
||||
prefix: string,
|
||||
...subPathParts: string[]
|
||||
): string[] {
|
||||
try {
|
||||
if (!fsSync.existsSync(parentDir)) {
|
||||
return [];
|
||||
}
|
||||
const entries = fsSync.readdirSync(parentDir);
|
||||
const matching = entries.filter((entry) => entry.startsWith(prefix));
|
||||
return matching.map((entry) => path.join(parentDir, entry, ...subPathParts));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get common Git Bash installation paths on Windows
|
||||
* Git Bash is needed for running shell scripts cross-platform
|
||||
*/
|
||||
export function getGitBashPaths(): string[] {
|
||||
if (process.platform !== 'win32') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const homeDir = os.homedir();
|
||||
const localAppData = process.env.LOCALAPPDATA || '';
|
||||
|
||||
// Dynamic paths that require directory enumeration
|
||||
// winget installs to: LocalAppData\Microsoft\WinGet\Packages\Git.Git_<hash>\bin\bash.exe
|
||||
const wingetGitPaths = localAppData
|
||||
? enumerateMatchingPaths(
|
||||
path.join(localAppData, 'Microsoft', 'WinGet', 'Packages'),
|
||||
'Git.Git_',
|
||||
'bin',
|
||||
'bash.exe'
|
||||
)
|
||||
: [];
|
||||
|
||||
// GitHub Desktop bundles Git at: LocalAppData\GitHubDesktop\app-<version>\resources\app\git\cmd\bash.exe
|
||||
const githubDesktopPaths = localAppData
|
||||
? enumerateMatchingPaths(
|
||||
path.join(localAppData, 'GitHubDesktop'),
|
||||
'app-',
|
||||
'resources',
|
||||
'app',
|
||||
'git',
|
||||
'cmd',
|
||||
'bash.exe'
|
||||
)
|
||||
: [];
|
||||
|
||||
return [
|
||||
// Standard Git for Windows installations
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
||||
// User-local installations
|
||||
path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
|
||||
// Scoop package manager
|
||||
path.join(homeDir, 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'),
|
||||
// Chocolatey
|
||||
path.join(
|
||||
process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey',
|
||||
'lib',
|
||||
'git',
|
||||
'tools',
|
||||
'bin',
|
||||
'bash.exe'
|
||||
),
|
||||
// winget installations (dynamically resolved)
|
||||
...wingetGitPaths,
|
||||
// GitHub Desktop bundled Git (dynamically resolved)
|
||||
...githubDesktopPaths,
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get common shell paths for shell detection
|
||||
* Includes both full paths and short names to match $SHELL or PATH entries
|
||||
@@ -550,6 +631,8 @@ function getAllAllowedSystemPaths(): string[] {
|
||||
getOpenCodeAuthPath(),
|
||||
// Shell paths
|
||||
...getShellPaths(),
|
||||
// Git Bash paths (for Windows cross-platform shell script execution)
|
||||
...getGitBashPaths(),
|
||||
// Node.js system paths
|
||||
...getNodeSystemPaths(),
|
||||
getScoopNodePath(),
|
||||
@@ -883,6 +966,13 @@ export async function findCodexCliPath(): Promise<string | null> {
|
||||
return findFirstExistingPath(getCodexCliPaths());
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Git Bash on Windows and return its path
|
||||
*/
|
||||
export async function findGitBashPath(): Promise<string | null> {
|
||||
return findFirstExistingPath(getGitBashPaths());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Claude authentication status by checking various indicators
|
||||
*/
|
||||
|
||||
@@ -399,7 +399,7 @@ describe('enhancement.ts', () => {
|
||||
const modes = getAvailableEnhancementModes();
|
||||
|
||||
expect(Array.isArray(modes)).toBe(true);
|
||||
expect(modes.length).toBe(4);
|
||||
expect(modes.length).toBe(5);
|
||||
});
|
||||
|
||||
it('should include all valid modes', () => {
|
||||
@@ -409,6 +409,7 @@ describe('enhancement.ts', () => {
|
||||
expect(modes).toContain('technical');
|
||||
expect(modes).toContain('simplify');
|
||||
expect(modes).toContain('acceptance');
|
||||
expect(modes).toContain('ux-reviewer');
|
||||
});
|
||||
|
||||
it('should return modes in consistent order', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user