mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +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
|
# API key files
|
||||||
data/.api-key
|
data/.api-key
|
||||||
data/credentials.json
|
data/credentials.json
|
||||||
|
data/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automaker/server",
|
"name": "@automaker/server",
|
||||||
"version": "0.9.0",
|
"version": "0.10.0",
|
||||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||||
"author": "AutoMaker Team",
|
"author": "AutoMaker Team",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ app.use('/api/sessions', createSessionsRoutes(agentService));
|
|||||||
app.use('/api/features', createFeaturesRoutes(featureLoader));
|
app.use('/api/features', createFeaturesRoutes(featureLoader));
|
||||||
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
||||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
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/git', createGitRoutes());
|
||||||
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
||||||
app.use('/api/models', createModelsRoutes());
|
app.use('/api/models', createModelsRoutes());
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ export interface WorktreeMetadata {
|
|||||||
branch: string;
|
branch: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
pr?: WorktreePRInfo;
|
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() {
|
export function createApplyHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, plan } = req.body as {
|
const {
|
||||||
|
projectPath,
|
||||||
|
plan,
|
||||||
|
branchName: rawBranchName,
|
||||||
|
} = req.body as {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
plan: BacklogPlanResult;
|
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) {
|
if (!projectPath) {
|
||||||
res.status(400).json({ success: false, error: 'projectPath required' });
|
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||||
return;
|
return;
|
||||||
@@ -82,6 +93,7 @@ export function createApplyHandler() {
|
|||||||
dependencies: change.feature.dependencies,
|
dependencies: change.feature.dependencies,
|
||||||
priority: change.feature.priority,
|
priority: change.feature.priority,
|
||||||
status: 'backlog',
|
status: 'backlog',
|
||||||
|
branchName,
|
||||||
});
|
});
|
||||||
|
|
||||||
appliedChanges.push(`added:${newFeature.id}`);
|
appliedChanges.push(`added:${newFeature.id}`);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { resolveModelString } from '@automaker/model-resolver';
|
|||||||
import {
|
import {
|
||||||
CLAUDE_MODEL_MAP,
|
CLAUDE_MODEL_MAP,
|
||||||
isCursorModel,
|
isCursorModel,
|
||||||
|
isOpencodeModel,
|
||||||
stripProviderPrefix,
|
stripProviderPrefix,
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
getThinkingTokenBudget,
|
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 prompt - The enhancement prompt
|
||||||
* @param model - The Cursor model to use
|
* @param model - The model to use
|
||||||
* @returns The enhanced text
|
* @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);
|
const provider = ProviderFactory.getProviderForModel(model);
|
||||||
// Strip provider prefix - providers expect bare model IDs
|
// Strip provider prefix - providers expect bare model IDs
|
||||||
const bareModel = stripProviderPrefix(model);
|
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
|
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
|
||||||
readOnly: true, // Prompt enhancement only generates text, doesn't write files
|
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) {
|
for (const block of msg.message.content) {
|
||||||
if (block.type === 'text' && block.text) {
|
if (block.type === 'text' && block.text) {
|
||||||
responseText += block.text;
|
responseText += block.text;
|
||||||
@@ -212,7 +217,14 @@ export function createEnhanceHandler(
|
|||||||
|
|
||||||
// Cursor doesn't have a separate system prompt concept, so combine them
|
// Cursor doesn't have a separate system prompt concept, so combine them
|
||||||
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
|
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 {
|
} else {
|
||||||
// Use Claude SDK for Claude models
|
// Use Claude SDK for Claude models
|
||||||
logger.info(`Using Claude provider for model: ${resolvedModel}`);
|
logger.info(`Using Claude provider for model: ${resolvedModel}`);
|
||||||
|
|||||||
@@ -10,14 +10,21 @@ import { getErrorMessage, logError } from '../common.js';
|
|||||||
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, featureId, updates, descriptionHistorySource, enhancementMode } =
|
const {
|
||||||
req.body as {
|
projectPath,
|
||||||
projectPath: string;
|
featureId,
|
||||||
featureId: string;
|
updates,
|
||||||
updates: Partial<Feature>;
|
descriptionHistorySource,
|
||||||
descriptionHistorySource?: 'enhance' | 'edit';
|
enhancementMode,
|
||||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
|
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) {
|
if (!projectPath || !featureId || !updates) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -32,7 +39,8 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
|||||||
featureId,
|
featureId,
|
||||||
updates,
|
updates,
|
||||||
descriptionHistorySource,
|
descriptionHistorySource,
|
||||||
enhancementMode
|
enhancementMode,
|
||||||
|
preEnhancementDescription
|
||||||
);
|
);
|
||||||
res.json({ success: true, feature: updated });
|
res.json({ success: true, feature: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ import { createDeauthCursorHandler } from './routes/deauth-cursor.js';
|
|||||||
import { createAuthOpencodeHandler } from './routes/auth-opencode.js';
|
import { createAuthOpencodeHandler } from './routes/auth-opencode.js';
|
||||||
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
|
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
|
||||||
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
|
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
|
||||||
|
import {
|
||||||
|
createGetOpencodeModelsHandler,
|
||||||
|
createRefreshOpencodeModelsHandler,
|
||||||
|
createGetOpencodeProvidersHandler,
|
||||||
|
createClearOpencodeCacheHandler,
|
||||||
|
} from './routes/opencode-models.js';
|
||||||
import {
|
import {
|
||||||
createGetCursorConfigHandler,
|
createGetCursorConfigHandler,
|
||||||
createSetCursorDefaultModelHandler,
|
createSetCursorDefaultModelHandler,
|
||||||
@@ -65,6 +71,12 @@ export function createSetupRoutes(): Router {
|
|||||||
router.get('/opencode-status', createOpencodeStatusHandler());
|
router.get('/opencode-status', createOpencodeStatusHandler());
|
||||||
router.post('/auth-opencode', createAuthOpencodeHandler());
|
router.post('/auth-opencode', createAuthOpencodeHandler());
|
||||||
router.post('/deauth-opencode', createDeauthOpencodeHandler());
|
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.get('/cursor-config', createGetCursorConfigHandler());
|
||||||
router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler());
|
router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler());
|
||||||
router.post('/cursor-config/models', createSetCursorModelsHandler());
|
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 { createLogger } from '@automaker/utils';
|
||||||
|
import { spawnProcess } from '@automaker/platform';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import path from 'path';
|
|
||||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
|
||||||
|
|
||||||
const logger = createLogger('Worktree');
|
const logger = createLogger('Worktree');
|
||||||
export const execAsync = promisify(exec);
|
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
|
// Constants
|
||||||
@@ -99,18 +135,6 @@ export function normalizePath(p: string): string {
|
|||||||
return p.replace(/\\/g, '/');
|
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)
|
* Check if a git repository has at least one commit (i.e., HEAD exists)
|
||||||
* Returns false for freshly initialized repos with no commits
|
* Returns false for freshly initialized repos with no commits
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||||
import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js';
|
import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js';
|
||||||
import { createInfoHandler } from './routes/info.js';
|
import { createInfoHandler } from './routes/info.js';
|
||||||
@@ -24,14 +25,22 @@ import { createSwitchBranchHandler } from './routes/switch-branch.js';
|
|||||||
import {
|
import {
|
||||||
createOpenInEditorHandler,
|
createOpenInEditorHandler,
|
||||||
createGetDefaultEditorHandler,
|
createGetDefaultEditorHandler,
|
||||||
|
createGetAvailableEditorsHandler,
|
||||||
|
createRefreshEditorsHandler,
|
||||||
} from './routes/open-in-editor.js';
|
} from './routes/open-in-editor.js';
|
||||||
import { createInitGitHandler } from './routes/init-git.js';
|
import { createInitGitHandler } from './routes/init-git.js';
|
||||||
import { createMigrateHandler } from './routes/migrate.js';
|
import { createMigrateHandler } from './routes/migrate.js';
|
||||||
import { createStartDevHandler } from './routes/start-dev.js';
|
import { createStartDevHandler } from './routes/start-dev.js';
|
||||||
import { createStopDevHandler } from './routes/stop-dev.js';
|
import { createStopDevHandler } from './routes/stop-dev.js';
|
||||||
import { createListDevServersHandler } from './routes/list-dev-servers.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();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
|
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
|
||||||
@@ -45,7 +54,7 @@ export function createWorktreeRoutes(): Router {
|
|||||||
requireValidProject,
|
requireValidProject,
|
||||||
createMergeHandler()
|
createMergeHandler()
|
||||||
);
|
);
|
||||||
router.post('/create', validatePathParams('projectPath'), createCreateHandler());
|
router.post('/create', validatePathParams('projectPath'), createCreateHandler(events));
|
||||||
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
|
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
|
||||||
router.post('/create-pr', createCreatePRHandler());
|
router.post('/create-pr', createCreatePRHandler());
|
||||||
router.post('/pr-info', createPRInfoHandler());
|
router.post('/pr-info', createPRInfoHandler());
|
||||||
@@ -77,6 +86,8 @@ export function createWorktreeRoutes(): Router {
|
|||||||
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
|
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
|
||||||
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
||||||
router.get('/default-editor', createGetDefaultEditorHandler());
|
router.get('/default-editor', createGetDefaultEditorHandler());
|
||||||
|
router.get('/available-editors', createGetAvailableEditorsHandler());
|
||||||
|
router.post('/refresh-editors', createRefreshEditorsHandler());
|
||||||
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
|
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
|
||||||
router.post('/migrate', createMigrateHandler());
|
router.post('/migrate', createMigrateHandler());
|
||||||
router.post(
|
router.post(
|
||||||
@@ -87,5 +98,15 @@ export function createWorktreeRoutes(): Router {
|
|||||||
router.post('/stop-dev', createStopDevHandler());
|
router.post('/stop-dev', createStopDevHandler());
|
||||||
router.post('/list-dev-servers', createListDevServersHandler());
|
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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response, NextFunction } from 'express';
|
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 {
|
interface ValidationOptions {
|
||||||
/** Check if the path is a git repository (default: true) */
|
/** Check if the path is a git repository (default: true) */
|
||||||
|
|||||||
@@ -12,15 +12,19 @@ import { exec } from 'child_process';
|
|||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import * as secureFs from '../../../lib/secure-fs.js';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
|
import { isGitRepo } from '@automaker/git-utils';
|
||||||
import {
|
import {
|
||||||
isGitRepo,
|
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
logError,
|
logError,
|
||||||
normalizePath,
|
normalizePath,
|
||||||
ensureInitialCommit,
|
ensureInitialCommit,
|
||||||
|
isValidBranchName,
|
||||||
|
execGitCommand,
|
||||||
} from '../common.js';
|
} from '../common.js';
|
||||||
import { trackBranch } from './branch-tracking.js';
|
import { trackBranch } from './branch-tracking.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import { runInitScript } from '../../../services/init-script-service.js';
|
||||||
|
|
||||||
const logger = createLogger('Worktree');
|
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> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, branchName, baseBranch } = req.body as {
|
const { projectPath, branchName, baseBranch } = req.body as {
|
||||||
@@ -94,6 +98,26 @@ export function createCreateHandler() {
|
|||||||
return;
|
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))) {
|
if (!(await isGitRepo(projectPath))) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -143,30 +167,28 @@ export function createCreateHandler() {
|
|||||||
// Create worktrees directory if it doesn't exist
|
// Create worktrees directory if it doesn't exist
|
||||||
await secureFs.mkdir(worktreesDir, { recursive: true });
|
await secureFs.mkdir(worktreesDir, { recursive: true });
|
||||||
|
|
||||||
// Check if branch exists
|
// Check if branch exists (using array arguments to prevent injection)
|
||||||
let branchExists = false;
|
let branchExists = false;
|
||||||
try {
|
try {
|
||||||
await execAsync(`git rev-parse --verify ${branchName}`, {
|
await execGitCommand(['rev-parse', '--verify', branchName], projectPath);
|
||||||
cwd: projectPath,
|
|
||||||
});
|
|
||||||
branchExists = true;
|
branchExists = true;
|
||||||
} catch {
|
} catch {
|
||||||
// Branch doesn't exist
|
// Branch doesn't exist
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create worktree
|
// Create worktree (using array arguments to prevent injection)
|
||||||
let createCmd: string;
|
|
||||||
if (branchExists) {
|
if (branchExists) {
|
||||||
// Use existing branch
|
// Use existing branch
|
||||||
createCmd = `git worktree add "${worktreePath}" ${branchName}`;
|
await execGitCommand(['worktree', 'add', worktreePath, branchName], projectPath);
|
||||||
} else {
|
} else {
|
||||||
// Create new branch from base or HEAD
|
// Create new branch from base or HEAD
|
||||||
const base = baseBranch || '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
|
// Note: We intentionally do NOT symlink .automaker to worktrees
|
||||||
// Features and config are always accessed from the main project path
|
// Features and config are always accessed from the main project path
|
||||||
// This avoids symlink loop issues when activating worktrees
|
// This avoids symlink loop issues when activating worktrees
|
||||||
@@ -177,6 +199,8 @@ export function createCreateHandler() {
|
|||||||
// Resolve to absolute path for cross-platform compatibility
|
// Resolve to absolute path for cross-platform compatibility
|
||||||
// normalizePath converts to forward slashes for API consistency
|
// normalizePath converts to forward slashes for API consistency
|
||||||
const absoluteWorktreePath = path.resolve(worktreePath);
|
const absoluteWorktreePath = path.resolve(worktreePath);
|
||||||
|
|
||||||
|
// Respond immediately (non-blocking)
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
worktree: {
|
worktree: {
|
||||||
@@ -185,6 +209,17 @@ export function createCreateHandler() {
|
|||||||
isNew: !branchExists,
|
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) {
|
} catch (error) {
|
||||||
logError(error, 'Create worktree failed');
|
logError(error, 'Create worktree failed');
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
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 { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { isGitRepo } from '@automaker/git-utils';
|
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 execAsync = promisify(exec);
|
||||||
|
const logger = createLogger('Worktree');
|
||||||
|
|
||||||
export function createDeleteHandler() {
|
export function createDeleteHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
@@ -46,22 +48,28 @@ export function createDeleteHandler() {
|
|||||||
// Could not get branch name
|
// Could not get branch name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the worktree
|
// Remove the worktree (using array arguments to prevent injection)
|
||||||
try {
|
try {
|
||||||
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
|
||||||
cwd: projectPath,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Try with prune if remove fails
|
// Try with prune if remove fails
|
||||||
await execAsync('git worktree prune', { cwd: projectPath });
|
await execGitCommand(['worktree', 'prune'], projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally delete the branch
|
// Optionally delete the branch
|
||||||
|
let branchDeleted = false;
|
||||||
if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') {
|
if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') {
|
||||||
try {
|
// Validate branch name to prevent command injection
|
||||||
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
|
if (!isValidBranchName(branchName)) {
|
||||||
} catch {
|
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
|
||||||
// Branch deletion failed, not critical
|
} 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,
|
success: true,
|
||||||
deleted: {
|
deleted: {
|
||||||
worktreePath,
|
worktreePath,
|
||||||
branch: deleteBranch ? branchName : null,
|
branch: branchDeleted ? branchName : null,
|
||||||
|
branchDeleted,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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
|
* 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
|
* 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 type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import { isAbsolute } from 'path';
|
||||||
import { promisify } from 'util';
|
import {
|
||||||
|
clearEditorCache,
|
||||||
|
detectAllEditors,
|
||||||
|
detectDefaultEditor,
|
||||||
|
openInEditor,
|
||||||
|
openInFileManager,
|
||||||
|
} from '@automaker/platform';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const logger = createLogger('open-in-editor');
|
||||||
|
|
||||||
// Editor detection with caching
|
export function createGetAvailableEditorsHandler() {
|
||||||
interface EditorInfo {
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
name: string;
|
try {
|
||||||
command: string;
|
const editors = await detectAllEditors();
|
||||||
}
|
res.json({
|
||||||
|
success: true,
|
||||||
let cachedEditor: EditorInfo | null = null;
|
result: {
|
||||||
|
editors,
|
||||||
/**
|
},
|
||||||
* Detect which code editor is available on the system
|
});
|
||||||
*/
|
} catch (error) {
|
||||||
async function detectDefaultEditor(): Promise<EditorInfo> {
|
logError(error, 'Get available editors failed');
|
||||||
// Return cached result if available
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
if (cachedEditor) {
|
}
|
||||||
return cachedEditor;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Try Cursor first (if user has Cursor, they probably prefer it)
|
|
||||||
try {
|
|
||||||
await execAsync('which cursor || where cursor');
|
|
||||||
cachedEditor = { name: 'Cursor', command: 'cursor' };
|
|
||||||
return cachedEditor;
|
|
||||||
} catch {
|
|
||||||
// Cursor not found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try VS Code
|
|
||||||
try {
|
|
||||||
await execAsync('which code || where code');
|
|
||||||
cachedEditor = { name: 'VS Code', command: 'code' };
|
|
||||||
return cachedEditor;
|
|
||||||
} catch {
|
|
||||||
// VS Code not found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try Zed
|
|
||||||
try {
|
|
||||||
await execAsync('which zed || where zed');
|
|
||||||
cachedEditor = { name: 'Zed', command: 'zed' };
|
|
||||||
return cachedEditor;
|
|
||||||
} catch {
|
|
||||||
// Zed not found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try Sublime Text
|
|
||||||
try {
|
|
||||||
await execAsync('which subl || where subl');
|
|
||||||
cachedEditor = { name: 'Sublime Text', command: 'subl' };
|
|
||||||
return cachedEditor;
|
|
||||||
} catch {
|
|
||||||
// Sublime not found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to file manager
|
|
||||||
const platform = process.platform;
|
|
||||||
if (platform === 'darwin') {
|
|
||||||
cachedEditor = { name: 'Finder', command: 'open' };
|
|
||||||
} else if (platform === 'win32') {
|
|
||||||
cachedEditor = { name: 'Explorer', command: 'explorer' };
|
|
||||||
} else {
|
|
||||||
cachedEditor = { name: 'File Manager', command: 'xdg-open' };
|
|
||||||
}
|
|
||||||
return cachedEditor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGetDefaultEditorHandler() {
|
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() {
|
export function createOpenInEditorHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath } = req.body as {
|
const { worktreePath, editorCommand } = req.body as {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
|
editorCommand?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!worktreePath) {
|
if (!worktreePath) {
|
||||||
@@ -108,42 +100,44 @@ export function createOpenInEditorHandler() {
|
|||||||
return;
|
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 {
|
try {
|
||||||
await execAsync(`${editor.command} "${worktreePath}"`);
|
// Use the platform utility to open in editor
|
||||||
|
const result = await openInEditor(worktreePath, editorCommand);
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
message: `Opened ${worktreePath} in ${editor.name}`,
|
message: `Opened ${worktreePath} in ${result.editorName}`,
|
||||||
editorName: editor.name,
|
editorName: result.editorName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (editorError) {
|
} catch (editorError) {
|
||||||
// If the detected editor fails, try opening in default file manager as fallback
|
// If the specified editor fails, try opening in default file manager as fallback
|
||||||
const platform = process.platform;
|
logger.warn(
|
||||||
let openCommand: string;
|
`Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}`
|
||||||
let fallbackName: string;
|
);
|
||||||
|
|
||||||
if (platform === 'darwin') {
|
try {
|
||||||
openCommand = `open "${worktreePath}"`;
|
const result = await openInFileManager(worktreePath);
|
||||||
fallbackName = 'Finder';
|
res.json({
|
||||||
} else if (platform === 'win32') {
|
success: true,
|
||||||
openCommand = `explorer "${worktreePath}"`;
|
result: {
|
||||||
fallbackName = 'Explorer';
|
message: `Opened ${worktreePath} in ${result.editorName}`,
|
||||||
} else {
|
editorName: result.editorName,
|
||||||
openCommand = `xdg-open "${worktreePath}"`;
|
},
|
||||||
fallbackName = 'File Manager';
|
});
|
||||||
|
} 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) {
|
} catch (error) {
|
||||||
logError(error, 'Open in editor failed');
|
logError(error, 'Open in editor failed');
|
||||||
|
|||||||
@@ -308,13 +308,15 @@ export class FeatureLoader {
|
|||||||
* @param updates - Partial feature updates
|
* @param updates - Partial feature updates
|
||||||
* @param descriptionHistorySource - Source of description change ('enhance' or 'edit')
|
* @param descriptionHistorySource - Source of description change ('enhance' or 'edit')
|
||||||
* @param enhancementMode - Enhancement mode if source is 'enhance'
|
* @param enhancementMode - Enhancement mode if source is 'enhance'
|
||||||
|
* @param preEnhancementDescription - Description before enhancement (for restoring original)
|
||||||
*/
|
*/
|
||||||
async update(
|
async update(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
updates: Partial<Feature>,
|
updates: Partial<Feature>,
|
||||||
descriptionHistorySource?: 'enhance' | 'edit',
|
descriptionHistorySource?: 'enhance' | 'edit',
|
||||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||||
|
preEnhancementDescription?: string
|
||||||
): Promise<Feature> {
|
): Promise<Feature> {
|
||||||
const feature = await this.get(projectPath, featureId);
|
const feature = await this.get(projectPath, featureId);
|
||||||
if (!feature) {
|
if (!feature) {
|
||||||
@@ -338,9 +340,31 @@ export class FeatureLoader {
|
|||||||
updates.description !== feature.description &&
|
updates.description !== feature.description &&
|
||||||
updates.description.trim()
|
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 = {
|
const historyEntry: DescriptionHistoryEntry = {
|
||||||
description: updates.description,
|
description: updates.description,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp,
|
||||||
source: descriptionHistorySource || 'edit',
|
source: descriptionHistorySource || 'edit',
|
||||||
...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}),
|
...(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,
|
type EnhancementMode,
|
||||||
} from '@/lib/enhancement-prompts.js';
|
} from '@/lib/enhancement-prompts.js';
|
||||||
|
|
||||||
|
const ENHANCEMENT_MODES: EnhancementMode[] = [
|
||||||
|
'improve',
|
||||||
|
'technical',
|
||||||
|
'simplify',
|
||||||
|
'acceptance',
|
||||||
|
'ux-reviewer',
|
||||||
|
];
|
||||||
|
|
||||||
describe('enhancement-prompts.ts', () => {
|
describe('enhancement-prompts.ts', () => {
|
||||||
describe('System Prompt Constants', () => {
|
describe('System Prompt Constants', () => {
|
||||||
it('should have non-empty improve system prompt', () => {
|
it('should have non-empty improve system prompt', () => {
|
||||||
@@ -184,8 +192,7 @@ describe('enhancement-prompts.ts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should work with all enhancement modes', () => {
|
it('should work with all enhancement modes', () => {
|
||||||
const modes: EnhancementMode[] = ['improve', 'technical', 'simplify', 'acceptance'];
|
ENHANCEMENT_MODES.forEach((mode) => {
|
||||||
modes.forEach((mode) => {
|
|
||||||
const prompt = buildUserPrompt(mode, testText);
|
const prompt = buildUserPrompt(mode, testText);
|
||||||
expect(prompt).toContain(testText);
|
expect(prompt).toContain(testText);
|
||||||
expect(prompt.length).toBeGreaterThan(100);
|
expect(prompt.length).toBeGreaterThan(100);
|
||||||
@@ -205,6 +212,7 @@ describe('enhancement-prompts.ts', () => {
|
|||||||
expect(isValidEnhancementMode('technical')).toBe(true);
|
expect(isValidEnhancementMode('technical')).toBe(true);
|
||||||
expect(isValidEnhancementMode('simplify')).toBe(true);
|
expect(isValidEnhancementMode('simplify')).toBe(true);
|
||||||
expect(isValidEnhancementMode('acceptance')).toBe(true);
|
expect(isValidEnhancementMode('acceptance')).toBe(true);
|
||||||
|
expect(isValidEnhancementMode('ux-reviewer')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for invalid modes', () => {
|
it('should return false for invalid modes', () => {
|
||||||
@@ -216,13 +224,12 @@ describe('enhancement-prompts.ts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getAvailableEnhancementModes', () => {
|
describe('getAvailableEnhancementModes', () => {
|
||||||
it('should return all four enhancement modes', () => {
|
it('should return all enhancement modes', () => {
|
||||||
const modes = getAvailableEnhancementModes();
|
const modes = getAvailableEnhancementModes();
|
||||||
expect(modes).toHaveLength(4);
|
expect(modes).toHaveLength(ENHANCEMENT_MODES.length);
|
||||||
expect(modes).toContain('improve');
|
ENHANCEMENT_MODES.forEach((mode) => {
|
||||||
expect(modes).toContain('technical');
|
expect(modes).toContain(mode);
|
||||||
expect(modes).toContain('simplify');
|
});
|
||||||
expect(modes).toContain('acceptance');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return an array', () => {
|
it('should return an array', () => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
OpencodeProvider,
|
OpencodeProvider,
|
||||||
resetToolUseIdCounter,
|
resetToolUseIdCounter,
|
||||||
} from '../../../src/providers/opencode-provider.js';
|
} 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 { collectAsyncGenerator } from '../../utils/helpers.js';
|
||||||
import { spawnJSONLProcess, getOpenCodeAuthIndicators } from '@automaker/platform';
|
import { spawnJSONLProcess, getOpenCodeAuthIndicators } from '@automaker/platform';
|
||||||
|
|
||||||
@@ -51,63 +51,38 @@ describe('opencode-provider.ts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getAvailableModels', () => {
|
describe('getAvailableModels', () => {
|
||||||
it('should return 10 models', () => {
|
it('should return 5 models', () => {
|
||||||
const models = provider.getAvailableModels();
|
const models = provider.getAvailableModels();
|
||||||
expect(models).toHaveLength(10);
|
expect(models).toHaveLength(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include Claude Sonnet 4.5 (Bedrock) as default', () => {
|
it('should include Big Pickle as default', () => {
|
||||||
const models = provider.getAvailableModels();
|
|
||||||
const sonnet = models.find(
|
|
||||||
(m) => m.id === 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(sonnet).toBeDefined();
|
|
||||||
expect(sonnet?.name).toBe('Claude Sonnet 4.5 (Bedrock)');
|
|
||||||
expect(sonnet?.provider).toBe('opencode');
|
|
||||||
expect(sonnet?.default).toBe(true);
|
|
||||||
expect(sonnet?.modelString).toBe('amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include Claude Opus 4.5 (Bedrock)', () => {
|
|
||||||
const models = provider.getAvailableModels();
|
|
||||||
const opus = models.find(
|
|
||||||
(m) => m.id === 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(opus).toBeDefined();
|
|
||||||
expect(opus?.name).toBe('Claude Opus 4.5 (Bedrock)');
|
|
||||||
expect(opus?.modelString).toBe('amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include Claude Haiku 4.5 (Bedrock)', () => {
|
|
||||||
const models = provider.getAvailableModels();
|
|
||||||
const haiku = models.find(
|
|
||||||
(m) => m.id === 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(haiku).toBeDefined();
|
|
||||||
expect(haiku?.name).toBe('Claude Haiku 4.5 (Bedrock)');
|
|
||||||
expect(haiku?.tier).toBe('standard');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include free tier Big Pickle model', () => {
|
|
||||||
const models = provider.getAvailableModels();
|
const models = provider.getAvailableModels();
|
||||||
const bigPickle = models.find((m) => m.id === 'opencode/big-pickle');
|
const bigPickle = models.find((m) => m.id === 'opencode/big-pickle');
|
||||||
|
|
||||||
expect(bigPickle).toBeDefined();
|
expect(bigPickle).toBeDefined();
|
||||||
expect(bigPickle?.name).toBe('Big Pickle (Free)');
|
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?.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 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(glm).toBeDefined();
|
||||||
expect(deepseek?.name).toBe('DeepSeek R1 (Bedrock)');
|
expect(glm?.name).toBe('GLM 4.7 Free');
|
||||||
expect(deepseek?.tier).toBe('premium');
|
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', () => {
|
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', () => {
|
describe('supportsFeature', () => {
|
||||||
it("should support 'tools' feature", () => {
|
it("should support 'tools' feature", () => {
|
||||||
expect(provider.supportsFeature('tools')).toBe(true);
|
expect(provider.supportsFeature('tools')).toBe(true);
|
||||||
@@ -1243,7 +1236,7 @@ describe('opencode-provider.ts', () => {
|
|||||||
const defaultModels = models.filter((m) => m.default === true);
|
const defaultModels = models.filter((m) => m.default === true);
|
||||||
|
|
||||||
expect(defaultModels).toHaveLength(1);
|
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', () => {
|
it('should have valid tier values for all models', () => {
|
||||||
|
|||||||
@@ -5,59 +5,61 @@ import {
|
|||||||
getSpecRegenerationStatus,
|
getSpecRegenerationStatus,
|
||||||
} from '@/routes/app-spec/common.js';
|
} from '@/routes/app-spec/common.js';
|
||||||
|
|
||||||
|
const TEST_PROJECT_PATH = '/tmp/automaker-test-project';
|
||||||
|
|
||||||
describe('app-spec/common.ts', () => {
|
describe('app-spec/common.ts', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset state before each test
|
// Reset state before each test
|
||||||
setRunningState(false, null);
|
setRunningState(TEST_PROJECT_PATH, false, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('setRunningState', () => {
|
describe('setRunningState', () => {
|
||||||
it('should set isRunning to true when running is true', () => {
|
it('should set isRunning to true when running is true', () => {
|
||||||
setRunningState(true);
|
setRunningState(TEST_PROJECT_PATH, true);
|
||||||
expect(getSpecRegenerationStatus().isRunning).toBe(true);
|
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set isRunning to false when running is false', () => {
|
it('should set isRunning to false when running is false', () => {
|
||||||
setRunningState(true);
|
setRunningState(TEST_PROJECT_PATH, true);
|
||||||
setRunningState(false);
|
setRunningState(TEST_PROJECT_PATH, false);
|
||||||
expect(getSpecRegenerationStatus().isRunning).toBe(false);
|
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set currentAbortController when provided', () => {
|
it('should set currentAbortController when provided', () => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setRunningState(true, controller);
|
setRunningState(TEST_PROJECT_PATH, true, controller);
|
||||||
expect(getSpecRegenerationStatus().currentAbortController).toBe(controller);
|
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set currentAbortController to null when not provided', () => {
|
it('should set currentAbortController to null when not provided', () => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setRunningState(true, controller);
|
setRunningState(TEST_PROJECT_PATH, true, controller);
|
||||||
setRunningState(false);
|
setRunningState(TEST_PROJECT_PATH, false);
|
||||||
expect(getSpecRegenerationStatus().currentAbortController).toBe(null);
|
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();
|
const controller = new AbortController();
|
||||||
setRunningState(true, controller);
|
setRunningState(TEST_PROJECT_PATH, true, controller);
|
||||||
setRunningState(true, null);
|
setRunningState(TEST_PROJECT_PATH, true, null);
|
||||||
expect(getSpecRegenerationStatus().currentAbortController).toBe(null);
|
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update state multiple times correctly', () => {
|
it('should update state multiple times correctly', () => {
|
||||||
const controller1 = new AbortController();
|
const controller1 = new AbortController();
|
||||||
const controller2 = new AbortController();
|
const controller2 = new AbortController();
|
||||||
|
|
||||||
setRunningState(true, controller1);
|
setRunningState(TEST_PROJECT_PATH, true, controller1);
|
||||||
expect(getSpecRegenerationStatus().isRunning).toBe(true);
|
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true);
|
||||||
expect(getSpecRegenerationStatus().currentAbortController).toBe(controller1);
|
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller1);
|
||||||
|
|
||||||
setRunningState(true, controller2);
|
setRunningState(TEST_PROJECT_PATH, true, controller2);
|
||||||
expect(getSpecRegenerationStatus().isRunning).toBe(true);
|
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true);
|
||||||
expect(getSpecRegenerationStatus().currentAbortController).toBe(controller2);
|
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller2);
|
||||||
|
|
||||||
setRunningState(false, null);
|
setRunningState(TEST_PROJECT_PATH, false, null);
|
||||||
expect(getSpecRegenerationStatus().isRunning).toBe(false);
|
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(false);
|
||||||
expect(getSpecRegenerationStatus().currentAbortController).toBe(null);
|
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import fs from 'fs/promises';
|
|||||||
vi.mock('child_process', () => ({
|
vi.mock('child_process', () => ({
|
||||||
spawn: vi.fn(),
|
spawn: vi.fn(),
|
||||||
execSync: vi.fn(),
|
execSync: vi.fn(),
|
||||||
|
execFile: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock secure-fs
|
// Mock secure-fs
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automaker/ui",
|
"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",
|
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -42,6 +42,8 @@
|
|||||||
"@automaker/dependency-resolver": "1.0.0",
|
"@automaker/dependency-resolver": "1.0.0",
|
||||||
"@automaker/types": "1.0.0",
|
"@automaker/types": "1.0.0",
|
||||||
"@codemirror/lang-xml": "6.1.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",
|
"@codemirror/theme-one-dark": "6.1.3",
|
||||||
"@dnd-kit/core": "6.3.1",
|
"@dnd-kit/core": "6.3.1",
|
||||||
"@dnd-kit/sortable": "10.0.0",
|
"@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 (
|
return (
|
||||||
<aside
|
<>
|
||||||
className={cn(
|
{/* Mobile overlay backdrop */}
|
||||||
'flex-shrink-0 flex flex-col z-30 relative',
|
{sidebarOpen && (
|
||||||
// Glass morphism background with gradient
|
<div
|
||||||
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
|
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||||
// Premium border with subtle glow
|
onClick={toggleSidebar}
|
||||||
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
|
aria-hidden="true"
|
||||||
// Smooth width transition
|
/>
|
||||||
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
|
||||||
sidebarOpen ? 'w-16 lg:w-72' : 'w-16'
|
|
||||||
)}
|
)}
|
||||||
data-testid="sidebar"
|
<aside
|
||||||
>
|
className={cn(
|
||||||
<CollapseToggleButton
|
'flex-shrink-0 flex flex-col z-50 relative',
|
||||||
sidebarOpen={sidebarOpen}
|
// Glass morphism background with gradient
|
||||||
toggleSidebar={toggleSidebar}
|
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
|
||||||
shortcut={shortcuts.toggleSidebar}
|
// Premium border with subtle glow
|
||||||
/>
|
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
|
||||||
|
// Smooth width transition
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||||
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
|
// Mobile: hidden when closed, full width overlay when open
|
||||||
|
// Desktop: always visible, toggle between narrow and wide
|
||||||
<ProjectSelectorWithOptions
|
sidebarOpen ? 'fixed lg:relative left-0 top-0 h-full w-72' : 'hidden lg:flex w-16'
|
||||||
|
)}
|
||||||
|
data-testid="sidebar"
|
||||||
|
>
|
||||||
|
<CollapseToggleButton
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
isProjectPickerOpen={isProjectPickerOpen}
|
toggleSidebar={toggleSidebar}
|
||||||
setIsProjectPickerOpen={setIsProjectPickerOpen}
|
shortcut={shortcuts.toggleSidebar}
|
||||||
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SidebarNavigation
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
currentProject={currentProject}
|
<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}
|
sidebarOpen={sidebarOpen}
|
||||||
navSections={navSections}
|
|
||||||
isActiveRoute={isActiveRoute}
|
isActiveRoute={isActiveRoute}
|
||||||
navigate={navigate}
|
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
|
{/* New Project Setup Dialog */}
|
||||||
sidebarOpen={sidebarOpen}
|
<CreateSpecDialog
|
||||||
isActiveRoute={isActiveRoute}
|
open={showSetupDialog}
|
||||||
navigate={navigate}
|
onOpenChange={setShowSetupDialog}
|
||||||
hideWiki={hideWiki}
|
projectOverview={projectOverview}
|
||||||
hideRunningAgents={hideRunningAgents}
|
onProjectOverviewChange={setProjectOverview}
|
||||||
runningAgentsCount={runningAgentsCount}
|
generateFeatures={generateFeatures}
|
||||||
shortcuts={{ settings: shortcuts.settings }}
|
onGenerateFeaturesChange={setGenerateFeatures}
|
||||||
/>
|
analyzeProject={analyzeProject}
|
||||||
<TrashDialog
|
onAnalyzeProjectChange={setAnalyzeProject}
|
||||||
open={showTrashDialog}
|
featureCount={featureCount}
|
||||||
onOpenChange={setShowTrashDialog}
|
onFeatureCountChange={setFeatureCount}
|
||||||
trashedProjects={trashedProjects}
|
onCreateSpec={handleCreateInitialSpec}
|
||||||
activeTrashId={activeTrashId}
|
onSkip={handleSkipSetup}
|
||||||
handleRestoreProject={handleRestoreProject}
|
isCreatingSpec={isCreatingSpec}
|
||||||
handleDeleteProjectFromDisk={handleDeleteProjectFromDisk}
|
showSkipButton={true}
|
||||||
deleteTrashedProject={deleteTrashedProject}
|
title="Set Up Your Project"
|
||||||
handleEmptyTrash={handleEmptyTrash}
|
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."
|
||||||
isEmptyingTrash={isEmptyingTrash}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
{/* New Project Setup Dialog */}
|
<OnboardingDialog
|
||||||
<CreateSpecDialog
|
open={showOnboardingDialog}
|
||||||
open={showSetupDialog}
|
onOpenChange={setShowOnboardingDialog}
|
||||||
onOpenChange={setShowSetupDialog}
|
newProjectName={newProjectName}
|
||||||
projectOverview={projectOverview}
|
onSkip={handleOnboardingSkip}
|
||||||
onProjectOverviewChange={setProjectOverview}
|
onGenerateSpec={handleOnboardingGenerateSpec}
|
||||||
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
|
{/* Delete Project Confirmation Dialog */}
|
||||||
open={showOnboardingDialog}
|
<DeleteProjectDialog
|
||||||
onOpenChange={setShowOnboardingDialog}
|
open={showDeleteProjectDialog}
|
||||||
newProjectName={newProjectName}
|
onOpenChange={setShowDeleteProjectDialog}
|
||||||
onSkip={handleOnboardingSkip}
|
project={currentProject}
|
||||||
onGenerateSpec={handleOnboardingGenerateSpec}
|
onConfirm={moveProjectToTrash}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete Project Confirmation Dialog */}
|
{/* New Project Modal */}
|
||||||
<DeleteProjectDialog
|
<NewProjectModal
|
||||||
open={showDeleteProjectDialog}
|
open={showNewProjectModal}
|
||||||
onOpenChange={setShowDeleteProjectDialog}
|
onOpenChange={setShowNewProjectModal}
|
||||||
project={currentProject}
|
onCreateBlankProject={handleCreateBlankProject}
|
||||||
onConfirm={moveProjectToTrash}
|
onCreateFromTemplate={handleCreateFromTemplate}
|
||||||
/>
|
onCreateFromCustomUrl={handleCreateFromCustomUrl}
|
||||||
|
isCreating={isCreatingProject}
|
||||||
{/* New Project Modal */}
|
/>
|
||||||
<NewProjectModal
|
</aside>
|
||||||
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' })}
|
onClick={() => navigate({ to: '/dashboard' })}
|
||||||
data-testid="logo-button"
|
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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex flex-col items-center justify-center rounded-lg gap-0.5',
|
'relative flex flex-col items-center justify-center rounded-lg gap-0.5',
|
||||||
sidebarOpen ? 'flex lg:hidden' : 'flex'
|
sidebarOpen ? 'hidden' : 'flex'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -90,16 +90,16 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded logo - only shown when sidebar is open on large screens */}
|
{/* Expanded logo - shown when sidebar is open */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div className="hidden lg:flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 256 256"
|
viewBox="0 0 256 256"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="automaker"
|
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>
|
<defs>
|
||||||
<linearGradient
|
<linearGradient
|
||||||
@@ -137,11 +137,11 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
|||||||
<path d="M164 92 L204 128 L164 164" />
|
<path d="M164 92 L204 128 L164 164" />
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</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>
|
automaker<span className="text-brand-500">.</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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}
|
v{appVersion} {versionSuffix}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ export function CollapseToggleButton({
|
|||||||
<button
|
<button
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className={cn(
|
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',
|
'group/toggle items-center justify-center w-7 h-7 rounded-full',
|
||||||
// Glass morphism button
|
// Glass morphism button
|
||||||
'bg-card/95 backdrop-blur-sm border border-border/80',
|
'bg-card/95 backdrop-blur-sm border border-border/80',
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span
|
<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"
|
data-testid="project-picker-shortcut"
|
||||||
>
|
>
|
||||||
{formatShortcut(shortcuts.projectPicker, true)}
|
{formatShortcut(shortcuts.projectPicker, true)}
|
||||||
@@ -219,7 +219,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className={cn(
|
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',
|
'text-muted-foreground hover:text-foreground',
|
||||||
'bg-transparent hover:bg-accent/60',
|
'bg-transparent hover:bg-accent/60',
|
||||||
'border border-border/50 hover:border-border',
|
'border border-border/50 hover:border-border',
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function SidebarFooter({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-3 font-medium text-sm flex-1 text-left',
|
'ml-3 font-medium text-sm flex-1 text-left',
|
||||||
sidebarOpen ? 'hidden lg:block' : 'hidden'
|
sidebarOpen ? 'block' : 'hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Wiki
|
Wiki
|
||||||
@@ -148,7 +148,7 @@ export function SidebarFooter({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-3 font-medium text-sm flex-1 text-left',
|
'ml-3 font-medium text-sm flex-1 text-left',
|
||||||
sidebarOpen ? 'hidden lg:block' : 'hidden'
|
sidebarOpen ? 'block' : 'hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Running Agents
|
Running Agents
|
||||||
@@ -157,7 +157,7 @@ export function SidebarFooter({
|
|||||||
{sidebarOpen && runningAgentsCount > 0 && (
|
{sidebarOpen && runningAgentsCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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',
|
'min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full',
|
||||||
'bg-brand-500 text-white shadow-sm',
|
'bg-brand-500 text-white shadow-sm',
|
||||||
'animate-in fade-in zoom-in duration-200',
|
'animate-in fade-in zoom-in duration-200',
|
||||||
@@ -227,7 +227,7 @@ export function SidebarFooter({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-3 font-medium text-sm flex-1 text-left',
|
'ml-3 font-medium text-sm flex-1 text-left',
|
||||||
sidebarOpen ? 'hidden lg:block' : 'hidden'
|
sidebarOpen ? 'block' : 'hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Settings
|
Settings
|
||||||
@@ -235,7 +235,7 @@ export function SidebarFooter({
|
|||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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')
|
isActiveRoute('settings')
|
||||||
? 'bg-brand-500/20 text-brand-400'
|
? 'bg-brand-500/20 text-brand-400'
|
||||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) {
|
|||||||
// Background gradient for depth
|
// Background gradient for depth
|
||||||
'bg-gradient-to-b from-transparent to-background/5',
|
'bg-gradient-to-b from-transparent to-background/5',
|
||||||
'flex items-center',
|
'flex items-center',
|
||||||
sidebarOpen ? 'px-3 lg:px-5 justify-start' : 'px-3 justify-center',
|
sidebarOpen ? 'px-4 lg:px-5 justify-start' : 'px-3 justify-center',
|
||||||
// Add left padding on macOS to avoid overlapping with traffic light buttons (only when expanded)
|
// Add padding on macOS to avoid overlapping with traffic light buttons
|
||||||
isMac && sidebarOpen && 'pt-4 pl-20',
|
isMac && sidebarOpen && 'pt-4',
|
||||||
// Smaller top padding on macOS when collapsed
|
// Smaller top padding on macOS when collapsed
|
||||||
isMac && !sidebarOpen && 'pt-4'
|
isMac && !sidebarOpen && 'pt-4'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function SidebarNavigation({
|
|||||||
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? 'mt-6' : ''}>
|
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? 'mt-6' : ''}>
|
||||||
{/* Section Label */}
|
{/* Section Label */}
|
||||||
{section.label && sidebarOpen && (
|
{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">
|
<span className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-widest">
|
||||||
{section.label}
|
{section.label}
|
||||||
</span>
|
</span>
|
||||||
@@ -115,7 +115,7 @@ export function SidebarNavigation({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-3 font-medium text-sm flex-1 text-left',
|
'ml-3 font-medium text-sm flex-1 text-left',
|
||||||
sidebarOpen ? 'hidden lg:block' : 'hidden'
|
sidebarOpen ? 'block' : 'hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -124,7 +124,7 @@ export function SidebarNavigation({
|
|||||||
{item.count !== undefined && item.count > 0 && sidebarOpen && (
|
{item.count !== undefined && item.count > 0 && sidebarOpen && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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',
|
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
|
||||||
'bg-primary text-primary-foreground shadow-sm',
|
'bg-primary text-primary-foreground shadow-sm',
|
||||||
'animate-in fade-in zoom-in duration-200'
|
'animate-in fade-in zoom-in duration-200'
|
||||||
@@ -137,7 +137,7 @@ export function SidebarNavigation({
|
|||||||
{item.shortcut && sidebarOpen && !item.count && (
|
{item.shortcut && sidebarOpen && !item.count && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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
|
isActive
|
||||||
? 'bg-brand-500/20 text-brand-400'
|
? 'bg-brand-500/20 text-brand-400'
|
||||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
: '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 = {
|
const PROVIDER_ICON_KEYS = {
|
||||||
anthropic: 'anthropic',
|
anthropic: 'anthropic',
|
||||||
openai: 'openai',
|
openai: 'openai',
|
||||||
|
openrouter: 'openrouter',
|
||||||
cursor: 'cursor',
|
cursor: 'cursor',
|
||||||
gemini: 'gemini',
|
gemini: 'gemini',
|
||||||
grok: 'grok',
|
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',
|
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',
|
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: {
|
cursor: {
|
||||||
viewBox: '0 0 512 512',
|
viewBox: '0 0 512 512',
|
||||||
// Official Cursor logo - hexagonal shape with triangular wedge
|
// 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} />;
|
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'>) {
|
export function CursorIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.cursor} {...props} />;
|
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;
|
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/...)
|
// Check for Amazon Bedrock models first (amazon-bedrock/...)
|
||||||
if (modelStr.startsWith('amazon-bedrock/')) {
|
if (modelStr.startsWith('amazon-bedrock/')) {
|
||||||
// Bedrock-hosted models - detect the specific provider
|
// Bedrock-hosted models - detect the specific provider
|
||||||
@@ -442,6 +458,71 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
|
|||||||
return 'opencode';
|
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
|
// Check for Cursor-specific models with underlying providers
|
||||||
if (modelStr.includes('sonnet') || modelStr.includes('opus') || modelStr.includes('claude')) {
|
if (modelStr.includes('sonnet') || modelStr.includes('opus') || modelStr.includes('claude')) {
|
||||||
return 'anthropic';
|
return 'anthropic';
|
||||||
@@ -475,6 +556,7 @@ export function getProviderIconForModel(
|
|||||||
const iconMap: Record<ProviderIconKey, ComponentType<{ className?: string }>> = {
|
const iconMap: Record<ProviderIconKey, ComponentType<{ className?: string }>> = {
|
||||||
anthropic: AnthropicIcon,
|
anthropic: AnthropicIcon,
|
||||||
openai: OpenAIIcon,
|
openai: OpenAIIcon,
|
||||||
|
openrouter: OpenRouterIcon,
|
||||||
cursor: CursorIcon,
|
cursor: CursorIcon,
|
||||||
gemini: GeminiIcon,
|
gemini: GeminiIcon,
|
||||||
grok: GrokIcon,
|
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';
|
} from './board-view/hooks';
|
||||||
import { SelectionActionBar } from './board-view/components';
|
import { SelectionActionBar } from './board-view/components';
|
||||||
import { MassEditDialog } from './board-view/dialogs';
|
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
|
// Stable empty array to avoid infinite loop in selector
|
||||||
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
|
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
|
||||||
@@ -99,6 +101,8 @@ export function BoardView() {
|
|||||||
useWorktrees,
|
useWorktrees,
|
||||||
enableDependencyBlocking,
|
enableDependencyBlocking,
|
||||||
skipVerificationInAutoMode,
|
skipVerificationInAutoMode,
|
||||||
|
planUseSelectedWorktreeBranch,
|
||||||
|
addFeatureUseSelectedWorktreeBranch,
|
||||||
isPrimaryWorktreeBranch,
|
isPrimaryWorktreeBranch,
|
||||||
getPrimaryWorktreeBranch,
|
getPrimaryWorktreeBranch,
|
||||||
setPipelineConfig,
|
setPipelineConfig,
|
||||||
@@ -107,6 +111,12 @@ export function BoardView() {
|
|||||||
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
|
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
|
||||||
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
|
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
|
||||||
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
|
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 shortcuts = useKeyboardShortcutsConfig();
|
||||||
const {
|
const {
|
||||||
features: hookFeatures,
|
features: hookFeatures,
|
||||||
@@ -161,12 +171,14 @@ export function BoardView() {
|
|||||||
followUpPrompt,
|
followUpPrompt,
|
||||||
followUpImagePaths,
|
followUpImagePaths,
|
||||||
followUpPreviewMap,
|
followUpPreviewMap,
|
||||||
|
followUpPromptHistory,
|
||||||
setShowFollowUpDialog,
|
setShowFollowUpDialog,
|
||||||
setFollowUpFeature,
|
setFollowUpFeature,
|
||||||
setFollowUpPrompt,
|
setFollowUpPrompt,
|
||||||
setFollowUpImagePaths,
|
setFollowUpImagePaths,
|
||||||
setFollowUpPreviewMap,
|
setFollowUpPreviewMap,
|
||||||
handleFollowUpDialogChange,
|
handleFollowUpDialogChange,
|
||||||
|
addToPromptHistory,
|
||||||
} = useFollowUpState();
|
} = useFollowUpState();
|
||||||
|
|
||||||
// Selection mode hook for mass editing
|
// Selection mode hook for mass editing
|
||||||
@@ -250,6 +262,9 @@ export function BoardView() {
|
|||||||
// Window state hook for compact dialog mode
|
// Window state hook for compact dialog mode
|
||||||
const { isMaximized } = useWindowState();
|
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
|
// Keyboard shortcuts hook will be initialized after actions hook
|
||||||
|
|
||||||
// Prevent hydration issues
|
// Prevent hydration issues
|
||||||
@@ -1287,6 +1302,8 @@ export function BoardView() {
|
|||||||
selectedFeatureIds={selectedFeatureIds}
|
selectedFeatureIds={selectedFeatureIds}
|
||||||
onToggleFeatureSelection={toggleFeatureSelection}
|
onToggleFeatureSelection={toggleFeatureSelection}
|
||||||
onToggleSelectionMode={toggleSelectionMode}
|
onToggleSelectionMode={toggleSelectionMode}
|
||||||
|
isDragging={activeFeature !== null}
|
||||||
|
onAiSuggest={() => setShowPlanDialog(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1357,6 +1374,14 @@ export function BoardView() {
|
|||||||
isMaximized={isMaximized}
|
isMaximized={isMaximized}
|
||||||
parentFeature={spawnParentFeature}
|
parentFeature={spawnParentFeature}
|
||||||
allFeatures={hookFeatures}
|
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 */}
|
{/* Edit Feature Dialog */}
|
||||||
@@ -1422,6 +1447,8 @@ export function BoardView() {
|
|||||||
onPreviewMapChange={setFollowUpPreviewMap}
|
onPreviewMapChange={setFollowUpPreviewMap}
|
||||||
onSend={handleSendFollowUp}
|
onSend={handleSendFollowUp}
|
||||||
isMaximized={isMaximized}
|
isMaximized={isMaximized}
|
||||||
|
promptHistory={followUpPromptHistory}
|
||||||
|
onHistoryAdd={addToPromptHistory}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Backlog Plan Dialog */}
|
{/* Backlog Plan Dialog */}
|
||||||
@@ -1434,6 +1461,7 @@ export function BoardView() {
|
|||||||
setPendingPlanResult={setPendingBacklogPlan}
|
setPendingPlanResult={setPendingBacklogPlan}
|
||||||
isGeneratingPlan={isGeneratingPlan}
|
isGeneratingPlan={isGeneratingPlan}
|
||||||
setIsGeneratingPlan={setIsGeneratingPlan}
|
setIsGeneratingPlan={setIsGeneratingPlan}
|
||||||
|
currentBranch={planUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Plan Approval Dialog */}
|
{/* Plan Approval Dialog */}
|
||||||
@@ -1501,6 +1529,7 @@ export function BoardView() {
|
|||||||
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
|
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
|
||||||
: 0
|
: 0
|
||||||
}
|
}
|
||||||
|
defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)}
|
||||||
onDeleted={(deletedWorktree, _deletedBranch) => {
|
onDeleted={(deletedWorktree, _deletedBranch) => {
|
||||||
// Reset features that were assigned to the deleted worktree (by branch)
|
// Reset features that were assigned to the deleted worktree (by branch)
|
||||||
hookFeatures.forEach((feature) => {
|
hookFeatures.forEach((feature) => {
|
||||||
@@ -1568,6 +1597,11 @@ export function BoardView() {
|
|||||||
setSelectedWorktreeForAction(null);
|
setSelectedWorktreeForAction(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Init Script Indicator - floating overlay for worktree init script status */}
|
||||||
|
{getShowInitScriptIndicator(currentProject.path) && (
|
||||||
|
<InitScriptIndicator projectPath={currentProject.path} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { UsagePopover } from '@/components/usage-popover';
|
|||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
|
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 { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { BoardSearchBar } from './board-search-bar';
|
import { BoardSearchBar } from './board-search-bar';
|
||||||
import { BoardControls } from './board-controls';
|
import { BoardControls } from './board-controls';
|
||||||
@@ -55,10 +57,22 @@ export function BoardHeader({
|
|||||||
completedCount,
|
completedCount,
|
||||||
}: BoardHeaderProps) {
|
}: BoardHeaderProps) {
|
||||||
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
|
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
|
||||||
|
const [showWorktreeSettings, setShowWorktreeSettings] = useState(false);
|
||||||
|
const [showPlanSettings, setShowPlanSettings] = useState(false);
|
||||||
const apiKeys = useAppStore((state) => state.apiKeys);
|
const apiKeys = useAppStore((state) => state.apiKeys);
|
||||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||||
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
|
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
|
||||||
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
|
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);
|
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||||
|
|
||||||
// Worktree panel visibility (per-project)
|
// Worktree panel visibility (per-project)
|
||||||
@@ -132,9 +146,25 @@ export function BoardHeader({
|
|||||||
onCheckedChange={handleWorktreePanelToggle}
|
onCheckedChange={handleWorktreePanelToggle}
|
||||||
data-testid="worktrees-toggle"
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Worktree Settings Dialog */}
|
||||||
|
<WorktreeSettingsDialog
|
||||||
|
open={showWorktreeSettings}
|
||||||
|
onOpenChange={setShowWorktreeSettings}
|
||||||
|
addFeatureUseSelectedWorktreeBranch={addFeatureUseSelectedWorktreeBranch}
|
||||||
|
onAddFeatureUseSelectedWorktreeBranchChange={setAddFeatureUseSelectedWorktreeBranch}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Concurrency Control - only show after mount to prevent hydration issues */}
|
{/* Concurrency Control - only show after mount to prevent hydration issues */}
|
||||||
{isMounted && (
|
{isMounted && (
|
||||||
<Popover>
|
<Popover>
|
||||||
@@ -209,15 +239,33 @@ export function BoardHeader({
|
|||||||
onSkipVerificationChange={setSkipVerificationInAutoMode}
|
onSkipVerificationChange={setSkipVerificationInAutoMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
{/* Plan Button with Settings */}
|
||||||
size="sm"
|
<div className={controlContainerClass} data-testid="plan-button-container">
|
||||||
variant="outline"
|
<button
|
||||||
onClick={onOpenPlanDialog}
|
onClick={onOpenPlanDialog}
|
||||||
data-testid="plan-backlog-button"
|
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
|
||||||
>
|
data-testid="plan-backlog-button"
|
||||||
<Wand2 className="w-4 h-4 mr-2" />
|
>
|
||||||
Plan
|
<Wand2 className="w-4 h-4 text-muted-foreground" />
|
||||||
</Button>
|
<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>
|
||||||
</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 { KanbanCard } from './kanban-card/kanban-card';
|
||||||
export { KanbanColumn } from './kanban-column';
|
export { KanbanColumn } from './kanban-column';
|
||||||
export { SelectionActionBar } from './selection-action-bar';
|
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'];
|
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 {
|
export interface Column {
|
||||||
id: FeatureStatusWithPipeline;
|
id: FeatureStatusWithPipeline;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -21,11 +21,9 @@ import {
|
|||||||
FeatureTextFilePath as DescriptionTextFilePath,
|
FeatureTextFilePath as DescriptionTextFilePath,
|
||||||
ImagePreviewMap,
|
ImagePreviewMap,
|
||||||
} from '@/components/ui/description-image-dropzone';
|
} from '@/components/ui/description-image-dropzone';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Play, Cpu, FolderKanban } from 'lucide-react';
|
||||||
import { Sparkles, ChevronDown, ChevronRight, Play, Cpu, FolderKanban } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
import { modelSupportsThinking } from '@/lib/utils';
|
import { modelSupportsThinking } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
useAppStore,
|
useAppStore,
|
||||||
@@ -43,16 +41,12 @@ import {
|
|||||||
WorkModeSelector,
|
WorkModeSelector,
|
||||||
PlanningModeSelect,
|
PlanningModeSelect,
|
||||||
AncestorContextSection,
|
AncestorContextSection,
|
||||||
|
EnhanceWithAI,
|
||||||
|
EnhancementHistoryButton,
|
||||||
|
type BaseHistoryEntry,
|
||||||
} from '../shared';
|
} from '../shared';
|
||||||
import type { WorkMode } from '../shared';
|
import type { WorkMode } from '../shared';
|
||||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import {
|
import {
|
||||||
getAncestors,
|
getAncestors,
|
||||||
@@ -62,6 +56,32 @@ import {
|
|||||||
|
|
||||||
const logger = createLogger('AddFeatureDialog');
|
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 = {
|
type FeatureData = {
|
||||||
title: string;
|
title: string;
|
||||||
category: string;
|
category: string;
|
||||||
@@ -95,6 +115,23 @@ interface AddFeatureDialogProps {
|
|||||||
isMaximized: boolean;
|
isMaximized: boolean;
|
||||||
parentFeature?: Feature | null;
|
parentFeature?: Feature | null;
|
||||||
allFeatures?: Feature[];
|
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({
|
export function AddFeatureDialog({
|
||||||
@@ -111,6 +148,8 @@ export function AddFeatureDialog({
|
|||||||
isMaximized,
|
isMaximized,
|
||||||
parentFeature = null,
|
parentFeature = null,
|
||||||
allFeatures = [],
|
allFeatures = [],
|
||||||
|
selectedNonMainWorktreeBranch,
|
||||||
|
forceCurrentBranchMode,
|
||||||
}: AddFeatureDialogProps) {
|
}: AddFeatureDialogProps) {
|
||||||
const isSpawnMode = !!parentFeature;
|
const isSpawnMode = !!parentFeature;
|
||||||
const [workMode, setWorkMode] = useState<WorkMode>('current');
|
const [workMode, setWorkMode] = useState<WorkMode>('current');
|
||||||
@@ -139,21 +178,16 @@ export function AddFeatureDialog({
|
|||||||
// UI state
|
// UI state
|
||||||
const [previewMap, setPreviewMap] = useState<ImagePreviewMap>(() => new Map());
|
const [previewMap, setPreviewMap] = useState<ImagePreviewMap>(() => new Map());
|
||||||
const [descriptionError, setDescriptionError] = useState(false);
|
const [descriptionError, setDescriptionError] = useState(false);
|
||||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
|
||||||
const [enhancementMode, setEnhancementMode] = useState<
|
// Description history state
|
||||||
'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
const [descriptionHistory, setDescriptionHistory] = useState<DescriptionHistoryEntry[]>([]);
|
||||||
>('improve');
|
|
||||||
const [enhanceOpen, setEnhanceOpen] = useState(false);
|
|
||||||
|
|
||||||
// Spawn mode state
|
// Spawn mode state
|
||||||
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
|
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
|
||||||
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
|
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Get defaults from store
|
// Get defaults from store
|
||||||
const { defaultPlanningMode, defaultRequirePlanApproval } = useAppStore();
|
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
|
||||||
|
|
||||||
// Enhancement model override
|
|
||||||
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
|
|
||||||
|
|
||||||
// Track previous open state to detect when dialog opens
|
// Track previous open state to detect when dialog opens
|
||||||
const wasOpenRef = useRef(false);
|
const wasOpenRef = useRef(false);
|
||||||
@@ -165,12 +199,19 @@ export function AddFeatureDialog({
|
|||||||
|
|
||||||
if (justOpened) {
|
if (justOpened) {
|
||||||
setSkipTests(defaultSkipTests);
|
setSkipTests(defaultSkipTests);
|
||||||
setBranchName(defaultBranch || '');
|
// When a non-main worktree is selected, use its branch name for custom mode
|
||||||
setWorkMode('current');
|
// Otherwise, use the default branch
|
||||||
|
setBranchName(selectedNonMainWorktreeBranch || defaultBranch || '');
|
||||||
|
setWorkMode(
|
||||||
|
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
|
||||||
|
);
|
||||||
setPlanningMode(defaultPlanningMode);
|
setPlanningMode(defaultPlanningMode);
|
||||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||||
setModelEntry({ model: 'opus' });
|
setModelEntry({ model: 'opus' });
|
||||||
|
|
||||||
|
// Initialize description history (empty for new feature)
|
||||||
|
setDescriptionHistory([]);
|
||||||
|
|
||||||
// Initialize ancestors for spawn mode
|
// Initialize ancestors for spawn mode
|
||||||
if (parentFeature) {
|
if (parentFeature) {
|
||||||
const ancestorList = getAncestors(parentFeature, allFeatures);
|
const ancestorList = getAncestors(parentFeature, allFeatures);
|
||||||
@@ -187,6 +228,9 @@ export function AddFeatureDialog({
|
|||||||
defaultBranch,
|
defaultBranch,
|
||||||
defaultPlanningMode,
|
defaultPlanningMode,
|
||||||
defaultRequirePlanApproval,
|
defaultRequirePlanApproval,
|
||||||
|
useWorktrees,
|
||||||
|
selectedNonMainWorktreeBranch,
|
||||||
|
forceCurrentBranchMode,
|
||||||
parentFeature,
|
parentFeature,
|
||||||
allFeatures,
|
allFeatures,
|
||||||
]);
|
]);
|
||||||
@@ -271,15 +315,18 @@ export function AddFeatureDialog({
|
|||||||
setImagePaths([]);
|
setImagePaths([]);
|
||||||
setTextFilePaths([]);
|
setTextFilePaths([]);
|
||||||
setSkipTests(defaultSkipTests);
|
setSkipTests(defaultSkipTests);
|
||||||
setBranchName('');
|
// When a non-main worktree is selected, use its branch name for custom mode
|
||||||
|
setBranchName(selectedNonMainWorktreeBranch || '');
|
||||||
setPriority(2);
|
setPriority(2);
|
||||||
setModelEntry({ model: 'opus' });
|
setModelEntry({ model: 'opus' });
|
||||||
setWorkMode('current');
|
setWorkMode(
|
||||||
|
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
|
||||||
|
);
|
||||||
setPlanningMode(defaultPlanningMode);
|
setPlanningMode(defaultPlanningMode);
|
||||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||||
setPreviewMap(new Map());
|
setPreviewMap(new Map());
|
||||||
setDescriptionError(false);
|
setDescriptionError(false);
|
||||||
setEnhanceOpen(false);
|
setDescriptionHistory([]);
|
||||||
onOpenChange(false);
|
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
|
// Shared card styling
|
||||||
const cardClass = 'rounded-lg border border-border/50 bg-muted/30 p-4 space-y-3';
|
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';
|
const sectionHeaderClass = 'flex items-center gap-2 text-sm font-medium text-foreground';
|
||||||
@@ -380,7 +400,18 @@ export function AddFeatureDialog({
|
|||||||
{/* Task Details Section */}
|
{/* Task Details Section */}
|
||||||
<div className={cardClass}>
|
<div className={cardClass}>
|
||||||
<div className="space-y-2">
|
<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
|
<DescriptionImageDropZone
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -409,75 +440,35 @@ export function AddFeatureDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Collapsible Enhancement Section */}
|
{/* Enhancement Section */}
|
||||||
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen}>
|
<EnhanceWithAI
|
||||||
<CollapsibleTrigger asChild>
|
value={description}
|
||||||
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1">
|
onChange={setDescription}
|
||||||
{enhanceOpen ? (
|
onHistoryAdd={({ mode, originalText, enhancedText }) => {
|
||||||
<ChevronDown className="w-4 h-4" />
|
const timestamp = new Date().toISOString();
|
||||||
) : (
|
setDescriptionHistory((prev) => {
|
||||||
<ChevronRight className="w-4 h-4" />
|
const newHistory = [...prev];
|
||||||
)}
|
// Add original text first (so user can restore to pre-enhancement state)
|
||||||
<Sparkles className="w-4 h-4" />
|
// Only add if it's different from the last entry to avoid duplicates
|
||||||
<span>Enhance with AI</span>
|
const lastEntry = prev[prev.length - 1];
|
||||||
</button>
|
if (!lastEntry || lastEntry.description !== originalText) {
|
||||||
</CollapsibleTrigger>
|
newHistory.push({
|
||||||
<CollapsibleContent className="pt-3">
|
description: originalText,
|
||||||
<div className="flex flex-wrap items-center gap-2 pl-6">
|
timestamp,
|
||||||
<DropdownMenu>
|
source: prev.length === 0 ? 'initial' : 'edit',
|
||||||
<DropdownMenuTrigger asChild>
|
});
|
||||||
<Button variant="outline" size="sm" className="h-8 text-xs">
|
}
|
||||||
{enhancementMode === 'improve' && 'Improve Clarity'}
|
// Add enhanced text
|
||||||
{enhancementMode === 'technical' && 'Add Technical Details'}
|
newHistory.push({
|
||||||
{enhancementMode === 'simplify' && 'Simplify'}
|
description: enhancedText,
|
||||||
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
|
timestamp,
|
||||||
{enhancementMode === 'ux-reviewer' && 'User Experience'}
|
source: 'enhance',
|
||||||
<ChevronDown className="w-3 h-3 ml-1" />
|
enhancementMode: mode,
|
||||||
</Button>
|
});
|
||||||
</DropdownMenuTrigger>
|
return newHistory;
|
||||||
<DropdownMenuContent align="start">
|
});
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
|
}}
|
||||||
Improve Clarity
|
/>
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
|
|
||||||
Add Technical Details
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
|
|
||||||
Simplify
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
|
|
||||||
Add Acceptance Criteria
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('ux-reviewer')}>
|
|
||||||
User Experience
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
onClick={handleEnhanceDescription}
|
|
||||||
disabled={!description.trim() || isEnhancing}
|
|
||||||
loading={isEnhancing}
|
|
||||||
>
|
|
||||||
<Sparkles className="w-3 h-3 mr-1" />
|
|
||||||
Enhance
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<ModelOverrideTrigger
|
|
||||||
currentModelEntry={enhancementOverride.effectiveModelEntry}
|
|
||||||
onModelChange={enhancementOverride.setOverride}
|
|
||||||
phase="enhancementModel"
|
|
||||||
isOverridden={enhancementOverride.isOverridden}
|
|
||||||
size="sm"
|
|
||||||
variant="icon"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI & Execution Section */}
|
{/* AI & Execution Section */}
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ interface BacklogPlanDialogProps {
|
|||||||
setPendingPlanResult: (result: BacklogPlanResult | null) => void;
|
setPendingPlanResult: (result: BacklogPlanResult | null) => void;
|
||||||
isGeneratingPlan: boolean;
|
isGeneratingPlan: boolean;
|
||||||
setIsGeneratingPlan: (generating: boolean) => void;
|
setIsGeneratingPlan: (generating: boolean) => void;
|
||||||
|
// Branch to use for created features (defaults to 'main' when applying)
|
||||||
|
currentBranch?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DialogMode = 'input' | 'review' | 'applying';
|
type DialogMode = 'input' | 'review' | 'applying';
|
||||||
@@ -76,6 +78,7 @@ export function BacklogPlanDialog({
|
|||||||
setPendingPlanResult,
|
setPendingPlanResult,
|
||||||
isGeneratingPlan,
|
isGeneratingPlan,
|
||||||
setIsGeneratingPlan,
|
setIsGeneratingPlan,
|
||||||
|
currentBranch,
|
||||||
}: BacklogPlanDialogProps) {
|
}: BacklogPlanDialogProps) {
|
||||||
const [mode, setMode] = useState<DialogMode>('input');
|
const [mode, setMode] = useState<DialogMode>('input');
|
||||||
const [prompt, setPrompt] = useState('');
|
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) {
|
if (result.success) {
|
||||||
toast.success(`Applied ${result.appliedChanges?.length || 0} changes`);
|
toast.success(`Applied ${result.appliedChanges?.length || 0} changes`);
|
||||||
setPendingPlanResult(null);
|
setPendingPlanResult(null);
|
||||||
@@ -184,6 +191,7 @@ export function BacklogPlanDialog({
|
|||||||
setPendingPlanResult,
|
setPendingPlanResult,
|
||||||
onPlanApplied,
|
onPlanApplied,
|
||||||
onClose,
|
onClose,
|
||||||
|
currentBranch,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleDiscard = useCallback(() => {
|
const handleDiscard = useCallback(() => {
|
||||||
|
|||||||
@@ -10,10 +10,73 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
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 {
|
interface CreatedWorktreeInfo {
|
||||||
path: string;
|
path: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
@@ -34,20 +97,21 @@ export function CreateWorktreeDialog({
|
|||||||
}: CreateWorktreeDialogProps) {
|
}: CreateWorktreeDialogProps) {
|
||||||
const [branchName, setBranchName] = useState('');
|
const [branchName, setBranchName] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
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 () => {
|
const handleCreate = async () => {
|
||||||
if (!branchName.trim()) {
|
if (!branchName.trim()) {
|
||||||
setError('Branch name is required');
|
setError({ title: 'Branch name is required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate branch name (git-compatible)
|
// Validate branch name (git-compatible)
|
||||||
const validBranchRegex = /^[a-zA-Z0-9._/-]+$/;
|
const validBranchRegex = /^[a-zA-Z0-9._/-]+$/;
|
||||||
if (!validBranchRegex.test(branchName)) {
|
if (!validBranchRegex.test(branchName)) {
|
||||||
setError(
|
setError({
|
||||||
'Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes.'
|
title: 'Invalid branch name',
|
||||||
);
|
description: 'Use only letters, numbers, dots, underscores, hyphens, and slashes.',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +121,7 @@ export function CreateWorktreeDialog({
|
|||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.worktree?.create) {
|
if (!api?.worktree?.create) {
|
||||||
setError('Worktree API not available');
|
setError({ title: 'Worktree API not available' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await api.worktree.create(projectPath, branchName);
|
const result = await api.worktree.create(projectPath, branchName);
|
||||||
@@ -70,10 +134,12 @@ export function CreateWorktreeDialog({
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setBranchName('');
|
setBranchName('');
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || 'Failed to create worktree');
|
setError(parseWorktreeError(result.error || 'Failed to create worktree'));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to create worktree');
|
setError(
|
||||||
|
parseWorktreeError(err instanceof Error ? err.message : 'Failed to create worktree')
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -114,7 +180,17 @@ export function CreateWorktreeDialog({
|
|||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
autoFocus
|
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>
|
||||||
|
|
||||||
<div className="text-xs text-muted-foreground space-y-1">
|
<div className="text-xs text-muted-foreground space-y-1">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -30,6 +30,8 @@ interface DeleteWorktreeDialogProps {
|
|||||||
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
||||||
/** Number of features assigned to this worktree's branch */
|
/** Number of features assigned to this worktree's branch */
|
||||||
affectedFeatureCount?: number;
|
affectedFeatureCount?: number;
|
||||||
|
/** Default value for the "delete branch" checkbox */
|
||||||
|
defaultDeleteBranch?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeleteWorktreeDialog({
|
export function DeleteWorktreeDialog({
|
||||||
@@ -39,10 +41,18 @@ export function DeleteWorktreeDialog({
|
|||||||
worktree,
|
worktree,
|
||||||
onDeleted,
|
onDeleted,
|
||||||
affectedFeatureCount = 0,
|
affectedFeatureCount = 0,
|
||||||
|
defaultDeleteBranch = false,
|
||||||
}: DeleteWorktreeDialogProps) {
|
}: DeleteWorktreeDialogProps) {
|
||||||
const [deleteBranch, setDeleteBranch] = useState(false);
|
const [deleteBranch, setDeleteBranch] = useState(defaultDeleteBranch);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Reset deleteBranch to default when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setDeleteBranch(defaultDeleteBranch);
|
||||||
|
}
|
||||||
|
}, [open, defaultDeleteBranch]);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!worktree) return;
|
if (!worktree) return;
|
||||||
|
|
||||||
|
|||||||
@@ -21,18 +21,8 @@ import {
|
|||||||
FeatureTextFilePath as DescriptionTextFilePath,
|
FeatureTextFilePath as DescriptionTextFilePath,
|
||||||
ImagePreviewMap,
|
ImagePreviewMap,
|
||||||
} from '@/components/ui/description-image-dropzone';
|
} from '@/components/ui/description-image-dropzone';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { GitBranch, Cpu, FolderKanban } from 'lucide-react';
|
||||||
import {
|
|
||||||
Sparkles,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
GitBranch,
|
|
||||||
History,
|
|
||||||
Cpu,
|
|
||||||
FolderKanban,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
import { cn, modelSupportsThinking } from '@/lib/utils';
|
import { cn, modelSupportsThinking } from '@/lib/utils';
|
||||||
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
|
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
|
||||||
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
|
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
|
||||||
@@ -41,17 +31,12 @@ import {
|
|||||||
PrioritySelector,
|
PrioritySelector,
|
||||||
WorkModeSelector,
|
WorkModeSelector,
|
||||||
PlanningModeSelect,
|
PlanningModeSelect,
|
||||||
|
EnhanceWithAI,
|
||||||
|
EnhancementHistoryButton,
|
||||||
|
type EnhancementMode,
|
||||||
} from '../shared';
|
} from '../shared';
|
||||||
import type { WorkMode } from '../shared';
|
import type { WorkMode } from '../shared';
|
||||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { DependencyTreeDialog } from './dependency-tree-dialog';
|
import { DependencyTreeDialog } from './dependency-tree-dialog';
|
||||||
import { isClaudeModel, supportsReasoningEffort } from '@automaker/types';
|
import { isClaudeModel, supportsReasoningEffort } from '@automaker/types';
|
||||||
@@ -79,7 +64,8 @@ interface EditFeatureDialogProps {
|
|||||||
requirePlanApproval: boolean;
|
requirePlanApproval: boolean;
|
||||||
},
|
},
|
||||||
descriptionHistorySource?: 'enhance' | 'edit',
|
descriptionHistorySource?: 'enhance' | 'edit',
|
||||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
enhancementMode?: EnhancementMode,
|
||||||
|
preEnhancementDescription?: string
|
||||||
) => void;
|
) => void;
|
||||||
categorySuggestions: string[];
|
categorySuggestions: string[];
|
||||||
branchSuggestions: string[];
|
branchSuggestions: string[];
|
||||||
@@ -110,11 +96,6 @@ export function EditFeatureDialog({
|
|||||||
const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState<ImagePreviewMap>(
|
const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState<ImagePreviewMap>(
|
||||||
() => new Map()
|
() => 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 [showDependencyTree, setShowDependencyTree] = useState(false);
|
||||||
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
|
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
|
||||||
const [requirePlanApproval, setRequirePlanApproval] = useState(
|
const [requirePlanApproval, setRequirePlanApproval] = useState(
|
||||||
@@ -133,15 +114,16 @@ export function EditFeatureDialog({
|
|||||||
|
|
||||||
// Track the source of description changes for history
|
// Track the source of description changes for history
|
||||||
const [descriptionChangeSource, setDescriptionChangeSource] = useState<
|
const [descriptionChangeSource, setDescriptionChangeSource] = useState<
|
||||||
{ source: 'enhance'; mode: 'improve' | 'technical' | 'simplify' | 'acceptance' } | 'edit' | null
|
{ source: 'enhance'; mode: EnhancementMode } | 'edit' | null
|
||||||
>(null);
|
>(null);
|
||||||
// Track the original description when the dialog opened for comparison
|
// Track the original description when the dialog opened for comparison
|
||||||
const [originalDescription, setOriginalDescription] = useState(feature?.description ?? '');
|
const [originalDescription, setOriginalDescription] = useState(feature?.description ?? '');
|
||||||
// Track if history dropdown is open
|
// Track the description before enhancement (so it can be restored)
|
||||||
const [showHistory, setShowHistory] = useState(false);
|
const [preEnhancementDescription, setPreEnhancementDescription] = useState<string | null>(null);
|
||||||
|
// Local history state for real-time display (combines persisted + session history)
|
||||||
// Enhancement model override
|
const [localHistory, setLocalHistory] = useState<DescriptionHistoryEntry[]>(
|
||||||
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
|
feature?.descriptionHistory ?? []
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEditingFeature(feature);
|
setEditingFeature(feature);
|
||||||
@@ -153,8 +135,8 @@ export function EditFeatureDialog({
|
|||||||
// Reset history tracking state
|
// Reset history tracking state
|
||||||
setOriginalDescription(feature.description ?? '');
|
setOriginalDescription(feature.description ?? '');
|
||||||
setDescriptionChangeSource(null);
|
setDescriptionChangeSource(null);
|
||||||
setShowHistory(false);
|
setPreEnhancementDescription(null);
|
||||||
setEnhanceOpen(false);
|
setLocalHistory(feature.descriptionHistory ?? []);
|
||||||
// Reset model entry
|
// Reset model entry
|
||||||
setModelEntry({
|
setModelEntry({
|
||||||
model: (feature.model as ModelAlias) || 'opus',
|
model: (feature.model as ModelAlias) || 'opus',
|
||||||
@@ -164,7 +146,8 @@ export function EditFeatureDialog({
|
|||||||
} else {
|
} else {
|
||||||
setEditFeaturePreviewMap(new Map());
|
setEditFeaturePreviewMap(new Map());
|
||||||
setDescriptionChangeSource(null);
|
setDescriptionChangeSource(null);
|
||||||
setShowHistory(false);
|
setPreEnhancementDescription(null);
|
||||||
|
setLocalHistory([]);
|
||||||
}
|
}
|
||||||
}, [feature]);
|
}, [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());
|
setEditFeaturePreviewMap(new Map());
|
||||||
onClose();
|
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) {
|
if (!editingFeature) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -304,95 +263,18 @@ export function EditFeatureDialog({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="edit-description">Description</Label>
|
<Label htmlFor="edit-description">Description</Label>
|
||||||
{/* Version History Button */}
|
{/* Version History Button - uses local history for real-time updates */}
|
||||||
{feature?.descriptionHistory && feature.descriptionHistory.length > 0 && (
|
<EnhancementHistoryButton
|
||||||
<Popover open={showHistory} onOpenChange={setShowHistory}>
|
history={localHistory}
|
||||||
<PopoverTrigger asChild>
|
currentValue={editingFeature.description}
|
||||||
<Button
|
onRestore={(description) => {
|
||||||
type="button"
|
setEditingFeature((prev) => (prev ? { ...prev, description } : prev));
|
||||||
variant="ghost"
|
setDescriptionChangeSource('edit');
|
||||||
size="sm"
|
}}
|
||||||
className="h-7 gap-1.5 text-xs text-muted-foreground"
|
valueAccessor={(entry) => entry.description}
|
||||||
>
|
title="Version History"
|
||||||
<History className="w-3.5 h-3.5" />
|
restoreMessage="Description restored from history"
|
||||||
History ({feature.descriptionHistory.length})
|
/>
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-80 p-0" align="end">
|
|
||||||
<div className="p-3 border-b">
|
|
||||||
<h4 className="font-medium text-sm">Version History</h4>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Click a version to restore it
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
|
|
||||||
{[...(feature.descriptionHistory || [])]
|
|
||||||
.reverse()
|
|
||||||
.map((entry: DescriptionHistoryEntry, index: number) => {
|
|
||||||
const isCurrentVersion =
|
|
||||||
entry.description === editingFeature.description;
|
|
||||||
const date = new Date(entry.timestamp);
|
|
||||||
const formattedDate = date.toLocaleDateString(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
const getEnhancementModeLabel = (mode?: string) => {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
improve: 'Improve Clarity',
|
|
||||||
technical: 'Add Technical Details',
|
|
||||||
simplify: 'Simplify',
|
|
||||||
acceptance: 'Add Acceptance Criteria',
|
|
||||||
'ux-reviewer': 'User Experience',
|
|
||||||
};
|
|
||||||
return labels[mode || 'improve'] || mode || 'improve';
|
|
||||||
};
|
|
||||||
const sourceLabel =
|
|
||||||
entry.source === 'initial'
|
|
||||||
? 'Original'
|
|
||||||
: entry.source === 'enhance'
|
|
||||||
? `Enhanced (${getEnhancementModeLabel(entry.enhancementMode)})`
|
|
||||||
: 'Edited';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`${entry.timestamp}-${index}`}
|
|
||||||
onClick={() => {
|
|
||||||
setEditingFeature((prev) =>
|
|
||||||
prev ? { ...prev, description: entry.description } : prev
|
|
||||||
);
|
|
||||||
// Mark as edit since user is restoring from history
|
|
||||||
setDescriptionChangeSource('edit');
|
|
||||||
setShowHistory(false);
|
|
||||||
toast.success('Description restored from history');
|
|
||||||
}}
|
|
||||||
className={`w-full text-left p-2 rounded-md hover:bg-muted transition-colors ${
|
|
||||||
isCurrentVersion ? 'bg-muted/50 border border-primary/20' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-xs font-medium">{sourceLabel}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formattedDate}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
|
||||||
{entry.description.slice(0, 100)}
|
|
||||||
{entry.description.length > 100 ? '...' : ''}
|
|
||||||
</p>
|
|
||||||
{isCurrentVersion && (
|
|
||||||
<span className="text-xs text-primary font-medium mt-1 block">
|
|
||||||
Current version
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<DescriptionImageDropZone
|
<DescriptionImageDropZone
|
||||||
value={editingFeature.description}
|
value={editingFeature.description}
|
||||||
@@ -443,75 +325,40 @@ export function EditFeatureDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Collapsible Enhancement Section */}
|
{/* Enhancement Section */}
|
||||||
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen}>
|
<EnhanceWithAI
|
||||||
<CollapsibleTrigger asChild>
|
value={editingFeature.description}
|
||||||
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1">
|
onChange={(enhanced) =>
|
||||||
{enhanceOpen ? (
|
setEditingFeature((prev) => (prev ? { ...prev, description: enhanced } : prev))
|
||||||
<ChevronDown className="w-4 h-4" />
|
}
|
||||||
) : (
|
onHistoryAdd={({ mode, originalText, enhancedText }) => {
|
||||||
<ChevronRight className="w-4 h-4" />
|
setDescriptionChangeSource({ source: 'enhance', mode });
|
||||||
)}
|
setPreEnhancementDescription(originalText);
|
||||||
<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
|
// Update local history for real-time display
|
||||||
type="button"
|
const timestamp = new Date().toISOString();
|
||||||
variant="default"
|
setLocalHistory((prev) => {
|
||||||
size="sm"
|
const newHistory = [...prev];
|
||||||
className="h-8 text-xs"
|
// Add original text first (so user can restore to pre-enhancement state)
|
||||||
onClick={handleEnhanceDescription}
|
const lastEntry = prev[prev.length - 1];
|
||||||
disabled={!editingFeature.description.trim() || isEnhancing}
|
if (!lastEntry || lastEntry.description !== originalText) {
|
||||||
loading={isEnhancing}
|
newHistory.push({
|
||||||
>
|
description: originalText,
|
||||||
<Sparkles className="w-3 h-3 mr-1" />
|
timestamp,
|
||||||
Enhance
|
source: prev.length === 0 ? 'initial' : 'edit',
|
||||||
</Button>
|
});
|
||||||
|
}
|
||||||
<ModelOverrideTrigger
|
// Add enhanced text
|
||||||
currentModelEntry={enhancementOverride.effectiveModelEntry}
|
newHistory.push({
|
||||||
onModelChange={enhancementOverride.setOverride}
|
description: enhancedText,
|
||||||
phase="enhancementModel"
|
timestamp,
|
||||||
isOverridden={enhancementOverride.isOverridden}
|
source: 'enhance',
|
||||||
size="sm"
|
enhancementMode: mode,
|
||||||
variant="icon"
|
});
|
||||||
/>
|
return newHistory;
|
||||||
</div>
|
});
|
||||||
</CollapsibleContent>
|
}}
|
||||||
</Collapsible>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI & Execution Section */}
|
{/* AI & Execution Section */}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -17,6 +18,21 @@ import {
|
|||||||
} from '@/components/ui/description-image-dropzone';
|
} from '@/components/ui/description-image-dropzone';
|
||||||
import { MessageSquare } from 'lucide-react';
|
import { MessageSquare } from 'lucide-react';
|
||||||
import { Feature } from '@/store/app-store';
|
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 {
|
interface FollowUpDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -30,6 +46,10 @@ interface FollowUpDialogProps {
|
|||||||
onPreviewMapChange: (map: ImagePreviewMap) => void;
|
onPreviewMapChange: (map: ImagePreviewMap) => void;
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
isMaximized: boolean;
|
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({
|
export function FollowUpDialog({
|
||||||
@@ -44,9 +64,11 @@ export function FollowUpDialog({
|
|||||||
onPreviewMapChange,
|
onPreviewMapChange,
|
||||||
onSend,
|
onSend,
|
||||||
isMaximized,
|
isMaximized,
|
||||||
|
promptHistory = [],
|
||||||
|
onHistoryAdd,
|
||||||
}: FollowUpDialogProps) {
|
}: FollowUpDialogProps) {
|
||||||
const handleClose = (open: boolean) => {
|
const handleClose = (openState: boolean) => {
|
||||||
if (!open) {
|
if (!openState) {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -77,7 +99,18 @@ export function FollowUpDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
|
<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
|
||||||
<div className="space-y-2">
|
<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
|
<DescriptionImageDropZone
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={onPromptChange}
|
onChange={onPromptChange}
|
||||||
@@ -88,6 +121,33 @@ export function FollowUpDialog({
|
|||||||
onPreviewMapChange={onPreviewMapChange}
|
onPreviewMapChange={onPreviewMapChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<p className="text-xs text-muted-foreground">
|
||||||
The agent will continue from where it left off, using the existing context. You can
|
The agent will continue from where it left off, using the existing context. You can
|
||||||
attach screenshots to help explain the issue.
|
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 { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
|
||||||
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
|
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
|
||||||
export { EditFeatureDialog } from './edit-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 { PlanApprovalDialog } from './plan-approval-dialog';
|
||||||
export { MassEditDialog } from './mass-edit-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,
|
featureId: string,
|
||||||
updates: Partial<Feature>,
|
updates: Partial<Feature>,
|
||||||
descriptionHistorySource?: 'enhance' | 'edit',
|
descriptionHistorySource?: 'enhance' | 'edit',
|
||||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||||
|
preEnhancementDescription?: string
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
persistFeatureDelete: (featureId: string) => Promise<void>;
|
persistFeatureDelete: (featureId: string) => Promise<void>;
|
||||||
saveCategory: (category: string) => Promise<void>;
|
saveCategory: (category: string) => Promise<void>;
|
||||||
@@ -251,7 +252,8 @@ export function useBoardActions({
|
|||||||
workMode?: 'current' | 'auto' | 'custom';
|
workMode?: 'current' | 'auto' | 'custom';
|
||||||
},
|
},
|
||||||
descriptionHistorySource?: 'enhance' | 'edit',
|
descriptionHistorySource?: 'enhance' | 'edit',
|
||||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||||
|
preEnhancementDescription?: string
|
||||||
) => {
|
) => {
|
||||||
const workMode = updates.workMode || 'current';
|
const workMode = updates.workMode || 'current';
|
||||||
|
|
||||||
@@ -308,7 +310,13 @@ export function useBoardActions({
|
|||||||
};
|
};
|
||||||
|
|
||||||
updateFeature(featureId, finalUpdates);
|
updateFeature(featureId, finalUpdates);
|
||||||
persistFeatureUpdate(featureId, finalUpdates, descriptionHistorySource, enhancementMode);
|
persistFeatureUpdate(
|
||||||
|
featureId,
|
||||||
|
finalUpdates,
|
||||||
|
descriptionHistorySource,
|
||||||
|
enhancementMode,
|
||||||
|
preEnhancementDescription
|
||||||
|
);
|
||||||
if (updates.category) {
|
if (updates.category) {
|
||||||
saveCategory(updates.category);
|
saveCategory(updates.category);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
|||||||
featureId: string,
|
featureId: string,
|
||||||
updates: Partial<Feature>,
|
updates: Partial<Feature>,
|
||||||
descriptionHistorySource?: 'enhance' | 'edit',
|
descriptionHistorySource?: 'enhance' | 'edit',
|
||||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||||
|
preEnhancementDescription?: string
|
||||||
) => {
|
) => {
|
||||||
if (!currentProject) return;
|
if (!currentProject) return;
|
||||||
|
|
||||||
@@ -35,7 +36,8 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
|||||||
featureId,
|
featureId,
|
||||||
updates,
|
updates,
|
||||||
descriptionHistorySource,
|
descriptionHistorySource,
|
||||||
enhancementMode
|
enhancementMode,
|
||||||
|
preEnhancementDescription
|
||||||
);
|
);
|
||||||
if (result.success && result.feature) {
|
if (result.success && result.feature) {
|
||||||
updateFeature(result.feature.id, result.feature);
|
updateFeature(result.feature.id, result.feature);
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ import {
|
|||||||
FeatureImagePath as DescriptionImagePath,
|
FeatureImagePath as DescriptionImagePath,
|
||||||
ImagePreviewMap,
|
ImagePreviewMap,
|
||||||
} from '@/components/ui/description-image-dropzone';
|
} 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() {
|
export function useFollowUpState() {
|
||||||
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
|
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
|
||||||
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
|
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
|
||||||
const [followUpPrompt, setFollowUpPrompt] = useState('');
|
const [followUpPrompt, setFollowUpPrompt] = useState('');
|
||||||
const [followUpImagePaths, setFollowUpImagePaths] = useState<DescriptionImagePath[]>([]);
|
const [followUpImagePaths, setFollowUpImagePaths] = useState<DescriptionImagePath[]>([]);
|
||||||
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(() => new Map());
|
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(() => new Map());
|
||||||
|
const [followUpPromptHistory, setFollowUpPromptHistory] = useState<FollowUpHistoryEntry[]>([]);
|
||||||
|
|
||||||
const resetFollowUpState = useCallback(() => {
|
const resetFollowUpState = useCallback(() => {
|
||||||
setShowFollowUpDialog(false);
|
setShowFollowUpDialog(false);
|
||||||
@@ -18,6 +23,7 @@ export function useFollowUpState() {
|
|||||||
setFollowUpPrompt('');
|
setFollowUpPrompt('');
|
||||||
setFollowUpImagePaths([]);
|
setFollowUpImagePaths([]);
|
||||||
setFollowUpPreviewMap(new Map());
|
setFollowUpPreviewMap(new Map());
|
||||||
|
setFollowUpPromptHistory([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFollowUpDialogChange = useCallback(
|
const handleFollowUpDialogChange = useCallback(
|
||||||
@@ -31,6 +37,13 @@ export function useFollowUpState() {
|
|||||||
[resetFollowUpState]
|
[resetFollowUpState]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a new entry to the prompt history
|
||||||
|
*/
|
||||||
|
const addToPromptHistory = useCallback((entry: FollowUpHistoryEntry) => {
|
||||||
|
setFollowUpPromptHistory((prev) => [...prev, entry]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
showFollowUpDialog,
|
showFollowUpDialog,
|
||||||
@@ -38,14 +51,17 @@ export function useFollowUpState() {
|
|||||||
followUpPrompt,
|
followUpPrompt,
|
||||||
followUpImagePaths,
|
followUpImagePaths,
|
||||||
followUpPreviewMap,
|
followUpPreviewMap,
|
||||||
|
followUpPromptHistory,
|
||||||
// Setters
|
// Setters
|
||||||
setShowFollowUpDialog,
|
setShowFollowUpDialog,
|
||||||
setFollowUpFeature,
|
setFollowUpFeature,
|
||||||
setFollowUpPrompt,
|
setFollowUpPrompt,
|
||||||
setFollowUpImagePaths,
|
setFollowUpImagePaths,
|
||||||
setFollowUpPreviewMap,
|
setFollowUpPreviewMap,
|
||||||
|
setFollowUpPromptHistory,
|
||||||
// Helpers
|
// Helpers
|
||||||
resetFollowUpState,
|
resetFollowUpState,
|
||||||
handleFollowUpDialogChange,
|
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 { DndContext, DragOverlay } from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Feature, useAppStore, formatShortcut } from '@/store/app-store';
|
||||||
import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-react';
|
import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-react';
|
||||||
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
||||||
@@ -51,6 +51,12 @@ interface KanbanBoardProps {
|
|||||||
selectedFeatureIds?: Set<string>;
|
selectedFeatureIds?: Set<string>;
|
||||||
onToggleFeatureSelection?: (featureId: string) => void;
|
onToggleFeatureSelection?: (featureId: string) => void;
|
||||||
onToggleSelectionMode?: () => 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({
|
export function KanbanBoard({
|
||||||
@@ -86,6 +92,9 @@ export function KanbanBoard({
|
|||||||
selectedFeatureIds = new Set(),
|
selectedFeatureIds = new Set(),
|
||||||
onToggleFeatureSelection,
|
onToggleFeatureSelection,
|
||||||
onToggleSelectionMode,
|
onToggleSelectionMode,
|
||||||
|
onAiSuggest,
|
||||||
|
isDragging = false,
|
||||||
|
isReadOnly = false,
|
||||||
}: KanbanBoardProps) {
|
}: KanbanBoardProps) {
|
||||||
// Generate columns including pipeline steps
|
// Generate columns including pipeline steps
|
||||||
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
||||||
@@ -211,6 +220,26 @@ export function KanbanBoard({
|
|||||||
items={columnFeatures.map((f) => f.id)}
|
items={columnFeatures.map((f) => f.id)}
|
||||||
strategy={verticalListSortingStrategy}
|
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) => {
|
{columnFeatures.map((feature, index) => {
|
||||||
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
||||||
let shortcutKey: string | undefined;
|
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 './planning-mode-select';
|
||||||
export * from './ancestor-context-section';
|
export * from './ancestor-context-section';
|
||||||
export * from './work-mode-selector';
|
export * from './work-mode-selector';
|
||||||
|
export * from './enhancement';
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
import {
|
||||||
Trash2,
|
Trash2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
GitCommit,
|
GitCommit,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
ExternalLink,
|
|
||||||
Download,
|
Download,
|
||||||
Upload,
|
Upload,
|
||||||
Play,
|
Play,
|
||||||
@@ -21,15 +23,19 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
GitMerge,
|
GitMerge,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Copy,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||||
import { TooltipWrapper } from './tooltip-wrapper';
|
import { TooltipWrapper } from './tooltip-wrapper';
|
||||||
|
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
|
||||||
|
import { getEditorIcon } from '@/components/icons/editor-icons';
|
||||||
|
|
||||||
interface WorktreeActionsDropdownProps {
|
interface WorktreeActionsDropdownProps {
|
||||||
worktree: WorktreeInfo;
|
worktree: WorktreeInfo;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
defaultEditorName: string;
|
|
||||||
aheadCount: number;
|
aheadCount: number;
|
||||||
behindCount: number;
|
behindCount: number;
|
||||||
isPulling: boolean;
|
isPulling: boolean;
|
||||||
@@ -41,7 +47,7 @@ interface WorktreeActionsDropdownProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onPull: (worktree: WorktreeInfo) => void;
|
onPull: (worktree: WorktreeInfo) => void;
|
||||||
onPush: (worktree: WorktreeInfo) => void;
|
onPush: (worktree: WorktreeInfo) => void;
|
||||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||||
onCommit: (worktree: WorktreeInfo) => void;
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
@@ -50,12 +56,13 @@ interface WorktreeActionsDropdownProps {
|
|||||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||||
|
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||||
|
hasInitScript: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorktreeActionsDropdown({
|
export function WorktreeActionsDropdown({
|
||||||
worktree,
|
worktree,
|
||||||
isSelected,
|
isSelected,
|
||||||
defaultEditorName,
|
|
||||||
aheadCount,
|
aheadCount,
|
||||||
behindCount,
|
behindCount,
|
||||||
isPulling,
|
isPulling,
|
||||||
@@ -76,7 +83,23 @@ export function WorktreeActionsDropdown({
|
|||||||
onStartDevServer,
|
onStartDevServer,
|
||||||
onStopDevServer,
|
onStopDevServer,
|
||||||
onOpenDevServerUrl,
|
onOpenDevServerUrl,
|
||||||
|
onRunInitScript,
|
||||||
|
hasInitScript,
|
||||||
}: WorktreeActionsDropdownProps) {
|
}: 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
|
// Check if there's a PR associated with this worktree from stored metadata
|
||||||
const hasPR = !!worktree.pr;
|
const hasPR = !!worktree.pr;
|
||||||
|
|
||||||
@@ -200,10 +223,60 @@ export function WorktreeActionsDropdown({
|
|||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => onOpenInEditor(worktree)} className="text-xs">
|
{/* Open in editor - split button: click main area for default, chevron for other options */}
|
||||||
<ExternalLink className="w-3.5 h-3.5 mr-2" />
|
{effectiveDefaultEditor && (
|
||||||
Open in {defaultEditorName}
|
<DropdownMenuSub>
|
||||||
</DropdownMenuItem>
|
<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 />
|
<DropdownMenuSeparator />
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ interface WorktreeTabProps {
|
|||||||
isActivating: boolean;
|
isActivating: boolean;
|
||||||
isDevServerRunning: boolean;
|
isDevServerRunning: boolean;
|
||||||
devServerInfo?: DevServerInfo;
|
devServerInfo?: DevServerInfo;
|
||||||
defaultEditorName: string;
|
|
||||||
branches: BranchInfo[];
|
branches: BranchInfo[];
|
||||||
filteredBranches: BranchInfo[];
|
filteredBranches: BranchInfo[];
|
||||||
branchFilter: string;
|
branchFilter: string;
|
||||||
@@ -37,7 +36,7 @@ interface WorktreeTabProps {
|
|||||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||||
onPull: (worktree: WorktreeInfo) => void;
|
onPull: (worktree: WorktreeInfo) => void;
|
||||||
onPush: (worktree: WorktreeInfo) => void;
|
onPush: (worktree: WorktreeInfo) => void;
|
||||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||||
onCommit: (worktree: WorktreeInfo) => void;
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
@@ -46,6 +45,8 @@ interface WorktreeTabProps {
|
|||||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||||
|
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||||
|
hasInitScript: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorktreeTab({
|
export function WorktreeTab({
|
||||||
@@ -58,7 +59,6 @@ export function WorktreeTab({
|
|||||||
isActivating,
|
isActivating,
|
||||||
isDevServerRunning,
|
isDevServerRunning,
|
||||||
devServerInfo,
|
devServerInfo,
|
||||||
defaultEditorName,
|
|
||||||
branches,
|
branches,
|
||||||
filteredBranches,
|
filteredBranches,
|
||||||
branchFilter,
|
branchFilter,
|
||||||
@@ -87,6 +87,8 @@ export function WorktreeTab({
|
|||||||
onStartDevServer,
|
onStartDevServer,
|
||||||
onStopDevServer,
|
onStopDevServer,
|
||||||
onOpenDevServerUrl,
|
onOpenDevServerUrl,
|
||||||
|
onRunInitScript,
|
||||||
|
hasInitScript,
|
||||||
}: WorktreeTabProps) {
|
}: WorktreeTabProps) {
|
||||||
let prBadge: JSX.Element | null = null;
|
let prBadge: JSX.Element | null = null;
|
||||||
if (worktree.pr) {
|
if (worktree.pr) {
|
||||||
@@ -315,7 +317,6 @@ export function WorktreeTab({
|
|||||||
<WorktreeActionsDropdown
|
<WorktreeActionsDropdown
|
||||||
worktree={worktree}
|
worktree={worktree}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
defaultEditorName={defaultEditorName}
|
|
||||||
aheadCount={aheadCount}
|
aheadCount={aheadCount}
|
||||||
behindCount={behindCount}
|
behindCount={behindCount}
|
||||||
isPulling={isPulling}
|
isPulling={isPulling}
|
||||||
@@ -336,6 +337,8 @@ export function WorktreeTab({
|
|||||||
onStartDevServer={onStartDevServer}
|
onStartDevServer={onStartDevServer}
|
||||||
onStopDevServer={onStopDevServer}
|
onStopDevServer={onStopDevServer}
|
||||||
onOpenDevServerUrl={onOpenDevServerUrl}
|
onOpenDevServerUrl={onOpenDevServerUrl}
|
||||||
|
onRunInitScript={onRunInitScript}
|
||||||
|
hasInitScript={hasInitScript}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ export { useWorktrees } from './use-worktrees';
|
|||||||
export { useDevServers } from './use-dev-servers';
|
export { useDevServers } from './use-dev-servers';
|
||||||
export { useBranches } from './use-branches';
|
export { useBranches } from './use-branches';
|
||||||
export { useWorktreeActions } from './use-worktree-actions';
|
export { useWorktreeActions } from './use-worktree-actions';
|
||||||
export { useDefaultEditor } from './use-default-editor';
|
|
||||||
export { useRunningFeatures } from './use-running-features';
|
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]
|
[isPushing, fetchBranches, fetchWorktrees]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo) => {
|
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => {
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.worktree?.openInEditor) {
|
if (!api?.worktree?.openInEditor) {
|
||||||
logger.warn('Open in editor API not available');
|
logger.warn('Open in editor API not available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await api.worktree.openInEditor(worktree.path);
|
const result = await api.worktree.openInEditor(worktree.path, editorCommand);
|
||||||
if (result.success && result.result) {
|
if (result.success && result.result) {
|
||||||
toast.success(result.result.message);
|
toast.success(result.result.message);
|
||||||
} else if (result.error) {
|
} 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 { Button } from '@/components/ui/button';
|
||||||
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
|
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
|
||||||
import { cn, pathsEqual } from '@/lib/utils';
|
import { cn, pathsEqual } from '@/lib/utils';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
||||||
import {
|
import {
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
useDevServers,
|
useDevServers,
|
||||||
useBranches,
|
useBranches,
|
||||||
useWorktreeActions,
|
useWorktreeActions,
|
||||||
useDefaultEditor,
|
|
||||||
useRunningFeatures,
|
useRunningFeatures,
|
||||||
} from './hooks';
|
} from './hooks';
|
||||||
import { WorktreeTab } from './components';
|
import { WorktreeTab } from './components';
|
||||||
@@ -75,13 +76,33 @@ export function WorktreePanel({
|
|||||||
fetchBranches,
|
fetchBranches,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { defaultEditorName } = useDefaultEditor();
|
|
||||||
|
|
||||||
const { hasRunningFeatures } = useRunningFeatures({
|
const { hasRunningFeatures } = useRunningFeatures({
|
||||||
runningFeatureIds,
|
runningFeatureIds,
|
||||||
features,
|
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
|
// 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
|
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
|
||||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
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 mainWorktree = worktrees.find((w) => w.isMain);
|
||||||
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
|
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
|
||||||
|
|
||||||
@@ -137,7 +185,6 @@ export function WorktreePanel({
|
|||||||
isActivating={isActivating}
|
isActivating={isActivating}
|
||||||
isDevServerRunning={isDevServerRunning(mainWorktree)}
|
isDevServerRunning={isDevServerRunning(mainWorktree)}
|
||||||
devServerInfo={getDevServerInfo(mainWorktree)}
|
devServerInfo={getDevServerInfo(mainWorktree)}
|
||||||
defaultEditorName={defaultEditorName}
|
|
||||||
branches={branches}
|
branches={branches}
|
||||||
filteredBranches={filteredBranches}
|
filteredBranches={filteredBranches}
|
||||||
branchFilter={branchFilter}
|
branchFilter={branchFilter}
|
||||||
@@ -166,6 +213,8 @@ export function WorktreePanel({
|
|||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServer}
|
||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
|
onRunInitScript={handleRunInitScript}
|
||||||
|
hasInitScript={hasInitScript}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -192,7 +241,6 @@ export function WorktreePanel({
|
|||||||
isActivating={isActivating}
|
isActivating={isActivating}
|
||||||
isDevServerRunning={isDevServerRunning(worktree)}
|
isDevServerRunning={isDevServerRunning(worktree)}
|
||||||
devServerInfo={getDevServerInfo(worktree)}
|
devServerInfo={getDevServerInfo(worktree)}
|
||||||
defaultEditorName={defaultEditorName}
|
|
||||||
branches={branches}
|
branches={branches}
|
||||||
filteredBranches={filteredBranches}
|
filteredBranches={filteredBranches}
|
||||||
branchFilter={branchFilter}
|
branchFilter={branchFilter}
|
||||||
@@ -221,6 +269,8 @@ export function WorktreePanel({
|
|||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServer}
|
||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
|
onRunInitScript={handleRunInitScript}
|
||||||
|
hasInitScript={hasInitScript}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -472,9 +472,9 @@ export function DashboardView() {
|
|||||||
aria-hidden="true"
|
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
|
<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' })}
|
onClick={() => navigate({ to: '/dashboard' })}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -482,7 +482,7 @@ export function DashboardView() {
|
|||||||
viewBox="0 0 256 256"
|
viewBox="0 0 256 256"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Automaker Logo"
|
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>
|
<defs>
|
||||||
<linearGradient
|
<linearGradient
|
||||||
@@ -521,7 +521,7 @@ export function DashboardView() {
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
<div className="flex flex-col">
|
<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>
|
automaker<span className="text-brand-500">.</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground leading-none font-medium mt-1">
|
<span className="text-xs text-muted-foreground leading-none font-medium mt-1">
|
||||||
@@ -541,30 +541,32 @@ export function DashboardView() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* 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">
|
<div className="max-w-6xl mx-auto">
|
||||||
{/* No projects - show getting started */}
|
{/* No projects - show getting started */}
|
||||||
{!hasProjects && (
|
{!hasProjects && (
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-8 sm:mb-12">
|
||||||
<h2 className="text-3xl font-bold text-foreground mb-3">Welcome to Automaker</h2>
|
<h2 className="text-2xl sm:text-3xl font-bold text-foreground mb-3">
|
||||||
<p className="text-lg text-muted-foreground max-w-xl mx-auto">
|
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
|
Your autonomous AI development studio. Get started by creating a new project or
|
||||||
opening an existing one.
|
opening an existing one.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* New Project Card */}
|
||||||
<div
|
<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"
|
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"
|
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="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="relative p-4 sm:p-6 h-full flex flex-col">
|
||||||
<div className="flex items-start gap-4 flex-1">
|
<div className="flex items-start gap-3 sm: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">
|
<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-6 h-6 text-white" />
|
<Plus className="w-5 h-5 sm:w-6 sm:h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-1.5">
|
<h3 className="text-lg font-semibold text-foreground mb-1.5">
|
||||||
@@ -578,7 +580,7 @@ export function DashboardView() {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<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"
|
data-testid="create-new-project"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
@@ -613,10 +615,10 @@ export function DashboardView() {
|
|||||||
data-testid="open-project-card"
|
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="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="relative p-4 sm:p-6 h-full flex flex-col">
|
||||||
<div className="flex items-start gap-4 flex-1">
|
<div className="flex items-start gap-3 sm: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">
|
<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-6 h-6 text-muted-foreground group-hover:text-blue-500 transition-colors duration-300" />
|
<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>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-1.5">
|
<h3 className="text-lg font-semibold text-foreground mb-1.5">
|
||||||
@@ -629,7 +631,7 @@ export function DashboardView() {
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
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"
|
data-testid="open-existing-project"
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-4 h-4 mr-2" />
|
<FolderOpen className="w-4 h-4 mr-2" />
|
||||||
@@ -643,21 +645,26 @@ export function DashboardView() {
|
|||||||
|
|
||||||
{/* Has projects - show project list */}
|
{/* Has projects - show project list */}
|
||||||
{hasProjects && (
|
{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 */}
|
{/* 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>
|
<h2 className="text-2xl font-bold text-foreground">Your Projects</h2>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={handleOpenProject}>
|
<Button
|
||||||
<FolderOpen className="w-4 h-4 mr-2" />
|
variant="outline"
|
||||||
Open Folder
|
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>
|
</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button className="bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white">
|
<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 mr-2" />
|
<Plus className="w-4 h-4 sm:mr-2" />
|
||||||
New Project
|
<span className="hidden sm:inline">New Project</span>
|
||||||
<ChevronDown className="w-4 h-4 ml-2" />
|
<span className="sm:hidden">New</span>
|
||||||
|
<ChevronDown className="w-4 h-4 ml-1 sm:ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
@@ -677,13 +684,15 @@ export function DashboardView() {
|
|||||||
{/* Favorites section */}
|
{/* Favorites section */}
|
||||||
{favoriteProjects.length > 0 && (
|
{favoriteProjects.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2.5 mb-4">
|
<div className="flex items-center gap-2 sm:gap-2.5 mb-3 sm:mb-4">
|
||||||
<div className="w-8 h-8 rounded-lg bg-yellow-500/10 flex items-center justify-center">
|
<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-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" />
|
||||||
</div>
|
</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>
|
||||||
<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) => (
|
{favoriteProjects.map((project) => (
|
||||||
<div
|
<div
|
||||||
key={project.id}
|
key={project.id}
|
||||||
@@ -692,40 +701,40 @@ export function DashboardView() {
|
|||||||
data-testid={`project-card-${project.id}`}
|
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="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="relative p-3 sm:p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-2.5 sm: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">
|
<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-5 h-5 text-yellow-500" />
|
<Folder className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<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}
|
{project.name}
|
||||||
</p>
|
</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}
|
{project.path}
|
||||||
</p>
|
</p>
|
||||||
{project.lastOpened && (
|
{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()}
|
{new Date(project.lastOpened).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5 sm:gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleToggleFavorite(e, project.id)}
|
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"
|
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>
|
</button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => e.stopPropagation()}
|
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"
|
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>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
@@ -750,13 +759,15 @@ export function DashboardView() {
|
|||||||
{/* Recent projects section */}
|
{/* Recent projects section */}
|
||||||
{recentProjects.length > 0 && (
|
{recentProjects.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2.5 mb-4">
|
<div className="flex items-center gap-2 sm:gap-2.5 mb-3 sm:mb-4">
|
||||||
<div className="w-8 h-8 rounded-lg bg-muted/50 flex items-center justify-center">
|
<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-4 h-4 text-muted-foreground" />
|
<Clock className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground" />
|
||||||
</div>
|
</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>
|
||||||
<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) => (
|
{recentProjects.map((project) => (
|
||||||
<div
|
<div
|
||||||
key={project.id}
|
key={project.id}
|
||||||
@@ -765,40 +776,40 @@ export function DashboardView() {
|
|||||||
data-testid={`project-card-${project.id}`}
|
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="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="relative p-3 sm:p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-2.5 sm: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">
|
<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-5 h-5 text-muted-foreground group-hover:text-brand-500 transition-colors duration-300" />
|
<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>
|
||||||
<div className="flex-1 min-w-0">
|
<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}
|
{project.name}
|
||||||
</p>
|
</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}
|
{project.path}
|
||||||
</p>
|
</p>
|
||||||
{project.lastOpened && (
|
{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()}
|
{new Date(project.lastOpened).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5 sm:gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleToggleFavorite(e, project.id)}
|
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"
|
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>
|
</button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => e.stopPropagation()}
|
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"
|
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>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
|||||||
@@ -13,7 +13,15 @@
|
|||||||
|
|
||||||
import { useReducer, useEffect, useRef } from 'react';
|
import { useReducer, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { KeyRound, AlertCircle, Loader2, RefreshCw, ServerCrash } from 'lucide-react';
|
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 MAX_RETRIES = 5;
|
||||||
const BACKOFF_BASE_MS = 400;
|
const BACKOFF_BASE_MS = 400;
|
||||||
|
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Imperative Flow Logic (runs once on mount)
|
// 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()
|
* Unlike the httpClient methods, this does NOT call handleUnauthorized()
|
||||||
* which would navigate us away to /logged-out.
|
* 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 }
|
* Returns: { authenticated: true } or { authenticated: false }
|
||||||
* Throws: on network errors (for retry logic)
|
* Throws: on network errors (for retry logic)
|
||||||
@@ -110,9 +121,31 @@ const BACKOFF_BASE_MS = 400;
|
|||||||
async function checkAuthStatusSafe(): Promise<{ authenticated: boolean }> {
|
async function checkAuthStatusSafe(): Promise<{ authenticated: boolean }> {
|
||||||
const serverUrl = getServerUrlSync();
|
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`, {
|
const response = await fetch(`${serverUrl}/api/auth/status`, {
|
||||||
credentials: 'include', // Send HTTP-only session cookie
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Any response means server is reachable
|
// Any response means server is reachable
|
||||||
@@ -246,6 +279,14 @@ export function LoginView() {
|
|||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const retryControllerRef = useRef<AbortController | null>(null);
|
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.
|
// Run initial server/session check on mount.
|
||||||
// IMPORTANT: Do not "run once" via a ref guard here.
|
// IMPORTANT: Do not "run once" via a ref guard here.
|
||||||
// In React StrictMode (dev), effects mount -> cleanup -> mount.
|
// 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 { AudioSection } from './settings-view/audio/audio-section';
|
||||||
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
|
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
|
||||||
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-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 { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
|
||||||
import { AccountSection } from './settings-view/account';
|
import { AccountSection } from './settings-view/account';
|
||||||
import { SecuritySection } from './settings-view/security';
|
import { SecuritySection } from './settings-view/security';
|
||||||
@@ -149,17 +150,19 @@ export function SettingsView() {
|
|||||||
defaultSkipTests={defaultSkipTests}
|
defaultSkipTests={defaultSkipTests}
|
||||||
enableDependencyBlocking={enableDependencyBlocking}
|
enableDependencyBlocking={enableDependencyBlocking}
|
||||||
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
||||||
useWorktrees={useWorktrees}
|
|
||||||
defaultPlanningMode={defaultPlanningMode}
|
defaultPlanningMode={defaultPlanningMode}
|
||||||
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
||||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||||
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||||
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
|
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
|
||||||
onUseWorktreesChange={setUseWorktrees}
|
|
||||||
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
||||||
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case 'worktrees':
|
||||||
|
return (
|
||||||
|
<WorktreesSection useWorktrees={useWorktrees} onUseWorktreesChange={setUseWorktrees} />
|
||||||
|
);
|
||||||
case 'account':
|
case 'account':
|
||||||
return <AccountSection />;
|
return <AccountSection />;
|
||||||
case 'security':
|
case 'security':
|
||||||
|
|||||||
@@ -1,15 +1,51 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { cn } from '@/lib/utils';
|
||||||
import { logout } from '@/lib/http-api-client';
|
import { logout } from '@/lib/http-api-client';
|
||||||
import { useAuthStore } from '@/store/auth-store';
|
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() {
|
export function AccountSection() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
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 () => {
|
const handleLogout = async () => {
|
||||||
setIsLoggingOut(true);
|
setIsLoggingOut(true);
|
||||||
try {
|
try {
|
||||||
@@ -43,6 +79,81 @@ export function AccountSection() {
|
|||||||
<p className="text-sm text-muted-foreground/80 ml-12">Manage your session and account.</p>
|
<p className="text-sm text-muted-foreground/80 ml-12">Manage your session and account.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-4">
|
<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 */}
|
{/* 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 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="flex items-center gap-3.5 min-w-0">
|
||||||
|
|||||||
@@ -1,9 +1,40 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
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 { cn } from '@/lib/utils';
|
||||||
import type { CliStatus } from '../shared/types';
|
import type { CliStatus } from '../shared/types';
|
||||||
import { OpenCodeIcon } from '@/components/ui/provider-icon';
|
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 =
|
export type OpencodeAuthMethod =
|
||||||
| 'api_key_env' // ANTHROPIC_API_KEY or other provider env vars
|
| 'api_key_env' // ANTHROPIC_API_KEY or other provider env vars
|
||||||
| 'api_key' // Manually stored API key
|
| 'api_key' // Manually stored API key
|
||||||
@@ -38,6 +69,7 @@ function getAuthMethodLabel(method: OpencodeAuthMethod): string {
|
|||||||
interface OpencodeCliStatusProps {
|
interface OpencodeCliStatusProps {
|
||||||
status: CliStatus | null;
|
status: CliStatus | null;
|
||||||
authStatus?: OpencodeAuthStatus | null;
|
authStatus?: OpencodeAuthStatus | null;
|
||||||
|
providers?: OpenCodeProviderInfo[];
|
||||||
isChecking: boolean;
|
isChecking: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}
|
}
|
||||||
@@ -152,9 +184,11 @@ export function OpencodeModelConfigSkeleton() {
|
|||||||
export function OpencodeCliStatus({
|
export function OpencodeCliStatus({
|
||||||
status,
|
status,
|
||||||
authStatus,
|
authStatus,
|
||||||
|
providers = [],
|
||||||
isChecking,
|
isChecking,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: OpencodeCliStatusProps) {
|
}: OpencodeCliStatusProps) {
|
||||||
|
const authenticatedProviders = providers.filter((p) => p.authenticated);
|
||||||
if (!status) return <OpencodeCliStatusSkeleton />;
|
if (!status) return <OpencodeCliStatusSkeleton />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -223,8 +257,8 @@ export function OpencodeCliStatus({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Authentication Status */}
|
{/* Authentication Status - consider both direct auth and provider auth */}
|
||||||
{authStatus?.authenticated ? (
|
{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="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">
|
<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" />
|
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||||
@@ -232,24 +266,71 @@ export function OpencodeCliStatus({
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
|
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
|
||||||
<div className="text-xs text-emerald-400/70 mt-1.5">
|
<div className="text-xs text-emerald-400/70 mt-1.5">
|
||||||
<p>
|
{authStatus?.authenticated && authStatus.method !== 'none' ? (
|
||||||
Method:{' '}
|
<p>
|
||||||
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
|
Method:{' '}
|
||||||
</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
<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-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
|
<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">
|
||||||
<XCircle className="w-5 h-5 text-amber-500" />
|
<Bot className="w-5 h-5 text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-amber-400">Not Authenticated</p>
|
<p className="text-sm font-medium text-blue-400">OpenCode Free Tier Ready</p>
|
||||||
<p className="text-xs text-amber-400/70 mt-1">
|
<p className="text-xs text-blue-400/70 mt-1">
|
||||||
Run{' '}
|
Free OpenCode models work without login. Run{' '}
|
||||||
<code className="font-mono bg-amber-500/10 px-1 rounded">opencode auth</code> or
|
<code className="font-mono bg-blue-500/10 px-1 rounded">
|
||||||
set an API key to authenticate.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
User,
|
User,
|
||||||
Shield,
|
Shield,
|
||||||
|
Cpu,
|
||||||
|
GitBranch,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
|
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
|
||||||
import type { SettingsViewId } from '../hooks/use-settings-view';
|
import type { SettingsViewId } from '../hooks/use-settings-view';
|
||||||
@@ -37,6 +39,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ id: 'model-defaults', label: 'Model Defaults', icon: Workflow },
|
{ id: 'model-defaults', label: 'Model Defaults', icon: Workflow },
|
||||||
{ id: 'defaults', label: 'Feature Defaults', icon: FlaskConical },
|
{ id: 'defaults', label: 'Feature Defaults', icon: FlaskConical },
|
||||||
|
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||||
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
|
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
|
||||||
{ id: 'api-keys', label: 'API Keys', icon: Key },
|
{ id: 'api-keys', label: 'API Keys', icon: Key },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import {
|
import {
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
TestTube,
|
TestTube,
|
||||||
GitBranch,
|
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Zap,
|
Zap,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
@@ -27,13 +26,11 @@ interface FeatureDefaultsSectionProps {
|
|||||||
defaultSkipTests: boolean;
|
defaultSkipTests: boolean;
|
||||||
enableDependencyBlocking: boolean;
|
enableDependencyBlocking: boolean;
|
||||||
skipVerificationInAutoMode: boolean;
|
skipVerificationInAutoMode: boolean;
|
||||||
useWorktrees: boolean;
|
|
||||||
defaultPlanningMode: PlanningMode;
|
defaultPlanningMode: PlanningMode;
|
||||||
defaultRequirePlanApproval: boolean;
|
defaultRequirePlanApproval: boolean;
|
||||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||||
onEnableDependencyBlockingChange: (value: boolean) => void;
|
onEnableDependencyBlockingChange: (value: boolean) => void;
|
||||||
onSkipVerificationInAutoModeChange: (value: boolean) => void;
|
onSkipVerificationInAutoModeChange: (value: boolean) => void;
|
||||||
onUseWorktreesChange: (value: boolean) => void;
|
|
||||||
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
||||||
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -42,13 +39,11 @@ export function FeatureDefaultsSection({
|
|||||||
defaultSkipTests,
|
defaultSkipTests,
|
||||||
enableDependencyBlocking,
|
enableDependencyBlocking,
|
||||||
skipVerificationInAutoMode,
|
skipVerificationInAutoMode,
|
||||||
useWorktrees,
|
|
||||||
defaultPlanningMode,
|
defaultPlanningMode,
|
||||||
defaultRequirePlanApproval,
|
defaultRequirePlanApproval,
|
||||||
onDefaultSkipTestsChange,
|
onDefaultSkipTestsChange,
|
||||||
onEnableDependencyBlockingChange,
|
onEnableDependencyBlockingChange,
|
||||||
onSkipVerificationInAutoModeChange,
|
onSkipVerificationInAutoModeChange,
|
||||||
onUseWorktreesChange,
|
|
||||||
onDefaultPlanningModeChange,
|
onDefaultPlanningModeChange,
|
||||||
onDefaultRequirePlanApprovalChange,
|
onDefaultRequirePlanApprovalChange,
|
||||||
}: FeatureDefaultsSectionProps) {
|
}: FeatureDefaultsSectionProps) {
|
||||||
@@ -256,33 +251,6 @@ export function FeatureDefaultsSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type SettingsViewId =
|
|||||||
| 'keyboard'
|
| 'keyboard'
|
||||||
| 'audio'
|
| 'audio'
|
||||||
| 'defaults'
|
| 'defaults'
|
||||||
|
| 'worktrees'
|
||||||
| 'account'
|
| 'account'
|
||||||
| 'security'
|
| 'security'
|
||||||
| 'danger';
|
| '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 { cn } from '@/lib/utils';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import type {
|
import type {
|
||||||
@@ -25,18 +25,13 @@ import {
|
|||||||
THINKING_LEVEL_LABELS,
|
THINKING_LEVEL_LABELS,
|
||||||
REASONING_EFFORT_LEVELS,
|
REASONING_EFFORT_LEVELS,
|
||||||
REASONING_EFFORT_LABELS,
|
REASONING_EFFORT_LABELS,
|
||||||
|
type ModelOption,
|
||||||
} from '@/components/views/board-view/shared/model-constants';
|
} from '@/components/views/board-view/shared/model-constants';
|
||||||
import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react';
|
import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
AnthropicIcon,
|
AnthropicIcon,
|
||||||
CursorIcon,
|
CursorIcon,
|
||||||
OpenAIIcon,
|
OpenAIIcon,
|
||||||
OpenCodeIcon,
|
|
||||||
DeepSeekIcon,
|
|
||||||
NovaIcon,
|
|
||||||
QwenIcon,
|
|
||||||
MistralIcon,
|
|
||||||
MetaIcon,
|
|
||||||
getProviderIconForModel,
|
getProviderIconForModel,
|
||||||
} from '@/components/ui/provider-icon';
|
} from '@/components/ui/provider-icon';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -51,6 +46,80 @@ import {
|
|||||||
} from '@/components/ui/command';
|
} from '@/components/ui/command';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
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 {
|
interface PhaseModelSelectorProps {
|
||||||
/** Label shown in full mode */
|
/** Label shown in full mode */
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -95,6 +164,7 @@ export function PhaseModelSelector({
|
|||||||
codexModels,
|
codexModels,
|
||||||
codexModelsLoading,
|
codexModelsLoading,
|
||||||
fetchCodexModels,
|
fetchCodexModels,
|
||||||
|
dynamicOpencodeModels,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
// Extract model and thinking/reasoning levels from value
|
// Extract model and thinking/reasoning levels from value
|
||||||
@@ -235,12 +305,30 @@ export function PhaseModelSelector({
|
|||||||
const codexModel = transformedCodexModels.find((m) => m.id === selectedModel);
|
const codexModel = transformedCodexModels.find((m) => m.id === selectedModel);
|
||||||
if (codexModel) return { ...codexModel, icon: OpenAIIcon };
|
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);
|
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;
|
return null;
|
||||||
}, [selectedModel, selectedThinkingLevel, availableCursorModels, transformedCodexModels]);
|
}, [
|
||||||
|
selectedModel,
|
||||||
|
selectedThinkingLevel,
|
||||||
|
availableCursorModels,
|
||||||
|
transformedCodexModels,
|
||||||
|
dynamicOpencodeModels,
|
||||||
|
]);
|
||||||
|
|
||||||
// Compute grouped vs standalone Cursor models
|
// Compute grouped vs standalone Cursor models
|
||||||
const { groupedModels, standaloneCursorModels } = useMemo(() => {
|
const { groupedModels, standaloneCursorModels } = useMemo(() => {
|
||||||
@@ -275,13 +363,35 @@ export function PhaseModelSelector({
|
|||||||
return { groupedModels: grouped, standaloneCursorModels: standalone };
|
return { groupedModels: grouped, standaloneCursorModels: standalone };
|
||||||
}, [availableCursorModels, enabledCursorModels]);
|
}, [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
|
// Group models
|
||||||
const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
|
const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
|
||||||
const favs: typeof CLAUDE_MODELS = [];
|
const favs: typeof CLAUDE_MODELS = [];
|
||||||
const cModels: typeof CLAUDE_MODELS = [];
|
const cModels: typeof CLAUDE_MODELS = [];
|
||||||
const curModels: typeof CURSOR_MODELS = [];
|
const curModels: typeof CURSOR_MODELS = [];
|
||||||
const codModels: typeof transformedCodexModels = [];
|
const codModels: typeof transformedCodexModels = [];
|
||||||
const ocModels: typeof OPENCODE_MODELS = [];
|
const ocModels: ModelOption[] = [];
|
||||||
|
|
||||||
// Process Claude Models
|
// Process Claude Models
|
||||||
CLAUDE_MODELS.forEach((model) => {
|
CLAUDE_MODELS.forEach((model) => {
|
||||||
@@ -310,8 +420,8 @@ export function PhaseModelSelector({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process OpenCode Models
|
// Process OpenCode Models (including dynamic)
|
||||||
OPENCODE_MODELS.forEach((model) => {
|
allOpencodeModels.forEach((model) => {
|
||||||
if (favoriteModels.includes(model.id)) {
|
if (favoriteModels.includes(model.id)) {
|
||||||
favs.push(model);
|
favs.push(model);
|
||||||
} else {
|
} else {
|
||||||
@@ -326,7 +436,95 @@ export function PhaseModelSelector({
|
|||||||
codex: codModels,
|
codex: codModels,
|
||||||
opencode: ocModels,
|
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)
|
// Render Codex model item with secondary popover for reasoning effort (only for models that support it)
|
||||||
const renderCodexModelItem = (model: (typeof transformedCodexModels)[0]) => {
|
const renderCodexModelItem = (model: (typeof transformedCodexModels)[0]) => {
|
||||||
@@ -992,9 +1190,32 @@ export function PhaseModelSelector({
|
|||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{opencode.length > 0 && (
|
{opencodeSections.length > 0 && (
|
||||||
<CommandGroup heading="OpenCode Models">
|
<CommandGroup heading={OPENCODE_CLI_GROUP_LABEL}>
|
||||||
{opencode.map((model) => renderOpencodeModelItem(model))}
|
{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>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
</CommandList>
|
</CommandList>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -8,20 +9,32 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { Terminal, Cloud, Cpu, Brain, Github, Loader2, KeyRound, ShieldCheck } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
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 { OPENCODE_MODELS, OPENCODE_MODEL_CONFIG_MAP } from '@automaker/types';
|
||||||
|
import type { OpenCodeProviderInfo } from '../cli-status/opencode-cli-status';
|
||||||
import {
|
import {
|
||||||
OpenCodeIcon,
|
OpenCodeIcon,
|
||||||
DeepSeekIcon,
|
DeepSeekIcon,
|
||||||
QwenIcon,
|
QwenIcon,
|
||||||
NovaIcon,
|
NovaIcon,
|
||||||
AnthropicIcon,
|
AnthropicIcon,
|
||||||
|
OpenRouterIcon,
|
||||||
MistralIcon,
|
MistralIcon,
|
||||||
MetaIcon,
|
MetaIcon,
|
||||||
|
GeminiIcon,
|
||||||
|
OpenAIIcon,
|
||||||
|
GrokIcon,
|
||||||
getProviderIconForModel,
|
getProviderIconForModel,
|
||||||
} from '@/components/ui/provider-icon';
|
} from '@/components/ui/provider-icon';
|
||||||
import type { ComponentType } from 'react';
|
import { useEffect, useMemo, useRef, useState, type ComponentType } from 'react';
|
||||||
|
|
||||||
interface OpencodeModelConfigurationProps {
|
interface OpencodeModelConfigurationProps {
|
||||||
enabledOpencodeModels: OpencodeModelId[];
|
enabledOpencodeModels: OpencodeModelId[];
|
||||||
@@ -29,6 +42,12 @@ interface OpencodeModelConfigurationProps {
|
|||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
onDefaultModelChange: (model: OpencodeModelId) => void;
|
onDefaultModelChange: (model: OpencodeModelId) => void;
|
||||||
onModelToggle: (model: OpencodeModelId, enabled: boolean) => 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) {
|
switch (provider) {
|
||||||
case 'opencode':
|
case 'opencode':
|
||||||
return 'OpenCode (Free)';
|
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:
|
default:
|
||||||
return provider;
|
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({
|
export function OpencodeModelConfiguration({
|
||||||
enabledOpencodeModels,
|
enabledOpencodeModels,
|
||||||
opencodeDefaultModel,
|
opencodeDefaultModel,
|
||||||
isSaving,
|
isSaving,
|
||||||
onDefaultModelChange,
|
onDefaultModelChange,
|
||||||
onModelToggle,
|
onModelToggle,
|
||||||
|
providers,
|
||||||
|
dynamicModels,
|
||||||
|
enabledDynamicModelIds,
|
||||||
|
onDynamicModelToggle,
|
||||||
|
isLoadingDynamicModels = false,
|
||||||
}: OpencodeModelConfigurationProps) {
|
}: OpencodeModelConfigurationProps) {
|
||||||
// Group models by provider for organized display
|
// Group static models by provider for organized display
|
||||||
const modelsByProvider = OPENCODE_MODELS.reduce(
|
const modelsByProvider = OPENCODE_MODELS.reduce(
|
||||||
(acc, model) => {
|
(acc, model) => {
|
||||||
if (!acc[model.provider]) {
|
if (!acc[model.provider]) {
|
||||||
@@ -81,17 +213,141 @@ export function OpencodeModelConfiguration({
|
|||||||
{} as Record<OpencodeProvider, OpencodeModelConfig[]>
|
{} 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
|
// Order: Free tier first, then Claude, then others
|
||||||
const providerOrder: OpencodeProvider[] = [
|
const providerOrder: OpencodeProvider[] = ['opencode'];
|
||||||
'opencode',
|
|
||||||
'amazon-bedrock-anthropic',
|
// Dynamic provider order (prioritize commonly used ones)
|
||||||
'amazon-bedrock-deepseek',
|
const dynamicProviderOrder = [
|
||||||
'amazon-bedrock-amazon',
|
'github-copilot',
|
||||||
'amazon-bedrock-meta',
|
'google',
|
||||||
'amazon-bedrock-mistral',
|
'openai',
|
||||||
'amazon-bedrock-qwen',
|
'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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -146,7 +402,21 @@ export function OpencodeModelConfiguration({
|
|||||||
|
|
||||||
{/* Available Models grouped by provider */}
|
{/* Available Models grouped by provider */}
|
||||||
<div className="space-y-4">
|
<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) => {
|
{providerOrder.map((provider) => {
|
||||||
const models = modelsByProvider[provider];
|
const models = modelsByProvider[provider];
|
||||||
if (!models || models.length === 0) return null;
|
if (!models || models.length === 0) return null;
|
||||||
@@ -217,6 +487,196 @@ export function OpencodeModelConfiguration({
|
|||||||
</div>
|
</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>
|
</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 { toast } from 'sonner';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import {
|
import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status';
|
||||||
OpencodeCliStatus,
|
|
||||||
OpencodeCliStatusSkeleton,
|
|
||||||
OpencodeModelConfigSkeleton,
|
|
||||||
} from '../cli-status/opencode-cli-status';
|
|
||||||
import { OpencodeModelConfiguration } from './opencode-model-configuration';
|
import { OpencodeModelConfiguration } from './opencode-model-configuration';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import type { CliStatus as SharedCliStatus } from '../shared/types';
|
import type { CliStatus as SharedCliStatus } from '../shared/types';
|
||||||
import type { OpencodeModelId } from '@automaker/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 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() {
|
export function OpencodeSettingsTab() {
|
||||||
const {
|
const {
|
||||||
@@ -21,15 +20,22 @@ export function OpencodeSettingsTab() {
|
|||||||
opencodeDefaultModel,
|
opencodeDefaultModel,
|
||||||
setOpencodeDefaultModel,
|
setOpencodeDefaultModel,
|
||||||
toggleOpencodeModel,
|
toggleOpencodeModel,
|
||||||
|
setDynamicOpencodeModels,
|
||||||
|
dynamicOpencodeModels,
|
||||||
|
enabledDynamicModelIds,
|
||||||
|
toggleDynamicModel,
|
||||||
|
cachedOpencodeProviders,
|
||||||
|
setCachedOpencodeProviders,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
const [isCheckingOpencodeCli, setIsCheckingOpencodeCli] = useState(false);
|
const [isCheckingOpencodeCli, setIsCheckingOpencodeCli] = useState(false);
|
||||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
const [isLoadingDynamicModels, setIsLoadingDynamicModels] = useState(false);
|
||||||
const [cliStatus, setCliStatus] = useState<SharedCliStatus | null>(null);
|
const [cliStatus, setCliStatus] = useState<SharedCliStatus | null>(null);
|
||||||
const [authStatus, setAuthStatus] = useState<OpencodeAuthStatus | null>(null);
|
const [authStatus, setAuthStatus] = useState<OpencodeAuthStatus | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const providerRefreshSignatureRef = useRef<string>('');
|
||||||
|
|
||||||
// Load OpenCode CLI status on mount
|
// Phase 1: Load CLI status quickly on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkOpencodeStatus = async () => {
|
const checkOpencodeStatus = async () => {
|
||||||
setIsCheckingOpencodeCli(true);
|
setIsCheckingOpencodeCli(true);
|
||||||
@@ -46,7 +52,6 @@ export function OpencodeSettingsTab() {
|
|||||||
recommendation: result.recommendation,
|
recommendation: result.recommendation,
|
||||||
installCommands: result.installCommands,
|
installCommands: result.installCommands,
|
||||||
});
|
});
|
||||||
// Set auth status if available
|
|
||||||
if (result.auth) {
|
if (result.auth) {
|
||||||
setAuthStatus({
|
setAuthStatus({
|
||||||
authenticated: result.auth.authenticated,
|
authenticated: result.auth.authenticated,
|
||||||
@@ -57,7 +62,6 @@ export function OpencodeSettingsTab() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback for web mode or when API is not available
|
|
||||||
setCliStatus({
|
setCliStatus({
|
||||||
success: false,
|
success: false,
|
||||||
status: 'not_installed',
|
status: 'not_installed',
|
||||||
@@ -73,14 +77,111 @@ export function OpencodeSettingsTab() {
|
|||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsCheckingOpencodeCli(false);
|
setIsCheckingOpencodeCli(false);
|
||||||
setIsInitialLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
checkOpencodeStatus();
|
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 () => {
|
const handleRefreshOpencodeCli = useCallback(async () => {
|
||||||
setIsCheckingOpencodeCli(true);
|
setIsCheckingOpencodeCli(true);
|
||||||
|
setIsLoadingDynamicModels(true);
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (api?.setup?.getOpencodeStatus) {
|
if (api?.setup?.getOpencodeStatus) {
|
||||||
@@ -94,7 +195,6 @@ export function OpencodeSettingsTab() {
|
|||||||
recommendation: result.recommendation,
|
recommendation: result.recommendation,
|
||||||
installCommands: result.installCommands,
|
installCommands: result.installCommands,
|
||||||
});
|
});
|
||||||
// Update auth status if available
|
|
||||||
if (result.auth) {
|
if (result.auth) {
|
||||||
setAuthStatus({
|
setAuthStatus({
|
||||||
authenticated: result.auth.authenticated,
|
authenticated: result.auth.authenticated,
|
||||||
@@ -104,14 +204,35 @@ export function OpencodeSettingsTab() {
|
|||||||
hasOAuthToken: result.auth.hasOAuthToken,
|
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) {
|
} catch (error) {
|
||||||
logger.error('Failed to refresh OpenCode CLI status:', error);
|
logger.error('Failed to refresh OpenCode CLI status:', error);
|
||||||
toast.error('Failed to refresh OpenCode CLI status');
|
toast.error('Failed to refresh OpenCode CLI status');
|
||||||
} finally {
|
} finally {
|
||||||
setIsCheckingOpencodeCli(false);
|
setIsCheckingOpencodeCli(false);
|
||||||
|
setIsLoadingDynamicModels(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setDynamicOpencodeModels, setCachedOpencodeProviders]);
|
||||||
|
|
||||||
const handleDefaultModelChange = useCallback(
|
const handleDefaultModelChange = useCallback(
|
||||||
(model: OpencodeModelId) => {
|
(model: OpencodeModelId) => {
|
||||||
@@ -142,12 +263,25 @@ export function OpencodeSettingsTab() {
|
|||||||
[toggleOpencodeModel]
|
[toggleOpencodeModel]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show loading skeleton during initial load
|
const handleDynamicModelToggle = useCallback(
|
||||||
if (isInitialLoading) {
|
(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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<OpencodeCliStatusSkeleton />
|
<OpencodeCliStatusSkeleton />
|
||||||
<OpencodeModelConfigSkeleton />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -159,6 +293,7 @@ export function OpencodeSettingsTab() {
|
|||||||
<OpencodeCliStatus
|
<OpencodeCliStatus
|
||||||
status={cliStatus}
|
status={cliStatus}
|
||||||
authStatus={authStatus}
|
authStatus={authStatus}
|
||||||
|
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
|
||||||
isChecking={isCheckingOpencodeCli}
|
isChecking={isCheckingOpencodeCli}
|
||||||
onRefresh={handleRefreshOpencodeCli}
|
onRefresh={handleRefreshOpencodeCli}
|
||||||
/>
|
/>
|
||||||
@@ -171,6 +306,11 @@ export function OpencodeSettingsTab() {
|
|||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
onDefaultModelChange={handleDefaultModelChange}
|
onDefaultModelChange={handleDefaultModelChange}
|
||||||
onModelToggle={handleModelToggle}
|
onModelToggle={handleModelToggle}
|
||||||
|
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
|
||||||
|
dynamicModels={dynamicOpencodeModels}
|
||||||
|
enabledDynamicModelIds={enabledDynamicModelIds}
|
||||||
|
onDynamicModelToggle={handleDynamicModelToggle}
|
||||||
|
isLoadingDynamicModels={isLoadingDynamicModels}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<p className="font-medium text-foreground">This step is optional</p>
|
<p className="font-medium text-foreground">This step is optional</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<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.
|
can skip this and use other providers, or configure it later in Settings.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,7 +241,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-medium text-foreground">OpenCode CLI not found</p>
|
<p className="font-medium text-foreground">OpenCode CLI not found</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1119,7 +1119,7 @@ function OpencodeContent() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-medium text-foreground">OpenCode CLI not found</p>
|
<p className="font-medium text-foreground">OpenCode CLI not found</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { getElectronAPI } from '@/lib/electron';
|
|||||||
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
|
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
|
||||||
|
|
||||||
const logger = createLogger('Terminal');
|
const logger = createLogger('Terminal');
|
||||||
|
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||||
|
|
||||||
// Font size constraints
|
// Font size constraints
|
||||||
const MIN_FONT_SIZE = 8;
|
const MIN_FONT_SIZE = 8;
|
||||||
@@ -504,6 +505,7 @@ export function TerminalPanel({
|
|||||||
const response = await fetch(`${serverUrl}/api/auth/token`, {
|
const response = await fetch(`${serverUrl}/api/auth/token`, {
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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 setCardBorderOpacity = useAppStore((state) => state.setCardBorderOpacity);
|
||||||
const setHideScrollbar = useAppStore((state) => state.setHideScrollbar);
|
const setHideScrollbar = useAppStore((state) => state.setHideScrollbar);
|
||||||
const setWorktreePanelVisible = useAppStore((state) => state.setWorktreePanelVisible);
|
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 loadingRef = useRef<string | null>(null);
|
||||||
const currentProjectRef = useRef<string | null>(null);
|
const currentProjectRef = useRef<string | null>(null);
|
||||||
@@ -78,6 +83,27 @@ export function useProjectSettingsLoader() {
|
|||||||
if (result.settings.worktreePanelVisible !== undefined) {
|
if (result.settings.worktreePanelVisible !== undefined) {
|
||||||
setWorktreePanelVisible(requestedProjectPath, result.settings.worktreePanelVisible);
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to load project settings:', 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 { getItem, setItem } from '@/lib/storage';
|
||||||
import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store';
|
import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-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');
|
const logger = createLogger('SettingsMigration');
|
||||||
|
|
||||||
@@ -152,6 +156,10 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
|||||||
phaseModels: state.phaseModels as GlobalSettings['phaseModels'],
|
phaseModels: state.phaseModels as GlobalSettings['phaseModels'],
|
||||||
enabledCursorModels: state.enabledCursorModels as GlobalSettings['enabledCursorModels'],
|
enabledCursorModels: state.enabledCursorModels as GlobalSettings['enabledCursorModels'],
|
||||||
cursorDefaultModel: state.cursorDefaultModel as GlobalSettings['cursorDefaultModel'],
|
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,
|
autoLoadClaudeMd: state.autoLoadClaudeMd as boolean,
|
||||||
keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'],
|
keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'],
|
||||||
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
|
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
|
||||||
@@ -495,6 +503,27 @@ export function useSettingsMigration(): MigrationState {
|
|||||||
*/
|
*/
|
||||||
export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||||
const current = useAppStore.getState();
|
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)
|
// Convert ProjectRef[] to Project[] (minimal data, features will be loaded separately)
|
||||||
const projects = (settings.projects ?? []).map((ref) => ({
|
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
|
// Save theme to localStorage for fallback when server settings aren't available
|
||||||
if (settings.theme) {
|
const storedTheme = (currentProject?.theme as string | undefined) || settings.theme;
|
||||||
setItem(THEME_STORAGE_KEY, settings.theme);
|
if (storedTheme) {
|
||||||
|
setItem(THEME_STORAGE_KEY, storedTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
useAppStore.setState({
|
useAppStore.setState({
|
||||||
@@ -538,6 +568,9 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
|||||||
phaseModels: settings.phaseModels ?? current.phaseModels,
|
phaseModels: settings.phaseModels ?? current.phaseModels,
|
||||||
enabledCursorModels: settings.enabledCursorModels ?? current.enabledCursorModels,
|
enabledCursorModels: settings.enabledCursorModels ?? current.enabledCursorModels,
|
||||||
cursorDefaultModel: settings.cursorDefaultModel ?? 'auto',
|
cursorDefaultModel: settings.cursorDefaultModel ?? 'auto',
|
||||||
|
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
|
||||||
|
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
|
||||||
|
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
||||||
autoLoadClaudeMd: settings.autoLoadClaudeMd ?? false,
|
autoLoadClaudeMd: settings.autoLoadClaudeMd ?? false,
|
||||||
skipSandboxWarning: settings.skipSandboxWarning ?? false,
|
skipSandboxWarning: settings.skipSandboxWarning ?? false,
|
||||||
keyboardShortcuts: {
|
keyboardShortcuts: {
|
||||||
@@ -591,6 +624,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
|||||||
enhancementModel: state.enhancementModel,
|
enhancementModel: state.enhancementModel,
|
||||||
validationModel: state.validationModel,
|
validationModel: state.validationModel,
|
||||||
phaseModels: state.phaseModels,
|
phaseModels: state.phaseModels,
|
||||||
|
enabledDynamicModelIds: state.enabledDynamicModelIds,
|
||||||
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
||||||
skipSandboxWarning: state.skipSandboxWarning,
|
skipSandboxWarning: state.skipSandboxWarning,
|
||||||
keyboardShortcuts: state.keyboardShortcuts,
|
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 { useSetupStore } from '@/store/setup-store';
|
||||||
import { useAuthStore } from '@/store/auth-store';
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration';
|
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');
|
const logger = createLogger('SettingsSync');
|
||||||
|
|
||||||
@@ -44,9 +48,13 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
|||||||
'phaseModels',
|
'phaseModels',
|
||||||
'enabledCursorModels',
|
'enabledCursorModels',
|
||||||
'cursorDefaultModel',
|
'cursorDefaultModel',
|
||||||
|
'enabledOpencodeModels',
|
||||||
|
'opencodeDefaultModel',
|
||||||
|
'enabledDynamicModelIds',
|
||||||
'autoLoadClaudeMd',
|
'autoLoadClaudeMd',
|
||||||
'keyboardShortcuts',
|
'keyboardShortcuts',
|
||||||
'mcpServers',
|
'mcpServers',
|
||||||
|
'defaultEditorCommand',
|
||||||
'promptCustomization',
|
'promptCustomization',
|
||||||
'projects',
|
'projects',
|
||||||
'trashedProjects',
|
'trashedProjects',
|
||||||
@@ -89,6 +97,7 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
|
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
const authChecked = useAuthStore((s) => s.authChecked);
|
const authChecked = useAuthStore((s) => s.authChecked);
|
||||||
|
const settingsLoaded = useAuthStore((s) => s.settingsLoaded);
|
||||||
|
|
||||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastSyncedRef = useRef<string>('');
|
const lastSyncedRef = useRef<string>('');
|
||||||
@@ -117,9 +126,17 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
// Debounced sync function
|
// Debounced sync function
|
||||||
const syncToServer = useCallback(async () => {
|
const syncToServer = useCallback(async () => {
|
||||||
try {
|
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();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +144,8 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
const api = getHttpApiClient();
|
const api = getHttpApiClient();
|
||||||
const appState = useAppStore.getState();
|
const appState = useAppStore.getState();
|
||||||
|
|
||||||
|
logger.debug('Syncing to server:', { projectsCount: appState.projects?.length ?? 0 });
|
||||||
|
|
||||||
// Build updates object from current state
|
// Build updates object from current state
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
for (const field of SETTINGS_FIELDS_TO_SYNC) {
|
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
|
// Create a hash of the updates to avoid redundant syncs
|
||||||
const updateHash = JSON.stringify(updates);
|
const updateHash = JSON.stringify(updates);
|
||||||
if (updateHash === lastSyncedRef.current) {
|
if (updateHash === lastSyncedRef.current) {
|
||||||
|
logger.debug('Sync skipped: no changes');
|
||||||
setState((s) => ({ ...s, syncing: false }));
|
setState((s) => ({ ...s, syncing: false }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('Sending settings update:', { projects: updates.projects });
|
||||||
|
|
||||||
const result = await api.settings.updateGlobal(updates);
|
const result = await api.settings.updateGlobal(updates);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
lastSyncedRef.current = updateHash;
|
lastSyncedRef.current = updateHash;
|
||||||
@@ -184,11 +206,20 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
void syncToServer();
|
void syncToServer();
|
||||||
}, [syncToServer]);
|
}, [syncToServer]);
|
||||||
|
|
||||||
// Initialize sync - WAIT for migration to complete first
|
// Initialize sync - WAIT for settings to be loaded and migration to complete
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Don't initialize syncing until we know auth status and are authenticated.
|
// Don't initialize syncing until:
|
||||||
// Prevents accidental overwrites when the app boots before settings are hydrated.
|
// 1. Auth has been checked
|
||||||
if (!authChecked || !isAuthenticated) return;
|
// 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;
|
if (isInitializedRef.current) return;
|
||||||
isInitializedRef.current = true;
|
isInitializedRef.current = true;
|
||||||
|
|
||||||
@@ -198,14 +229,26 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
await waitForApiKeyInit();
|
await waitForApiKeyInit();
|
||||||
|
|
||||||
// CRITICAL: Wait for migration/hydration to complete before we start syncing
|
// 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...');
|
logger.info('Waiting for migration to complete before starting sync...');
|
||||||
await waitForMigrationComplete();
|
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');
|
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
|
// Store the initial state hash to avoid immediate re-sync
|
||||||
// (migration has already hydrated the store from server/localStorage)
|
// (migration has already hydrated the store from server/localStorage)
|
||||||
const appState = useAppStore.getState();
|
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
for (const field of SETTINGS_FIELDS_TO_SYNC) {
|
for (const field of SETTINGS_FIELDS_TO_SYNC) {
|
||||||
if (field === 'currentProjectId') {
|
if (field === 'currentProjectId') {
|
||||||
@@ -214,7 +257,6 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
updates[field] = appState[field as keyof typeof appState];
|
updates[field] = appState[field as keyof typeof appState];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const setupState = useSetupStore.getState();
|
|
||||||
for (const field of SETUP_FIELDS_TO_SYNC) {
|
for (const field of SETUP_FIELDS_TO_SYNC) {
|
||||||
updates[field] = setupState[field as keyof typeof setupState];
|
updates[field] = setupState[field as keyof typeof setupState];
|
||||||
}
|
}
|
||||||
@@ -233,16 +275,33 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initializeSync();
|
initializeSync();
|
||||||
}, [authChecked, isAuthenticated]);
|
}, [authChecked, isAuthenticated, settingsLoaded]);
|
||||||
|
|
||||||
// Subscribe to store changes and sync to server
|
// Subscribe to store changes and sync to server
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.loaded || !authChecked || !isAuthenticated) return;
|
if (!state.loaded || !authChecked || !isAuthenticated || !settingsLoaded) return;
|
||||||
|
|
||||||
// Subscribe to app store changes
|
// Subscribe to app store changes
|
||||||
const unsubscribeApp = useAppStore.subscribe((newState, prevState) => {
|
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 the current project changed, sync immediately so we can restore on next launch
|
||||||
if (newState.currentProject?.id !== prevState.currentProject?.id) {
|
if (newState.currentProject?.id !== prevState.currentProject?.id) {
|
||||||
|
logger.debug('Current project changed, syncing immediately');
|
||||||
syncNow();
|
syncNow();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -266,6 +325,7 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
|
logger.debug('Store changed, scheduling sync');
|
||||||
scheduleSyncToServer();
|
scheduleSyncToServer();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -294,11 +354,11 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
clearTimeout(syncTimeoutRef.current);
|
clearTimeout(syncTimeoutRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [state.loaded, authChecked, isAuthenticated, scheduleSyncToServer, syncNow]);
|
}, [state.loaded, authChecked, isAuthenticated, settingsLoaded, scheduleSyncToServer, syncNow]);
|
||||||
|
|
||||||
// Best-effort flush on tab close / backgrounding
|
// Best-effort flush on tab close / backgrounding
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.loaded || !authChecked || !isAuthenticated) return;
|
if (!state.loaded || !authChecked || !isAuthenticated || !settingsLoaded) return;
|
||||||
|
|
||||||
const handleBeforeUnload = () => {
|
const handleBeforeUnload = () => {
|
||||||
// Fire-and-forget; may not complete in all browsers, but helps in Electron/webview
|
// 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);
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
};
|
};
|
||||||
}, [state.loaded, authChecked, isAuthenticated, syncNow]);
|
}, [state.loaded, authChecked, isAuthenticated, settingsLoaded, syncNow]);
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -368,6 +428,27 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
|
|
||||||
const serverSettings = result.settings as unknown as GlobalSettings;
|
const serverSettings = result.settings as unknown as GlobalSettings;
|
||||||
const currentAppState = useAppStore.getState();
|
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
|
// Save theme to localStorage for fallback when server settings aren't available
|
||||||
if (serverSettings.theme) {
|
if (serverSettings.theme) {
|
||||||
@@ -391,6 +472,9 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
phaseModels: serverSettings.phaseModels,
|
phaseModels: serverSettings.phaseModels,
|
||||||
enabledCursorModels: serverSettings.enabledCursorModels,
|
enabledCursorModels: serverSettings.enabledCursorModels,
|
||||||
cursorDefaultModel: serverSettings.cursorDefaultModel,
|
cursorDefaultModel: serverSettings.cursorDefaultModel,
|
||||||
|
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
|
||||||
|
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
|
||||||
|
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
||||||
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,
|
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,
|
||||||
keyboardShortcuts: {
|
keyboardShortcuts: {
|
||||||
...currentAppState.keyboardShortcuts,
|
...currentAppState.keyboardShortcuts,
|
||||||
@@ -399,6 +483,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
>),
|
>),
|
||||||
},
|
},
|
||||||
mcpServers: serverSettings.mcpServers,
|
mcpServers: serverSettings.mcpServers,
|
||||||
|
defaultEditorCommand: serverSettings.defaultEditorCommand ?? null,
|
||||||
promptCustomization: serverSettings.promptCustomization ?? {},
|
promptCustomization: serverSettings.promptCustomization ?? {},
|
||||||
projects: serverSettings.projects,
|
projects: serverSettings.projects,
|
||||||
trashedProjects: serverSettings.trashedProjects,
|
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
|
// Server URL - uses shared cached URL from http-api-client
|
||||||
const getServerUrl = (): string => getServerUrlSync();
|
const getServerUrl = (): string => getServerUrlSync();
|
||||||
|
const DEFAULT_CACHE_MODE: RequestCache = 'no-store';
|
||||||
|
|
||||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@ export async function apiFetch(
|
|||||||
method: HttpMethod = 'GET',
|
method: HttpMethod = 'GET',
|
||||||
options: ApiFetchOptions = {}
|
options: ApiFetchOptions = {}
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const { headers: additionalHeaders, body, skipAuth, ...restOptions } = options;
|
const { headers: additionalHeaders, body, skipAuth, cache, ...restOptions } = options;
|
||||||
|
|
||||||
const headers = skipAuth
|
const headers = skipAuth
|
||||||
? { 'Content-Type': 'application/json', ...additionalHeaders }
|
? { 'Content-Type': 'application/json', ...additionalHeaders }
|
||||||
@@ -90,6 +91,7 @@ export async function apiFetch(
|
|||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
cache: cache ?? DEFAULT_CACHE_MODE,
|
||||||
...restOptions,
|
...restOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -462,7 +462,8 @@ export interface FeaturesAPI {
|
|||||||
featureId: string,
|
featureId: string,
|
||||||
updates: Partial<Feature>,
|
updates: Partial<Feature>,
|
||||||
descriptionHistorySource?: 'enhance' | 'edit',
|
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 }>;
|
) => Promise<{ success: boolean; feature?: Feature; error?: string }>;
|
||||||
delete: (projectPath: string, featureId: string) => Promise<{ success: boolean; error?: string }>;
|
delete: (projectPath: string, featureId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
getAgentOutput: (
|
getAgentOutput: (
|
||||||
@@ -612,7 +613,8 @@ export interface ElectronAPI {
|
|||||||
enhance: (
|
enhance: (
|
||||||
originalText: string,
|
originalText: string,
|
||||||
enhancementMode: string,
|
enhancementMode: string,
|
||||||
model?: string
|
model?: string,
|
||||||
|
thinkingLevel?: string
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
enhancedText?: string;
|
enhancedText?: string;
|
||||||
@@ -649,7 +651,8 @@ export interface ElectronAPI {
|
|||||||
removedDependencies: string[];
|
removedDependencies: string[];
|
||||||
addedDependencies: string[];
|
addedDependencies: string[];
|
||||||
}>;
|
}>;
|
||||||
}
|
},
|
||||||
|
branchName?: string
|
||||||
) => Promise<{ success: boolean; appliedChanges?: string[]; error?: string }>;
|
) => Promise<{ success: boolean; appliedChanges?: string[]; error?: string }>;
|
||||||
onEvent: (callback: (data: unknown) => void) => () => void;
|
onEvent: (callback: (data: unknown) => void) => () => void;
|
||||||
};
|
};
|
||||||
@@ -1643,13 +1646,34 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
openInEditor: async (worktreePath: string) => {
|
openInEditor: async (worktreePath: string, editorCommand?: string) => {
|
||||||
console.log('[Mock] Opening in editor:', worktreePath);
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
message: `Opened ${worktreePath} in VS Code`,
|
message: `Opened ${worktreePath} in ${editorName}`,
|
||||||
editorName: 'VS Code',
|
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) => {
|
initGit: async (projectPath: string) => {
|
||||||
console.log('[Mock] Initializing git:', projectPath);
|
console.log('[Mock] Initializing git:', projectPath);
|
||||||
return {
|
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';
|
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
|
||||||
|
|
||||||
const logger = createLogger('HttpClient');
|
const logger = createLogger('HttpClient');
|
||||||
|
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||||
|
|
||||||
// Cached server URL (set during initialization in Electron mode)
|
// Cached server URL (set during initialization in Electron mode)
|
||||||
let cachedServerUrl: string | null = null;
|
let cachedServerUrl: string | null = null;
|
||||||
@@ -69,6 +70,7 @@ const handleUnauthorized = (): void => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: '{}',
|
body: '{}',
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
notifyLoggedOut();
|
notifyLoggedOut();
|
||||||
};
|
};
|
||||||
@@ -296,6 +298,7 @@ export const checkAuthStatus = async (): Promise<{
|
|||||||
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
|
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: getApiKey() ? { 'X-API-Key': getApiKey()! } : undefined,
|
headers: getApiKey() ? { 'X-API-Key': getApiKey()! } : undefined,
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return {
|
return {
|
||||||
@@ -322,6 +325,7 @@ export const login = async (
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ apiKey }),
|
body: JSON.stringify({ apiKey }),
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
@@ -361,6 +365,7 @@ export const fetchSessionToken = async (): Promise<boolean> => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
|
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
|
||||||
credentials: 'include', // Send the session cookie
|
credentials: 'include', // Send the session cookie
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -391,6 +396,7 @@ export const logout = async (): Promise<{ success: boolean }> => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear the cached session token
|
// Clear the cached session token
|
||||||
@@ -439,6 +445,7 @@ export const verifySession = async (): Promise<boolean> => {
|
|||||||
const response = await fetch(`${getServerUrl()}/api/settings/status`, {
|
const response = await fetch(`${getServerUrl()}/api/settings/status`, {
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
// Avoid hanging indefinitely during backend reloads or network issues
|
// Avoid hanging indefinitely during backend reloads or network issues
|
||||||
signal: AbortSignal.timeout(2500),
|
signal: AbortSignal.timeout(2500),
|
||||||
});
|
});
|
||||||
@@ -475,6 +482,7 @@ export const checkSandboxEnvironment = async (): Promise<{
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`${getServerUrl()}/api/health/environment`, {
|
const response = await fetch(`${getServerUrl()}/api/health/environment`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -499,7 +507,10 @@ type EventType =
|
|||||||
| 'issue-validation:event'
|
| 'issue-validation:event'
|
||||||
| 'backlog-plan:event'
|
| 'backlog-plan:event'
|
||||||
| 'ideation:stream'
|
| 'ideation:stream'
|
||||||
| 'ideation:analysis';
|
| 'ideation:analysis'
|
||||||
|
| 'worktree:init-started'
|
||||||
|
| 'worktree:init-output'
|
||||||
|
| 'worktree:init-completed';
|
||||||
|
|
||||||
type EventCallback = (payload: unknown) => void;
|
type EventCallback = (payload: unknown) => void;
|
||||||
|
|
||||||
@@ -556,6 +567,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
const response = await fetch(`${this.serverUrl}/api/auth/token`, {
|
const response = await fetch(`${this.serverUrl}/api/auth/token`, {
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
@@ -587,6 +599,17 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
|
|
||||||
this.isConnecting = true;
|
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.
|
// Electron mode typically authenticates with the injected API key.
|
||||||
// However, in external-server/cookie-auth flows, the API key may be unavailable.
|
// 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
|
// 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}`, {
|
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
credentials: 'include', // Include cookies for session auth
|
credentials: 'include', // Include cookies for session auth
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
@@ -825,13 +849,14 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
return response.json();
|
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
|
// Ensure API key is initialized before making request
|
||||||
await waitForApiKeyInit();
|
await waitForApiKeyInit();
|
||||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
credentials: 'include', // Include cookies for session auth
|
credentials: 'include', // Include cookies for session auth
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
@@ -1415,6 +1440,67 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> => this.get('/api/setup/opencode-status'),
|
}> => 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) => {
|
onInstallProgress: (callback: (progress: unknown) => void) => {
|
||||||
return this.subscribeToEvent('agent:stream', callback);
|
return this.subscribeToEvent('agent:stream', callback);
|
||||||
},
|
},
|
||||||
@@ -1459,7 +1545,8 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
featureId: string,
|
featureId: string,
|
||||||
updates: Partial<Feature>,
|
updates: Partial<Feature>,
|
||||||
descriptionHistorySource?: 'enhance' | 'edit',
|
descriptionHistorySource?: 'enhance' | 'edit',
|
||||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||||
|
preEnhancementDescription?: string
|
||||||
) =>
|
) =>
|
||||||
this.post('/api/features/update', {
|
this.post('/api/features/update', {
|
||||||
projectPath,
|
projectPath,
|
||||||
@@ -1467,6 +1554,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
updates,
|
updates,
|
||||||
descriptionHistorySource,
|
descriptionHistorySource,
|
||||||
enhancementMode,
|
enhancementMode,
|
||||||
|
preEnhancementDescription,
|
||||||
}),
|
}),
|
||||||
delete: (projectPath: string, featureId: string) =>
|
delete: (projectPath: string, featureId: string) =>
|
||||||
this.post('/api/features/delete', { projectPath, featureId }),
|
this.post('/api/features/delete', { projectPath, featureId }),
|
||||||
@@ -1612,9 +1700,11 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.post('/api/worktree/list-branches', { worktreePath }),
|
this.post('/api/worktree/list-branches', { worktreePath }),
|
||||||
switchBranch: (worktreePath: string, branchName: string) =>
|
switchBranch: (worktreePath: string, branchName: string) =>
|
||||||
this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
|
this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
|
||||||
openInEditor: (worktreePath: string) =>
|
openInEditor: (worktreePath: string, editorCommand?: string) =>
|
||||||
this.post('/api/worktree/open-in-editor', { worktreePath }),
|
this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }),
|
||||||
getDefaultEditor: () => this.get('/api/worktree/default-editor'),
|
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 }),
|
initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }),
|
||||||
startDevServer: (projectPath: string, worktreePath: string) =>
|
startDevServer: (projectPath: string, worktreePath: string) =>
|
||||||
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
|
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', {}),
|
listDevServers: () => this.post('/api/worktree/list-dev-servers', {}),
|
||||||
getPRInfo: (worktreePath: string, branchName: string) =>
|
getPRInfo: (worktreePath: string, branchName: string) =>
|
||||||
this.post('/api/worktree/pr-info', { worktreePath, branchName }),
|
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
|
// Git API
|
||||||
@@ -2142,9 +2263,10 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
removedDependencies: string[];
|
removedDependencies: string[];
|
||||||
addedDependencies: string[];
|
addedDependencies: string[];
|
||||||
}>;
|
}>;
|
||||||
}
|
},
|
||||||
|
branchName?: string
|
||||||
): Promise<{ success: boolean; appliedChanges?: string[]; error?: 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) => {
|
onEvent: (callback: (data: unknown) => void): (() => void) => {
|
||||||
return this.subscribeToEvent('backlog-plan:event', callback as EventCallback);
|
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 { useAuthStore } from '@/store/auth-store';
|
||||||
import { getElectronAPI, isElectron } from '@/lib/electron';
|
import { getElectronAPI, isElectron } from '@/lib/electron';
|
||||||
import { isMac } from '@/lib/utils';
|
import { isMac } from '@/lib/utils';
|
||||||
|
import { initializeProject } from '@/lib/project-init';
|
||||||
import {
|
import {
|
||||||
initApiKey,
|
initApiKey,
|
||||||
verifySession,
|
verifySession,
|
||||||
checkSandboxEnvironment,
|
checkSandboxEnvironment,
|
||||||
getServerUrlSync,
|
getServerUrlSync,
|
||||||
getHttpApiClient,
|
getHttpApiClient,
|
||||||
|
handleServerOffline,
|
||||||
} from '@/lib/http-api-client';
|
} from '@/lib/http-api-client';
|
||||||
import {
|
import {
|
||||||
hydrateStoreFromSettings,
|
hydrateStoreFromSettings,
|
||||||
@@ -25,13 +27,29 @@ import {
|
|||||||
performSettingsMigration,
|
performSettingsMigration,
|
||||||
} from '@/hooks/use-settings-migration';
|
} from '@/hooks/use-settings-migration';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
|
import { Menu } from 'lucide-react';
|
||||||
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
||||||
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
|
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
|
||||||
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
|
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
|
||||||
import { LoadingState } from '@/components/ui/loading-state';
|
import { LoadingState } from '@/components/ui/loading-state';
|
||||||
import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader';
|
import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader';
|
||||||
|
import type { Project } from '@/lib/electron';
|
||||||
|
|
||||||
const logger = createLogger('RootLayout');
|
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)
|
// Apply stored theme immediately on page load (before React hydration)
|
||||||
// This prevents flash of default theme on login/setup pages
|
// 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)
|
// Apply stored theme immediately (runs synchronously before render)
|
||||||
applyStoredTheme();
|
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() {
|
function RootLayoutContent() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const {
|
const {
|
||||||
setIpcConnected,
|
setIpcConnected,
|
||||||
|
projects,
|
||||||
currentProject,
|
currentProject,
|
||||||
|
projectHistory,
|
||||||
|
upsertAndSetCurrentProject,
|
||||||
getEffectiveTheme,
|
getEffectiveTheme,
|
||||||
skipSandboxWarning,
|
skipSandboxWarning,
|
||||||
setSkipSandboxWarning,
|
setSkipSandboxWarning,
|
||||||
fetchCodexModels,
|
fetchCodexModels,
|
||||||
|
sidebarOpen,
|
||||||
|
toggleSidebar,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const { setupComplete, codexCliStatus } = useSetupStore();
|
const { setupComplete, codexCliStatus } = useSetupStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -76,6 +169,7 @@ function RootLayoutContent() {
|
|||||||
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
|
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
|
||||||
const authChecked = useAuthStore((s) => s.authChecked);
|
const authChecked = useAuthStore((s) => s.authChecked);
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
const settingsLoaded = useAuthStore((s) => s.settingsLoaded);
|
||||||
const { openFileBrowser } = useFileBrowser();
|
const { openFileBrowser } = useFileBrowser();
|
||||||
|
|
||||||
// Load project settings when switching projects
|
// Load project settings when switching projects
|
||||||
@@ -85,6 +179,22 @@ function RootLayoutContent() {
|
|||||||
const isLoginRoute = location.pathname === '/login';
|
const isLoginRoute = location.pathname === '/login';
|
||||||
const isLoggedOutRoute = location.pathname === '/logged-out';
|
const isLoggedOutRoute = location.pathname === '/logged-out';
|
||||||
const isDashboardRoute = location.pathname === '/dashboard';
|
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
|
// Sandbox environment check state
|
||||||
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
|
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
|
||||||
@@ -218,9 +328,11 @@ function RootLayoutContent() {
|
|||||||
// Works for ALL modes (unified flow)
|
// Works for ALL modes (unified flow)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleLoggedOut = () => {
|
const handleLoggedOut = () => {
|
||||||
|
logger.warn('automaker:logged-out event received!');
|
||||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
|
|
||||||
if (location.pathname !== '/logged-out') {
|
if (location.pathname !== '/logged-out') {
|
||||||
|
logger.warn('Navigating to /logged-out due to logged-out event');
|
||||||
navigate({ to: '/logged-out' });
|
navigate({ to: '/logged-out' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -236,6 +348,7 @@ function RootLayoutContent() {
|
|||||||
// Redirects to login page which will detect server is offline and show error UI.
|
// Redirects to login page which will detect server is offline and show error UI.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleServerOffline = () => {
|
const handleServerOffline = () => {
|
||||||
|
logger.warn('automaker:server-offline event received!');
|
||||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
|
|
||||||
// Navigate to login - the login page will detect server is offline and show appropriate UI
|
// 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
|
// Initialize API key for Electron mode
|
||||||
await initApiKey();
|
await initApiKey();
|
||||||
|
|
||||||
|
const serverReady = await waitForServerReady();
|
||||||
|
if (!serverReady) {
|
||||||
|
handleServerOffline();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Verify session (Single Request, ALL modes)
|
// 1. Verify session (Single Request, ALL modes)
|
||||||
let isValid = false;
|
let isValid = false;
|
||||||
try {
|
try {
|
||||||
@@ -302,13 +421,28 @@ function RootLayoutContent() {
|
|||||||
// Hydrate store with the final settings (merged if migration occurred)
|
// Hydrate store with the final settings (merged if migration occurred)
|
||||||
hydrateStoreFromSettings(finalSettings);
|
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();
|
signalMigrationComplete();
|
||||||
|
|
||||||
// Mark auth as checked only after settings hydration succeeded.
|
// Now mark auth as checked AND settings as loaded.
|
||||||
useAuthStore
|
// The settingsLoaded flag ensures useSettingsSync won't start syncing
|
||||||
.getState()
|
// until settings have been properly hydrated, even if authChecked was
|
||||||
.setAuthState({ isAuthenticated: true, authChecked: true });
|
// set earlier by login-view.
|
||||||
|
useAuthStore.getState().setAuthState({
|
||||||
|
isAuthenticated: true,
|
||||||
|
authChecked: true,
|
||||||
|
settingsLoaded: true,
|
||||||
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,17 +507,38 @@ function RootLayoutContent() {
|
|||||||
// - If authenticated but setup incomplete: force /setup
|
// - If authenticated but setup incomplete: force /setup
|
||||||
// - If authenticated and setup complete: allow access to app
|
// - If authenticated and setup complete: allow access to app
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
logger.debug('Routing effect triggered:', {
|
||||||
|
authChecked,
|
||||||
|
isAuthenticated,
|
||||||
|
settingsLoaded,
|
||||||
|
setupComplete,
|
||||||
|
pathname: location.pathname,
|
||||||
|
});
|
||||||
|
|
||||||
// Wait for auth check to complete before enforcing any redirects
|
// 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)
|
// Unauthenticated -> force /logged-out (but allow /login so user can authenticate)
|
||||||
if (!isAuthenticated) {
|
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') {
|
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
|
||||||
navigate({ to: '/logged-out' });
|
navigate({ to: '/logged-out' });
|
||||||
}
|
}
|
||||||
return;
|
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
|
// Authenticated -> determine whether setup is required
|
||||||
if (!setupComplete && location.pathname !== '/setup') {
|
if (!setupComplete && location.pathname !== '/setup') {
|
||||||
navigate({ to: '/setup' });
|
navigate({ to: '/setup' });
|
||||||
@@ -394,7 +549,46 @@ function RootLayoutContent() {
|
|||||||
if (setupComplete && location.pathname === '/setup') {
|
if (setupComplete && location.pathname === '/setup') {
|
||||||
navigate({ to: '/dashboard' });
|
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(() => {
|
useEffect(() => {
|
||||||
setGlobalFileBrowser(openFileBrowser);
|
setGlobalFileBrowser(openFileBrowser);
|
||||||
@@ -428,7 +622,10 @@ function RootLayoutContent() {
|
|||||||
|
|
||||||
// Redirect from welcome page based on project state
|
// Redirect from welcome page based on project state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMounted && location.pathname === '/') {
|
if (isMounted && isRootRoute) {
|
||||||
|
if (!settingsLoaded || shouldAutoOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (currentProject) {
|
if (currentProject) {
|
||||||
// Project is selected, go to board
|
// Project is selected, go to board
|
||||||
navigate({ to: '/board' });
|
navigate({ to: '/board' });
|
||||||
@@ -437,14 +634,66 @@ function RootLayoutContent() {
|
|||||||
navigate({ to: '/dashboard' });
|
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)
|
// Bootstrap Codex models on app startup (after auth completes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only fetch if authenticated and Codex CLI is available
|
// Only fetch if authenticated and Codex CLI is available
|
||||||
if (!authChecked || !isAuthenticated) return;
|
if (!authChecked || !isAuthenticated) return;
|
||||||
|
|
||||||
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated;
|
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.hasApiKey;
|
||||||
if (!isCodexAvailable) return;
|
if (!isCodexAvailable) return;
|
||||||
|
|
||||||
// Fetch models in the background
|
// 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
|
// Show setup page (full screen, no sidebar) - authenticated only
|
||||||
if (isSetupRoute) {
|
if (isSetupRoute) {
|
||||||
return (
|
return (
|
||||||
@@ -549,6 +814,16 @@ function RootLayoutContent() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Sidebar />
|
<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
|
<div
|
||||||
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
|
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
|
||||||
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}
|
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
PipelineConfig,
|
PipelineConfig,
|
||||||
PipelineStep,
|
PipelineStep,
|
||||||
PromptCustomization,
|
PromptCustomization,
|
||||||
|
ModelDefinition,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
getAllCursorModelIds,
|
getAllCursorModelIds,
|
||||||
@@ -33,6 +34,8 @@ import {
|
|||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
|
|
||||||
const logger = createLogger('AppStore');
|
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
|
// Re-export types for convenience
|
||||||
export type {
|
export type {
|
||||||
@@ -80,6 +83,9 @@ export type ThemeMode =
|
|||||||
// LocalStorage key for theme persistence (fallback when server settings aren't available)
|
// LocalStorage key for theme persistence (fallback when server settings aren't available)
|
||||||
export const THEME_STORAGE_KEY = 'automaker:theme';
|
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
|
* Get the theme from localStorage as a fallback
|
||||||
* Used before server settings are loaded (e.g., on login/setup pages)
|
* 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);
|
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 type BoardViewMode = 'kanban' | 'graph';
|
||||||
|
|
||||||
export interface ApiKeys {
|
export interface ApiKeys {
|
||||||
@@ -463,6 +475,14 @@ export interface PersistedTerminalSettings {
|
|||||||
maxSessions: number;
|
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 {
|
export interface AppState {
|
||||||
// Project state
|
// Project state
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
@@ -516,6 +536,8 @@ export interface AppState {
|
|||||||
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
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)
|
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)
|
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
|
// Worktree Settings
|
||||||
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: true)
|
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: true)
|
||||||
@@ -564,8 +586,19 @@ export interface AppState {
|
|||||||
codexEnableImages: boolean; // Enable image processing
|
codexEnableImages: boolean; // Enable image processing
|
||||||
|
|
||||||
// OpenCode CLI Settings (global)
|
// 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
|
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
|
// Claude Agent SDK Settings
|
||||||
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
|
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
|
||||||
@@ -574,6 +607,9 @@ export interface AppState {
|
|||||||
// MCP Servers
|
// MCP Servers
|
||||||
mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use
|
mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use
|
||||||
|
|
||||||
|
// Editor Configuration
|
||||||
|
defaultEditorCommand: string | null; // Default editor for "Open In" action
|
||||||
|
|
||||||
// Skills Configuration
|
// Skills Configuration
|
||||||
enableSkills: boolean; // Enable Skills functionality (loads from .claude/skills/ directories)
|
enableSkills: boolean; // Enable Skills functionality (loads from .claude/skills/ directories)
|
||||||
skillsSources: Array<'user' | 'project'>; // Which directories to load Skills from
|
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)
|
// Whether the worktree panel row is visible (default: true)
|
||||||
worktreePanelVisibleByProject: Record<string, boolean>;
|
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)
|
// UI State (previously in localStorage, now synced via API)
|
||||||
/** Whether worktree panel is collapsed in board view */
|
/** Whether worktree panel is collapsed in board view */
|
||||||
worktreePanelCollapsed: boolean;
|
worktreePanelCollapsed: boolean;
|
||||||
@@ -668,6 +716,9 @@ export interface AppState {
|
|||||||
lastProjectDir: string;
|
lastProjectDir: string;
|
||||||
/** Recently accessed folders for quick access */
|
/** Recently accessed folders for quick access */
|
||||||
recentFolders: string[];
|
recentFolders: string[];
|
||||||
|
|
||||||
|
// Init Script State (keyed by "projectPath::branch" to support concurrent scripts)
|
||||||
|
initScriptState: Record<string, InitScriptState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claude Usage interface matching the server response
|
// Claude Usage interface matching the server response
|
||||||
@@ -881,6 +932,8 @@ export interface AppActions {
|
|||||||
setDefaultSkipTests: (skip: boolean) => void;
|
setDefaultSkipTests: (skip: boolean) => void;
|
||||||
setEnableDependencyBlocking: (enabled: boolean) => void;
|
setEnableDependencyBlocking: (enabled: boolean) => void;
|
||||||
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
|
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
|
||||||
|
setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
||||||
|
setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
||||||
|
|
||||||
// Worktree Settings actions
|
// Worktree Settings actions
|
||||||
setUseWorktrees: (enabled: boolean) => void;
|
setUseWorktrees: (enabled: boolean) => void;
|
||||||
@@ -949,11 +1002,20 @@ export interface AppActions {
|
|||||||
setEnabledOpencodeModels: (models: OpencodeModelId[]) => void;
|
setEnabledOpencodeModels: (models: OpencodeModelId[]) => void;
|
||||||
setOpencodeDefaultModel: (model: OpencodeModelId) => void;
|
setOpencodeDefaultModel: (model: OpencodeModelId) => void;
|
||||||
toggleOpencodeModel: (model: OpencodeModelId, enabled: boolean) => 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
|
// Claude Agent SDK Settings actions
|
||||||
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
||||||
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
|
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
|
||||||
|
|
||||||
|
// Editor Configuration actions
|
||||||
|
setDefaultEditorCommand: (command: string | null) => void;
|
||||||
|
|
||||||
// Prompt Customization actions
|
// Prompt Customization actions
|
||||||
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
|
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
|
||||||
|
|
||||||
@@ -1071,6 +1133,18 @@ export interface AppActions {
|
|||||||
setWorktreePanelVisible: (projectPath: string, visible: boolean) => void;
|
setWorktreePanelVisible: (projectPath: string, visible: boolean) => void;
|
||||||
getWorktreePanelVisible: (projectPath: string) => boolean;
|
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)
|
// UI State actions (previously in localStorage, now synced via API)
|
||||||
setWorktreePanelCollapsed: (collapsed: boolean) => void;
|
setWorktreePanelCollapsed: (collapsed: boolean) => void;
|
||||||
setLastProjectDir: (dir: string) => void;
|
setLastProjectDir: (dir: string) => void;
|
||||||
@@ -1099,6 +1173,19 @@ export interface AppActions {
|
|||||||
}>
|
}>
|
||||||
) => void;
|
) => 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
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@@ -1131,6 +1218,8 @@ const initialState: AppState = {
|
|||||||
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
||||||
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
||||||
skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified)
|
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)
|
useWorktrees: true, // Default to enabled (git worktree isolation)
|
||||||
currentWorktreeByProject: {},
|
currentWorktreeByProject: {},
|
||||||
worktreesByProject: {},
|
worktreesByProject: {},
|
||||||
@@ -1150,10 +1239,14 @@ const initialState: AppState = {
|
|||||||
codexEnableWebSearch: false, // Default to disabled
|
codexEnableWebSearch: false, // Default to disabled
|
||||||
codexEnableImages: false, // Default to disabled
|
codexEnableImages: false, // Default to disabled
|
||||||
enabledOpencodeModels: getAllOpencodeModelIds(), // All OpenCode models enabled by default
|
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)
|
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
||||||
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
|
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
|
||||||
mcpServers: [], // No MCP servers configured by default
|
mcpServers: [], // No MCP servers configured by default
|
||||||
|
defaultEditorCommand: null, // Auto-detect: Cursor > VS Code > first available
|
||||||
enableSkills: true, // Skills enabled by default
|
enableSkills: true, // Skills enabled by default
|
||||||
skillsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default
|
skillsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default
|
||||||
enableSubagents: true, // Subagents enabled by default
|
enableSubagents: true, // Subagents enabled by default
|
||||||
@@ -1195,10 +1288,14 @@ const initialState: AppState = {
|
|||||||
codexModelsLastFetched: null,
|
codexModelsLastFetched: null,
|
||||||
pipelineConfigByProject: {},
|
pipelineConfigByProject: {},
|
||||||
worktreePanelVisibleByProject: {},
|
worktreePanelVisibleByProject: {},
|
||||||
|
showInitScriptIndicatorByProject: {},
|
||||||
|
defaultDeleteBranchByProject: {},
|
||||||
|
autoDismissInitScriptIndicatorByProject: {},
|
||||||
// UI State (previously in localStorage, now synced via API)
|
// UI State (previously in localStorage, now synced via API)
|
||||||
worktreePanelCollapsed: false,
|
worktreePanelCollapsed: false,
|
||||||
lastProjectDir: '',
|
lastProjectDir: '',
|
||||||
recentFolders: [],
|
recentFolders: [],
|
||||||
|
initScriptState: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
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 isCurrent = get().currentProject?.id === projectId;
|
||||||
|
const nextCurrentProject = isCurrent ? null : get().currentProject;
|
||||||
|
|
||||||
set({
|
set({
|
||||||
projects: remainingProjects,
|
projects: remainingProjects,
|
||||||
trashedProjects: [trashedProject, ...existingTrash],
|
trashedProjects: [trashedProject, ...existingTrash],
|
||||||
currentProject: isCurrent ? null : get().currentProject,
|
currentProject: nextCurrentProject,
|
||||||
currentView: isCurrent ? 'welcome' : get().currentView,
|
currentView: isCurrent ? 'welcome' : get().currentView,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
persistEffectiveThemeForProject(nextCurrentProject, get().theme);
|
||||||
},
|
},
|
||||||
|
|
||||||
restoreTrashedProject: (projectId) => {
|
restoreTrashedProject: (projectId) => {
|
||||||
@@ -1266,6 +1366,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
currentProject: samePathProject,
|
currentProject: samePathProject,
|
||||||
currentView: 'board',
|
currentView: 'board',
|
||||||
});
|
});
|
||||||
|
persistEffectiveThemeForProject(samePathProject, get().theme);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1283,6 +1384,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
currentProject: restoredProject,
|
currentProject: restoredProject,
|
||||||
currentView: 'board',
|
currentView: 'board',
|
||||||
});
|
});
|
||||||
|
persistEffectiveThemeForProject(restoredProject, get().theme);
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteTrashedProject: (projectId) => {
|
deleteTrashedProject: (projectId) => {
|
||||||
@@ -1302,6 +1404,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
|
|
||||||
setCurrentProject: (project) => {
|
setCurrentProject: (project) => {
|
||||||
set({ currentProject: project });
|
set({ currentProject: project });
|
||||||
|
persistEffectiveThemeForProject(project, get().theme);
|
||||||
if (project) {
|
if (project) {
|
||||||
set({ currentView: 'board' });
|
set({ currentView: 'board' });
|
||||||
// Add to project history (MRU order)
|
// Add to project history (MRU order)
|
||||||
@@ -1385,6 +1488,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
projectHistoryIndex: newIndex,
|
projectHistoryIndex: newIndex,
|
||||||
currentView: 'board',
|
currentView: 'board',
|
||||||
});
|
});
|
||||||
|
persistEffectiveThemeForProject(targetProject, get().theme);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1418,6 +1522,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
projectHistoryIndex: newIndex,
|
projectHistoryIndex: newIndex,
|
||||||
currentView: 'board',
|
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
|
// Also update currentProject if it's the same project
|
||||||
const currentProject = get().currentProject;
|
const currentProject = get().currentProject;
|
||||||
if (currentProject?.id === projectId) {
|
if (currentProject?.id === projectId) {
|
||||||
|
const updatedTheme = theme === null ? undefined : theme;
|
||||||
set({
|
set({
|
||||||
currentProject: {
|
currentProject: {
|
||||||
...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');
|
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||||
await syncSettingsToServer();
|
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
|
// Worktree Settings actions
|
||||||
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
|
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
|
||||||
@@ -1909,6 +2040,34 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
? [...state.enabledOpencodeModels, model]
|
? [...state.enabledOpencodeModels, model]
|
||||||
: state.enabledOpencodeModels.filter((m) => m !== 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
|
// Claude Agent SDK Settings actions
|
||||||
setAutoLoadClaudeMd: async (enabled) => {
|
setAutoLoadClaudeMd: async (enabled) => {
|
||||||
@@ -1933,6 +2092,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
set({ skipSandboxWarning: previous });
|
set({ skipSandboxWarning: previous });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Editor Configuration actions
|
||||||
|
setDefaultEditorCommand: (command) => set({ defaultEditorCommand: command }),
|
||||||
// Prompt Customization actions
|
// Prompt Customization actions
|
||||||
setPromptCustomization: async (customization) => {
|
setPromptCustomization: async (customization) => {
|
||||||
set({ promptCustomization: customization });
|
set({ promptCustomization: customization });
|
||||||
@@ -3110,6 +3272,51 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
return get().worktreePanelVisibleByProject[projectPath] ?? true;
|
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)
|
// UI State actions (previously in localStorage, now synced via API)
|
||||||
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
|
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
|
||||||
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),
|
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),
|
||||||
@@ -3123,6 +3330,62 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
set({ recentFolders: updated });
|
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
|
||||||
reset: () => set(initialState),
|
reset: () => set(initialState),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ interface AuthState {
|
|||||||
authChecked: boolean;
|
authChecked: boolean;
|
||||||
/** Whether the user is currently authenticated (web mode: valid session cookie) */
|
/** Whether the user is currently authenticated (web mode: valid session cookie) */
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
|
/** Whether settings have been loaded and hydrated from server */
|
||||||
|
settingsLoaded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthActions {
|
interface AuthActions {
|
||||||
@@ -15,15 +17,18 @@ interface AuthActions {
|
|||||||
const initialState: AuthState = {
|
const initialState: AuthState = {
|
||||||
authChecked: false,
|
authChecked: false,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
settingsLoaded: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Web authentication state.
|
* 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) => ({
|
export const useAuthStore = create<AuthState & AuthActions>((set) => ({
|
||||||
...initialState,
|
...initialState,
|
||||||
setAuthState: (state) => set(state),
|
setAuthState: (state) => {
|
||||||
|
set({ ...state });
|
||||||
|
},
|
||||||
resetAuth: () => set(initialState),
|
resetAuth: () => set(initialState),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface CliStatus {
|
|||||||
path: string | null;
|
path: string | null;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
method: string;
|
method: string;
|
||||||
|
hasApiKey?: boolean;
|
||||||
error?: string;
|
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
|
// Open a worktree directory in the editor
|
||||||
openInEditor: (worktreePath: string) => Promise<{
|
openInEditor: (
|
||||||
|
worktreePath: string,
|
||||||
|
editorCommand?: string
|
||||||
|
) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
result?: {
|
result?: {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -903,6 +906,30 @@ export interface WorktreeAPI {
|
|||||||
error?: string;
|
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
|
// Initialize git repository in a project
|
||||||
initGit: (projectPath: string) => Promise<{
|
initGit: (projectPath: string) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -988,6 +1015,50 @@ export interface WorktreeAPI {
|
|||||||
};
|
};
|
||||||
error?: string;
|
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 {
|
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
|
// Use browser history in web mode (for e2e tests and dev), memory history in Electron
|
||||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined;
|
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined;
|
||||||
|
const BOARD_ROUTE_PATH = '/board';
|
||||||
|
|
||||||
const history = isElectron
|
const history = isElectron
|
||||||
? createMemoryHistory({ initialEntries: [window.location.pathname || '/'] })
|
? createMemoryHistory({ initialEntries: [BOARD_ROUTE_PATH] })
|
||||||
: createBrowserHistory();
|
: createBrowserHistory();
|
||||||
|
|
||||||
export const router = createRouter({
|
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;
|
return modelKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenCode model - pass through unchanged
|
// OpenCode model (static or dynamic) - pass through unchanged
|
||||||
// Supports: opencode/big-pickle, opencode-sonnet, amazon-bedrock/anthropic.claude-*
|
// 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)) {
|
if (isOpencodeModel(modelKey)) {
|
||||||
console.log(`[ModelResolver] Using OpenCode model: ${modelKey}`);
|
console.log(`[ModelResolver] Using OpenCode model: ${modelKey}`);
|
||||||
return 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,
|
getCodexCliPaths,
|
||||||
getCodexConfigDir,
|
getCodexConfigDir,
|
||||||
getCodexAuthPath,
|
getCodexAuthPath,
|
||||||
|
getGitBashPaths,
|
||||||
getOpenCodeCliPaths,
|
getOpenCodeCliPaths,
|
||||||
getOpenCodeConfigDir,
|
getOpenCodeConfigDir,
|
||||||
getOpenCodeAuthPath,
|
getOpenCodeAuthPath,
|
||||||
@@ -130,6 +131,7 @@ export {
|
|||||||
findCodexCliPath,
|
findCodexCliPath,
|
||||||
getCodexAuthIndicators,
|
getCodexAuthIndicators,
|
||||||
type CodexAuthIndicators,
|
type CodexAuthIndicators,
|
||||||
|
findGitBashPath,
|
||||||
findOpenCodeCliPath,
|
findOpenCodeCliPath,
|
||||||
getOpenCodeAuthIndicators,
|
getOpenCodeAuthIndicators,
|
||||||
type OpenCodeAuthIndicators,
|
type OpenCodeAuthIndicators,
|
||||||
@@ -157,3 +159,14 @@ export {
|
|||||||
|
|
||||||
// Port configuration
|
// Port configuration
|
||||||
export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './config/ports.js';
|
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');
|
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
|
* Get common shell paths for shell detection
|
||||||
* Includes both full paths and short names to match $SHELL or PATH entries
|
* Includes both full paths and short names to match $SHELL or PATH entries
|
||||||
@@ -550,6 +631,8 @@ function getAllAllowedSystemPaths(): string[] {
|
|||||||
getOpenCodeAuthPath(),
|
getOpenCodeAuthPath(),
|
||||||
// Shell paths
|
// Shell paths
|
||||||
...getShellPaths(),
|
...getShellPaths(),
|
||||||
|
// Git Bash paths (for Windows cross-platform shell script execution)
|
||||||
|
...getGitBashPaths(),
|
||||||
// Node.js system paths
|
// Node.js system paths
|
||||||
...getNodeSystemPaths(),
|
...getNodeSystemPaths(),
|
||||||
getScoopNodePath(),
|
getScoopNodePath(),
|
||||||
@@ -883,6 +966,13 @@ export async function findCodexCliPath(): Promise<string | null> {
|
|||||||
return findFirstExistingPath(getCodexCliPaths());
|
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
|
* Get Claude authentication status by checking various indicators
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ describe('enhancement.ts', () => {
|
|||||||
const modes = getAvailableEnhancementModes();
|
const modes = getAvailableEnhancementModes();
|
||||||
|
|
||||||
expect(Array.isArray(modes)).toBe(true);
|
expect(Array.isArray(modes)).toBe(true);
|
||||||
expect(modes.length).toBe(4);
|
expect(modes.length).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include all valid modes', () => {
|
it('should include all valid modes', () => {
|
||||||
@@ -409,6 +409,7 @@ describe('enhancement.ts', () => {
|
|||||||
expect(modes).toContain('technical');
|
expect(modes).toContain('technical');
|
||||||
expect(modes).toContain('simplify');
|
expect(modes).toContain('simplify');
|
||||||
expect(modes).toContain('acceptance');
|
expect(modes).toContain('acceptance');
|
||||||
|
expect(modes).toContain('ux-reviewer');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return modes in consistent order', () => {
|
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