mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-27 13:23:07 +00:00
Compare commits
6 Commits
feature/pu
...
dfa719079f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfa719079f | ||
|
|
28becb177b | ||
|
|
f785f1204b | ||
|
|
f3edfbf24e | ||
|
|
3ddf26f666 | ||
|
|
c81ea768a7 |
@@ -209,9 +209,10 @@ COPY libs ./libs
|
|||||||
COPY apps/ui ./apps/ui
|
COPY apps/ui ./apps/ui
|
||||||
|
|
||||||
# Build packages in dependency order, then build UI
|
# Build packages in dependency order, then build UI
|
||||||
# VITE_SERVER_URL tells the UI where to find the API server
|
# When VITE_SERVER_URL is empty, the UI uses relative URLs (e.g., /api/...) which nginx proxies
|
||||||
# Use ARG to allow overriding at build time: --build-arg VITE_SERVER_URL=http://api.example.com
|
# to the server container. This avoids CORS issues entirely in Docker Compose setups.
|
||||||
ARG VITE_SERVER_URL=http://localhost:3008
|
# Override at build time if needed: --build-arg VITE_SERVER_URL=http://api.example.com
|
||||||
|
ARG VITE_SERVER_URL=
|
||||||
ENV VITE_SKIP_ELECTRON=true
|
ENV VITE_SKIP_ELECTRON=true
|
||||||
ENV VITE_SERVER_URL=${VITE_SERVER_URL}
|
ENV VITE_SERVER_URL=${VITE_SERVER_URL}
|
||||||
RUN npm run build:packages && npm run build --workspace=apps/ui
|
RUN npm run build:packages && npm run build --workspace=apps/ui
|
||||||
|
|||||||
@@ -267,6 +267,26 @@ app.use(
|
|||||||
// CORS configuration
|
// CORS configuration
|
||||||
// When using credentials (cookies), origin cannot be '*'
|
// When using credentials (cookies), origin cannot be '*'
|
||||||
// We dynamically allow the requesting origin for local development
|
// We dynamically allow the requesting origin for local development
|
||||||
|
|
||||||
|
// Check if origin is a local/private network address
|
||||||
|
function isLocalOrigin(origin: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(origin);
|
||||||
|
const hostname = url.hostname;
|
||||||
|
return (
|
||||||
|
hostname === 'localhost' ||
|
||||||
|
hostname === '127.0.0.1' ||
|
||||||
|
hostname === '[::1]' ||
|
||||||
|
hostname === '0.0.0.0' ||
|
||||||
|
hostname.startsWith('192.168.') ||
|
||||||
|
hostname.startsWith('10.') ||
|
||||||
|
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: (origin, callback) => {
|
origin: (origin, callback) => {
|
||||||
@@ -277,35 +297,25 @@ app.use(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If CORS_ORIGIN is set, use it (can be comma-separated list)
|
// If CORS_ORIGIN is set, use it (can be comma-separated list)
|
||||||
const allowedOrigins = process.env.CORS_ORIGIN?.split(',').map((o) => o.trim());
|
const allowedOrigins = process.env.CORS_ORIGIN?.split(',')
|
||||||
if (allowedOrigins && allowedOrigins.length > 0 && allowedOrigins[0] !== '*') {
|
.map((o) => o.trim())
|
||||||
if (allowedOrigins.includes(origin)) {
|
.filter(Boolean);
|
||||||
callback(null, origin);
|
if (allowedOrigins && allowedOrigins.length > 0) {
|
||||||
} else {
|
if (allowedOrigins.includes('*')) {
|
||||||
callback(new Error('Not allowed by CORS'));
|
callback(null, true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return;
|
if (allowedOrigins.includes(origin)) {
|
||||||
}
|
|
||||||
|
|
||||||
// For local development, allow all localhost/loopback origins (any port)
|
|
||||||
try {
|
|
||||||
const url = new URL(origin);
|
|
||||||
const hostname = url.hostname;
|
|
||||||
|
|
||||||
if (
|
|
||||||
hostname === 'localhost' ||
|
|
||||||
hostname === '127.0.0.1' ||
|
|
||||||
hostname === '::1' ||
|
|
||||||
hostname === '0.0.0.0' ||
|
|
||||||
hostname.startsWith('192.168.') ||
|
|
||||||
hostname.startsWith('10.') ||
|
|
||||||
hostname.startsWith('172.')
|
|
||||||
) {
|
|
||||||
callback(null, origin);
|
callback(null, origin);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
// Fall through to local network check below
|
||||||
// Ignore URL parsing errors
|
}
|
||||||
|
|
||||||
|
// Allow all localhost/loopback/private network origins (any port)
|
||||||
|
if (isLocalOrigin(origin)) {
|
||||||
|
callback(null, origin);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject other origins by default for security
|
// Reject other origins by default for security
|
||||||
|
|||||||
37
apps/server/src/lib/exec-utils.ts
Normal file
37
apps/server/src/lib/exec-utils.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Shared execution utilities
|
||||||
|
*
|
||||||
|
* Common helpers for spawning child processes with the correct environment.
|
||||||
|
* Used by both route handlers and service layers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
|
const logger = createLogger('ExecUtils');
|
||||||
|
|
||||||
|
// Extended PATH to include common tool installation locations
|
||||||
|
export const extendedPath = [
|
||||||
|
process.env.PATH,
|
||||||
|
'/opt/homebrew/bin',
|
||||||
|
'/usr/local/bin',
|
||||||
|
'/home/linuxbrew/.linuxbrew/bin',
|
||||||
|
`${process.env.HOME}/.local/bin`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(':');
|
||||||
|
|
||||||
|
export const execEnv = {
|
||||||
|
...process.env,
|
||||||
|
PATH: extendedPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logError(error: unknown, context: string): void {
|
||||||
|
logger.error(`${context}:`, error);
|
||||||
|
}
|
||||||
@@ -367,6 +367,11 @@ export interface CreateSdkOptionsConfig {
|
|||||||
|
|
||||||
/** Extended thinking level for Claude models */
|
/** Extended thinking level for Claude models */
|
||||||
thinkingLevel?: ThinkingLevel;
|
thinkingLevel?: ThinkingLevel;
|
||||||
|
|
||||||
|
/** Optional user-configured max turns override (from settings).
|
||||||
|
* When provided, overrides the preset MAX_TURNS for the use case.
|
||||||
|
* Range: 1-2000. */
|
||||||
|
maxTurns?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export MCP types from @automaker/types for convenience
|
// Re-export MCP types from @automaker/types for convenience
|
||||||
@@ -403,7 +408,7 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt
|
|||||||
// See: https://github.com/AutoMaker-Org/automaker/issues/149
|
// See: https://github.com/AutoMaker-Org/automaker/issues/149
|
||||||
permissionMode: 'default',
|
permissionMode: 'default',
|
||||||
model: getModelForUseCase('spec', config.model),
|
model: getModelForUseCase('spec', config.model),
|
||||||
maxTurns: MAX_TURNS.maximum,
|
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
allowedTools: [...TOOL_PRESETS.specGeneration],
|
allowedTools: [...TOOL_PRESETS.specGeneration],
|
||||||
...claudeMdOptions,
|
...claudeMdOptions,
|
||||||
@@ -437,7 +442,7 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig):
|
|||||||
// Override permissionMode - feature generation only needs read-only tools
|
// Override permissionMode - feature generation only needs read-only tools
|
||||||
permissionMode: 'default',
|
permissionMode: 'default',
|
||||||
model: getModelForUseCase('features', config.model),
|
model: getModelForUseCase('features', config.model),
|
||||||
maxTurns: MAX_TURNS.quick,
|
maxTurns: config.maxTurns ?? MAX_TURNS.quick,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
allowedTools: [...TOOL_PRESETS.readOnly],
|
allowedTools: [...TOOL_PRESETS.readOnly],
|
||||||
...claudeMdOptions,
|
...claudeMdOptions,
|
||||||
@@ -468,7 +473,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
|
|||||||
return {
|
return {
|
||||||
...getBaseOptions(),
|
...getBaseOptions(),
|
||||||
model: getModelForUseCase('suggestions', config.model),
|
model: getModelForUseCase('suggestions', config.model),
|
||||||
maxTurns: MAX_TURNS.extended,
|
maxTurns: config.maxTurns ?? MAX_TURNS.extended,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
allowedTools: [...TOOL_PRESETS.readOnly],
|
allowedTools: [...TOOL_PRESETS.readOnly],
|
||||||
...claudeMdOptions,
|
...claudeMdOptions,
|
||||||
@@ -506,7 +511,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
|||||||
return {
|
return {
|
||||||
...getBaseOptions(),
|
...getBaseOptions(),
|
||||||
model: getModelForUseCase('chat', effectiveModel),
|
model: getModelForUseCase('chat', effectiveModel),
|
||||||
maxTurns: MAX_TURNS.standard,
|
maxTurns: config.maxTurns ?? MAX_TURNS.standard,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
allowedTools: [...TOOL_PRESETS.chat],
|
allowedTools: [...TOOL_PRESETS.chat],
|
||||||
...claudeMdOptions,
|
...claudeMdOptions,
|
||||||
@@ -541,7 +546,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
|||||||
return {
|
return {
|
||||||
...getBaseOptions(),
|
...getBaseOptions(),
|
||||||
model: getModelForUseCase('auto', config.model),
|
model: getModelForUseCase('auto', config.model),
|
||||||
maxTurns: MAX_TURNS.maximum,
|
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
allowedTools: [...TOOL_PRESETS.fullAccess],
|
allowedTools: [...TOOL_PRESETS.fullAccess],
|
||||||
...claudeMdOptions,
|
...claudeMdOptions,
|
||||||
|
|||||||
@@ -33,9 +33,16 @@ import {
|
|||||||
|
|
||||||
const logger = createLogger('SettingsHelper');
|
const logger = createLogger('SettingsHelper');
|
||||||
|
|
||||||
|
/** Default number of agent turns used when no value is configured. */
|
||||||
|
export const DEFAULT_MAX_TURNS = 1000;
|
||||||
|
|
||||||
|
/** Upper bound for the max-turns clamp; values above this are capped here. */
|
||||||
|
export const MAX_ALLOWED_TURNS = 2000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the autoLoadClaudeMd setting, with project settings taking precedence over global.
|
* Get the autoLoadClaudeMd setting, with project settings taking precedence over global.
|
||||||
* Returns false if settings service is not available.
|
* Falls back to global settings and defaults to true when unset.
|
||||||
|
* Returns true if settings service is not available.
|
||||||
*
|
*
|
||||||
* @param projectPath - Path to the project
|
* @param projectPath - Path to the project
|
||||||
* @param settingsService - Optional settings service instance
|
* @param settingsService - Optional settings service instance
|
||||||
@@ -48,8 +55,8 @@ export async function getAutoLoadClaudeMdSetting(
|
|||||||
logPrefix = '[SettingsHelper]'
|
logPrefix = '[SettingsHelper]'
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!settingsService) {
|
if (!settingsService) {
|
||||||
logger.info(`${logPrefix} SettingsService not available, autoLoadClaudeMd disabled`);
|
logger.info(`${logPrefix} SettingsService not available, autoLoadClaudeMd defaulting to true`);
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -64,7 +71,7 @@ export async function getAutoLoadClaudeMdSetting(
|
|||||||
|
|
||||||
// Fall back to global settings
|
// Fall back to global settings
|
||||||
const globalSettings = await settingsService.getGlobalSettings();
|
const globalSettings = await settingsService.getGlobalSettings();
|
||||||
const result = globalSettings.autoLoadClaudeMd ?? false;
|
const result = globalSettings.autoLoadClaudeMd ?? true;
|
||||||
logger.info(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`);
|
logger.info(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -73,6 +80,41 @@ export async function getAutoLoadClaudeMdSetting(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default max turns setting from global settings.
|
||||||
|
*
|
||||||
|
* Reads the user's configured `defaultMaxTurns` setting, which controls the maximum
|
||||||
|
* number of agent turns (tool-call round-trips) for feature execution.
|
||||||
|
*
|
||||||
|
* @param settingsService - Settings service instance (may be null)
|
||||||
|
* @param logPrefix - Logging prefix for debugging
|
||||||
|
* @returns The user's configured max turns, or {@link DEFAULT_MAX_TURNS} as default
|
||||||
|
*/
|
||||||
|
export async function getDefaultMaxTurnsSetting(
|
||||||
|
settingsService?: SettingsService | null,
|
||||||
|
logPrefix = '[SettingsHelper]'
|
||||||
|
): Promise<number> {
|
||||||
|
if (!settingsService) {
|
||||||
|
logger.info(
|
||||||
|
`${logPrefix} SettingsService not available, using default maxTurns=${DEFAULT_MAX_TURNS}`
|
||||||
|
);
|
||||||
|
return DEFAULT_MAX_TURNS;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const globalSettings = await settingsService.getGlobalSettings();
|
||||||
|
const raw = globalSettings.defaultMaxTurns;
|
||||||
|
const result = Number.isFinite(raw) ? (raw as number) : DEFAULT_MAX_TURNS;
|
||||||
|
// Clamp to valid range
|
||||||
|
const clamped = Math.max(1, Math.min(MAX_ALLOWED_TURNS, Math.floor(result)));
|
||||||
|
logger.debug(`${logPrefix} defaultMaxTurns from global settings: ${clamped}`);
|
||||||
|
return clamped;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`${logPrefix} Failed to load defaultMaxTurns setting:`, error);
|
||||||
|
return DEFAULT_MAX_TURNS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled
|
* Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled
|
||||||
* and rebuilds the formatted prompt without it.
|
* and rebuilds the formatted prompt without it.
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
model,
|
model,
|
||||||
cwd,
|
cwd,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
maxTurns = 100,
|
maxTurns = 1000,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
abortController,
|
abortController,
|
||||||
conversationHistory,
|
conversationHistory,
|
||||||
|
|||||||
@@ -738,6 +738,16 @@ export class CodexProvider extends BaseProvider {
|
|||||||
);
|
);
|
||||||
const baseSystemPrompt = resolveSystemPrompt(options.systemPrompt);
|
const baseSystemPrompt = resolveSystemPrompt(options.systemPrompt);
|
||||||
const resolvedMaxTurns = resolveMaxTurns(options.maxTurns);
|
const resolvedMaxTurns = resolveMaxTurns(options.maxTurns);
|
||||||
|
if (resolvedMaxTurns === null && options.maxTurns === undefined) {
|
||||||
|
logger.warn(
|
||||||
|
`[executeQuery] maxTurns not provided — Codex CLI will use its internal default. ` +
|
||||||
|
`This may cause premature completion. Model: ${options.model}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`[executeQuery] maxTurns: requested=${options.maxTurns}, resolved=${resolvedMaxTurns}, model=${options.model}`
|
||||||
|
);
|
||||||
|
}
|
||||||
const resolvedAllowedTools = options.allowedTools ?? Array.from(DEFAULT_ALLOWED_TOOLS);
|
const resolvedAllowedTools = options.allowedTools ?? Array.from(DEFAULT_ALLOWED_TOOLS);
|
||||||
const restrictTools = !hasMcpServers || options.mcpUnrestrictedTools === false;
|
const restrictTools = !hasMcpServers || options.mcpUnrestrictedTools === false;
|
||||||
const wantsOutputSchema = Boolean(
|
const wantsOutputSchema = Boolean(
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ export function createWriteHandler() {
|
|||||||
|
|
||||||
// Ensure parent directory exists (symlink-safe)
|
// Ensure parent directory exists (symlink-safe)
|
||||||
await mkdirSafe(path.dirname(path.resolve(filePath)));
|
await mkdirSafe(path.dirname(path.resolve(filePath)));
|
||||||
await secureFs.writeFile(filePath, content, 'utf-8');
|
// Default content to empty string if undefined/null to prevent writing
|
||||||
|
// "undefined" as literal text (e.g. when content field is missing from request)
|
||||||
|
await secureFs.writeFile(filePath, content ?? '', 'utf-8');
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js'
|
|||||||
import { createListIssuesHandler } from './routes/list-issues.js';
|
import { createListIssuesHandler } from './routes/list-issues.js';
|
||||||
import { createListPRsHandler } from './routes/list-prs.js';
|
import { createListPRsHandler } from './routes/list-prs.js';
|
||||||
import { createListCommentsHandler } from './routes/list-comments.js';
|
import { createListCommentsHandler } from './routes/list-comments.js';
|
||||||
|
import { createListPRReviewCommentsHandler } from './routes/list-pr-review-comments.js';
|
||||||
|
import { createResolvePRCommentHandler } from './routes/resolve-pr-comment.js';
|
||||||
import { createValidateIssueHandler } from './routes/validate-issue.js';
|
import { createValidateIssueHandler } from './routes/validate-issue.js';
|
||||||
import {
|
import {
|
||||||
createValidationStatusHandler,
|
createValidationStatusHandler,
|
||||||
@@ -29,6 +31,16 @@ export function createGitHubRoutes(
|
|||||||
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
|
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
|
||||||
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
|
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
|
||||||
router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler());
|
router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler());
|
||||||
|
router.post(
|
||||||
|
'/pr-review-comments',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createListPRReviewCommentsHandler()
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/resolve-pr-comment',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createResolvePRCommentHandler()
|
||||||
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/validate-issue',
|
'/validate-issue',
|
||||||
validatePathParams('projectPath'),
|
validatePathParams('projectPath'),
|
||||||
|
|||||||
@@ -1,38 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Common utilities for GitHub routes
|
* Common utilities for GitHub routes
|
||||||
|
*
|
||||||
|
* Re-exports shared utilities from lib/exec-utils so route consumers
|
||||||
|
* can continue importing from this module unchanged.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('GitHub');
|
|
||||||
|
|
||||||
export const execAsync = promisify(exec);
|
export const execAsync = promisify(exec);
|
||||||
|
|
||||||
// Extended PATH to include common tool installation locations
|
// Re-export shared utilities from the canonical location
|
||||||
export const extendedPath = [
|
export { extendedPath, execEnv, getErrorMessage, logError } from '../../../lib/exec-utils.js';
|
||||||
process.env.PATH,
|
|
||||||
'/opt/homebrew/bin',
|
|
||||||
'/usr/local/bin',
|
|
||||||
'/home/linuxbrew/.linuxbrew/bin',
|
|
||||||
`${process.env.HOME}/.local/bin`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(':');
|
|
||||||
|
|
||||||
export const execEnv = {
|
|
||||||
...process.env,
|
|
||||||
PATH: extendedPath,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getErrorMessage(error: unknown): string {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return error.message;
|
|
||||||
}
|
|
||||||
return String(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logError(error: unknown, context: string): void {
|
|
||||||
logger.error(`${context}:`, error);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* POST /pr-review-comments endpoint - Fetch review comments for a GitHub PR
|
||||||
|
*
|
||||||
|
* Fetches both regular PR comments and inline code review comments
|
||||||
|
* for a specific pull request, providing file path and line context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { getErrorMessage, logError } from './common.js';
|
||||||
|
import { checkGitHubRemote } from './check-github-remote.js';
|
||||||
|
import {
|
||||||
|
fetchPRReviewComments,
|
||||||
|
fetchReviewThreadResolvedStatus,
|
||||||
|
type PRReviewComment,
|
||||||
|
type ListPRReviewCommentsResult,
|
||||||
|
} from '../../../services/pr-review-comments.service.js';
|
||||||
|
|
||||||
|
// Re-export types so existing callers continue to work
|
||||||
|
export type { PRReviewComment, ListPRReviewCommentsResult };
|
||||||
|
// Re-export service functions so existing callers continue to work
|
||||||
|
export { fetchPRReviewComments, fetchReviewThreadResolvedStatus };
|
||||||
|
|
||||||
|
interface ListPRReviewCommentsRequest {
|
||||||
|
projectPath: string;
|
||||||
|
prNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createListPRReviewCommentsHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, prNumber } = req.body as ListPRReviewCommentsRequest;
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prNumber || typeof prNumber !== 'number') {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ success: false, error: 'prNumber is required and must be a number' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a GitHub repo and get owner/repo
|
||||||
|
const remoteStatus = await checkGitHubRemote(projectPath);
|
||||||
|
if (!remoteStatus.hasGitHubRemote || !remoteStatus.owner || !remoteStatus.repo) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Project does not have a GitHub remote',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = await fetchPRReviewComments(
|
||||||
|
projectPath,
|
||||||
|
remoteStatus.owner,
|
||||||
|
remoteStatus.repo,
|
||||||
|
prNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
comments,
|
||||||
|
totalCount: comments.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Fetch PR review comments failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
66
apps/server/src/routes/github/routes/resolve-pr-comment.ts
Normal file
66
apps/server/src/routes/github/routes/resolve-pr-comment.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* POST /resolve-pr-comment endpoint - Resolve or unresolve a GitHub PR review thread
|
||||||
|
*
|
||||||
|
* Uses the GitHub GraphQL API to resolve or unresolve a review thread
|
||||||
|
* identified by its GraphQL node ID (threadId).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { getErrorMessage, logError } from './common.js';
|
||||||
|
import { checkGitHubRemote } from './check-github-remote.js';
|
||||||
|
import { executeReviewThreadMutation } from '../../../services/github-pr-comment.service.js';
|
||||||
|
|
||||||
|
export interface ResolvePRCommentResult {
|
||||||
|
success: boolean;
|
||||||
|
isResolved?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvePRCommentRequest {
|
||||||
|
projectPath: string;
|
||||||
|
threadId: string;
|
||||||
|
resolve: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createResolvePRCommentHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, threadId, resolve } = req.body as ResolvePRCommentRequest;
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!threadId) {
|
||||||
|
res.status(400).json({ success: false, error: 'threadId is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof resolve !== 'boolean') {
|
||||||
|
res.status(400).json({ success: false, error: 'resolve must be a boolean' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a GitHub repo
|
||||||
|
const remoteStatus = await checkGitHubRemote(projectPath);
|
||||||
|
if (!remoteStatus.hasGitHubRemote) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Project does not have a GitHub remote',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeReviewThreadMutation(projectPath, threadId, resolve);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
isResolved: result.isResolved,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Resolve PR comment failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
* This endpoint handles worktree creation with proper checks:
|
* This endpoint handles worktree creation with proper checks:
|
||||||
* 1. First checks if git already has a worktree for the branch (anywhere)
|
* 1. First checks if git already has a worktree for the branch (anywhere)
|
||||||
* 2. If found, returns the existing worktree (no error)
|
* 2. If found, returns the existing worktree (no error)
|
||||||
* 3. Only creates a new worktree if none exists for the branch
|
* 3. Syncs the base branch from its remote tracking branch (fast-forward only)
|
||||||
|
* 4. Only creates a new worktree if none exists for the branch
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
@@ -27,6 +28,10 @@ import { execGitCommand } from '../../../lib/git.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';
|
import { runInitScript } from '../../../services/init-script-service.js';
|
||||||
|
import {
|
||||||
|
syncBaseBranch,
|
||||||
|
type BaseBranchSyncResult,
|
||||||
|
} from '../../../services/branch-sync-service.js';
|
||||||
|
|
||||||
const logger = createLogger('Worktree');
|
const logger = createLogger('Worktree');
|
||||||
|
|
||||||
@@ -193,6 +198,52 @@ export function createCreateHandler(events: EventEmitter, settingsService?: Sett
|
|||||||
logger.warn(`Failed to fetch from remotes: ${getErrorMessage(fetchErr)}`);
|
logger.warn(`Failed to fetch from remotes: ${getErrorMessage(fetchErr)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync the base branch with its remote tracking branch (fast-forward only).
|
||||||
|
// This ensures the new worktree starts from an up-to-date state rather than
|
||||||
|
// a potentially stale local copy. If the sync fails or the branch has diverged,
|
||||||
|
// we proceed with the local copy and inform the user.
|
||||||
|
const effectiveBase = baseBranch || 'HEAD';
|
||||||
|
let syncResult: BaseBranchSyncResult = { attempted: false, synced: false };
|
||||||
|
|
||||||
|
// Only sync if the base is a real branch (not 'HEAD')
|
||||||
|
// Pass skipFetch=true because we already fetched all remotes above.
|
||||||
|
if (effectiveBase !== 'HEAD') {
|
||||||
|
logger.info(`Syncing base branch '${effectiveBase}' before creating worktree`);
|
||||||
|
syncResult = await syncBaseBranch(projectPath, effectiveBase, true);
|
||||||
|
if (syncResult.attempted) {
|
||||||
|
if (syncResult.synced) {
|
||||||
|
logger.info(`Base branch sync result: ${syncResult.message}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`Base branch sync result: ${syncResult.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// When using HEAD, try to sync the currently checked-out branch
|
||||||
|
// Pass skipFetch=true because we already fetched all remotes above.
|
||||||
|
try {
|
||||||
|
const currentBranch = await execGitCommand(
|
||||||
|
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||||
|
projectPath
|
||||||
|
);
|
||||||
|
const trimmedBranch = currentBranch.trim();
|
||||||
|
if (trimmedBranch && trimmedBranch !== 'HEAD') {
|
||||||
|
logger.info(
|
||||||
|
`Syncing current branch '${trimmedBranch}' (HEAD) before creating worktree`
|
||||||
|
);
|
||||||
|
syncResult = await syncBaseBranch(projectPath, trimmedBranch, true);
|
||||||
|
if (syncResult.attempted) {
|
||||||
|
if (syncResult.synced) {
|
||||||
|
logger.info(`HEAD branch sync result: ${syncResult.message}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`HEAD branch sync result: ${syncResult.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Could not determine HEAD branch — skip sync
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if branch exists (using array arguments to prevent injection)
|
// Check if branch exists (using array arguments to prevent injection)
|
||||||
let branchExists = false;
|
let branchExists = false;
|
||||||
try {
|
try {
|
||||||
@@ -226,6 +277,19 @@ export function createCreateHandler(events: EventEmitter, settingsService?: Sett
|
|||||||
// 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);
|
||||||
|
|
||||||
|
// Get the commit hash the new worktree is based on for logging
|
||||||
|
let baseCommitHash: string | undefined;
|
||||||
|
try {
|
||||||
|
const hash = await execGitCommand(['rev-parse', '--short', 'HEAD'], absoluteWorktreePath);
|
||||||
|
baseCommitHash = hash.trim();
|
||||||
|
} catch {
|
||||||
|
// Non-critical — just for logging
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseCommitHash) {
|
||||||
|
logger.info(`New worktree for '${branchName}' based on commit ${baseCommitHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Copy configured files into the new worktree before responding
|
// Copy configured files into the new worktree before responding
|
||||||
// This runs synchronously to ensure files are in place before any init script
|
// This runs synchronously to ensure files are in place before any init script
|
||||||
try {
|
try {
|
||||||
@@ -247,6 +311,17 @@ export function createCreateHandler(events: EventEmitter, settingsService?: Sett
|
|||||||
path: normalizePath(absoluteWorktreePath),
|
path: normalizePath(absoluteWorktreePath),
|
||||||
branch: branchName,
|
branch: branchName,
|
||||||
isNew: !branchExists,
|
isNew: !branchExists,
|
||||||
|
baseCommitHash,
|
||||||
|
...(syncResult.attempted
|
||||||
|
? {
|
||||||
|
syncResult: {
|
||||||
|
synced: syncResult.synced,
|
||||||
|
remote: syncResult.remote,
|
||||||
|
message: syncResult.message,
|
||||||
|
diverged: syncResult.diverged,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
@@ -20,7 +20,7 @@ import { getErrorMessage, logError } from '../common.js';
|
|||||||
import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js';
|
import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js';
|
||||||
|
|
||||||
const logger = createLogger('GenerateCommitMessage');
|
const logger = createLogger('GenerateCommitMessage');
|
||||||
const execAsync = promisify(exec);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
/** Timeout for AI provider calls in milliseconds (30 seconds) */
|
/** Timeout for AI provider calls in milliseconds (30 seconds) */
|
||||||
const AI_TIMEOUT_MS = 30_000;
|
const AI_TIMEOUT_MS = 30_000;
|
||||||
@@ -33,20 +33,39 @@ async function* withTimeout<T>(
|
|||||||
generator: AsyncIterable<T>,
|
generator: AsyncIterable<T>,
|
||||||
timeoutMs: number
|
timeoutMs: number
|
||||||
): AsyncGenerator<T, void, unknown> {
|
): AsyncGenerator<T, void, unknown> {
|
||||||
|
let timerId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
setTimeout(() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), timeoutMs);
|
timerId = setTimeout(
|
||||||
|
() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)),
|
||||||
|
timeoutMs
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const iterator = generator[Symbol.asyncIterator]();
|
const iterator = generator[Symbol.asyncIterator]();
|
||||||
let done = false;
|
let done = false;
|
||||||
|
|
||||||
while (!done) {
|
try {
|
||||||
const result = await Promise.race([iterator.next(), timeoutPromise]);
|
while (!done) {
|
||||||
if (result.done) {
|
const result = await Promise.race([iterator.next(), timeoutPromise]).catch(async (err) => {
|
||||||
done = true;
|
// Capture the original error, then attempt to close the iterator.
|
||||||
} else {
|
// If iterator.return() throws, log it but rethrow the original error
|
||||||
yield result.value;
|
// so the timeout error (not the teardown error) is preserved.
|
||||||
|
try {
|
||||||
|
await iterator.return?.();
|
||||||
|
} catch (teardownErr) {
|
||||||
|
logger.warn('Error during iterator cleanup after timeout:', teardownErr);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
if (result.done) {
|
||||||
|
done = true;
|
||||||
|
} else {
|
||||||
|
yield result.value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timerId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,14 +136,14 @@ export function createGenerateCommitMessageHandler(
|
|||||||
let diff = '';
|
let diff = '';
|
||||||
try {
|
try {
|
||||||
// First try to get staged changes
|
// First try to get staged changes
|
||||||
const { stdout: stagedDiff } = await execAsync('git diff --cached', {
|
const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
|
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
|
||||||
});
|
});
|
||||||
|
|
||||||
// If no staged changes, get unstaged changes
|
// If no staged changes, get unstaged changes
|
||||||
if (!stagedDiff.trim()) {
|
if (!stagedDiff.trim()) {
|
||||||
const { stdout: unstagedDiff } = await execAsync('git diff', {
|
const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
|
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
|
||||||
});
|
});
|
||||||
@@ -213,14 +232,16 @@ export function createGenerateCommitMessageHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||||
// Use result if available (some providers return final text here)
|
// Use result text if longer than accumulated text (consistent with simpleQuery pattern)
|
||||||
responseText = msg.result;
|
if (msg.result.length > responseText.length) {
|
||||||
|
responseText = msg.result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = responseText.trim();
|
const message = responseText.trim();
|
||||||
|
|
||||||
if (!message || message.trim().length === 0) {
|
if (!message) {
|
||||||
logger.warn('Received empty response from model');
|
logger.warn('Received empty response from model');
|
||||||
const response: GenerateCommitMessageErrorResponse = {
|
const response: GenerateCommitMessageErrorResponse = {
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const MAX_DIFF_SIZE = 15_000;
|
|||||||
|
|
||||||
const PR_DESCRIPTION_SYSTEM_PROMPT = `You are a pull request description generator. Your task is to create a clear, well-structured PR title and description based on the git diff and branch information provided.
|
const PR_DESCRIPTION_SYSTEM_PROMPT = `You are a pull request description generator. Your task is to create a clear, well-structured PR title and description based on the git diff and branch information provided.
|
||||||
|
|
||||||
|
IMPORTANT: Do NOT include any conversational text, explanations, or preamble. Do NOT say things like "I'll analyze..." or "Here is...". Output ONLY the structured format below and nothing else.
|
||||||
|
|
||||||
Output your response in EXACTLY this format (including the markers):
|
Output your response in EXACTLY this format (including the markers):
|
||||||
---TITLE---
|
---TITLE---
|
||||||
<a concise PR title, 50-72 chars, imperative mood>
|
<a concise PR title, 50-72 chars, imperative mood>
|
||||||
@@ -41,6 +43,7 @@ Output your response in EXACTLY this format (including the markers):
|
|||||||
<Detailed list of what was changed and why>
|
<Detailed list of what was changed and why>
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
- Your ENTIRE response must start with ---TITLE--- and contain nothing before it
|
||||||
- The title should be concise and descriptive (50-72 characters)
|
- The title should be concise and descriptive (50-72 characters)
|
||||||
- Use imperative mood for the title (e.g., "Add dark mode toggle" not "Added dark mode toggle")
|
- Use imperative mood for the title (e.g., "Add dark mode toggle" not "Added dark mode toggle")
|
||||||
- The description should explain WHAT changed and WHY
|
- The description should explain WHAT changed and WHY
|
||||||
@@ -397,7 +400,10 @@ export function createGeneratePRDescriptionHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||||
responseText = msg.result;
|
// Use result text if longer than accumulated text (consistent with simpleQuery pattern)
|
||||||
|
if (msg.result.length > responseText.length) {
|
||||||
|
responseText = msg.result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +419,9 @@ export function createGeneratePRDescriptionHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the response to extract title and body
|
// Parse the response to extract title and body.
|
||||||
|
// The model may include conversational preamble before the structured markers,
|
||||||
|
// so we search for the markers anywhere in the response, not just at the start.
|
||||||
let title = '';
|
let title = '';
|
||||||
let body = '';
|
let body = '';
|
||||||
|
|
||||||
@@ -424,14 +432,46 @@ export function createGeneratePRDescriptionHandler(
|
|||||||
title = titleMatch[1].trim();
|
title = titleMatch[1].trim();
|
||||||
body = bodyMatch[1].trim();
|
body = bodyMatch[1].trim();
|
||||||
} else {
|
} else {
|
||||||
// Fallback: treat first line as title, rest as body
|
// Fallback: try to extract meaningful content, skipping any conversational preamble.
|
||||||
const lines = fullResponse.split('\n');
|
// Common preamble patterns start with "I'll", "I will", "Here", "Let me", "Based on", etc.
|
||||||
title = lines[0].trim();
|
const lines = fullResponse.split('\n').filter((line) => line.trim().length > 0);
|
||||||
body = lines.slice(1).join('\n').trim();
|
|
||||||
|
// Skip lines that look like conversational preamble
|
||||||
|
let startIndex = 0;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
// Check if this line looks like conversational AI preamble
|
||||||
|
if (
|
||||||
|
/^(I'll|I will|Here('s| is| are)|Let me|Based on|Looking at|Analyzing|Sure|OK|Okay|Of course)/i.test(
|
||||||
|
line
|
||||||
|
) ||
|
||||||
|
/^(The following|Below is|This (is|will)|After (analyzing|reviewing|looking))/i.test(
|
||||||
|
line
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
startIndex = i + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use remaining lines after skipping preamble
|
||||||
|
const contentLines = lines.slice(startIndex);
|
||||||
|
if (contentLines.length > 0) {
|
||||||
|
title = contentLines[0].trim();
|
||||||
|
body = contentLines.slice(1).join('\n').trim();
|
||||||
|
} else {
|
||||||
|
// If all lines were filtered as preamble, use the original first non-empty line
|
||||||
|
title = lines[0]?.trim() || '';
|
||||||
|
body = lines.slice(1).join('\n').trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up title - remove any markdown or quotes
|
// Clean up title - remove any markdown headings, quotes, or marker artifacts
|
||||||
title = title.replace(/^#+\s*/, '').replace(/^["']|["']$/g, '');
|
title = title
|
||||||
|
.replace(/^#+\s*/, '')
|
||||||
|
.replace(/^["']|["']$/g, '')
|
||||||
|
.replace(/^---\w+---\s*/, '');
|
||||||
|
|
||||||
logger.info(`Generated PR title: ${title.substring(0, 100)}...`);
|
logger.info(`Generated PR title: ${title.substring(0, 100)}...`);
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export type {
|
|||||||
|
|
||||||
const logger = createLogger('AgentExecutor');
|
const logger = createLogger('AgentExecutor');
|
||||||
|
|
||||||
|
const DEFAULT_MAX_TURNS = 1000;
|
||||||
|
|
||||||
export class AgentExecutor {
|
export class AgentExecutor {
|
||||||
private static readonly WRITE_DEBOUNCE_MS = 500;
|
private static readonly WRITE_DEBOUNCE_MS = 500;
|
||||||
private static readonly STREAM_HEARTBEAT_MS = 15_000;
|
private static readonly STREAM_HEARTBEAT_MS = 15_000;
|
||||||
@@ -99,10 +101,22 @@ export class AgentExecutor {
|
|||||||
workDir,
|
workDir,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
const resolvedMaxTurns = sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS;
|
||||||
|
if (sdkOptions?.maxTurns == null) {
|
||||||
|
logger.info(
|
||||||
|
`[execute] Feature ${featureId}: sdkOptions.maxTurns is not set, defaulting to ${resolvedMaxTurns}. ` +
|
||||||
|
`Model: ${effectiveBareModel}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`[execute] Feature ${featureId}: maxTurns=${resolvedMaxTurns}, model=${effectiveBareModel}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const executeOptions: ExecuteOptions = {
|
const executeOptions: ExecuteOptions = {
|
||||||
prompt: promptContent,
|
prompt: promptContent,
|
||||||
model: effectiveBareModel,
|
model: effectiveBareModel,
|
||||||
maxTurns: sdkOptions?.maxTurns,
|
maxTurns: resolvedMaxTurns,
|
||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
allowedTools: sdkOptions?.allowedTools as string[] | undefined,
|
allowedTools: sdkOptions?.allowedTools as string[] | undefined,
|
||||||
abortController,
|
abortController,
|
||||||
@@ -279,6 +293,17 @@ export class AgentExecutor {
|
|||||||
throw new Error(AgentExecutor.sanitizeProviderError(msg.error));
|
throw new Error(AgentExecutor.sanitizeProviderError(msg.error));
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success') scheduleWrite();
|
} else if (msg.type === 'result' && msg.subtype === 'success') scheduleWrite();
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
clearInterval(streamHeartbeat);
|
||||||
|
if (writeTimeout) clearTimeout(writeTimeout);
|
||||||
|
if (rawWriteTimeout) clearTimeout(rawWriteTimeout);
|
||||||
|
|
||||||
|
const streamElapsedMs = Date.now() - streamStartTime;
|
||||||
|
logger.info(
|
||||||
|
`[execute] Stream ended for feature ${featureId} after ${Math.round(streamElapsedMs / 1000)}s. ` +
|
||||||
|
`aborted=${aborted}, specDetected=${specDetected}, responseLength=${responseText.length}`
|
||||||
|
);
|
||||||
|
|
||||||
await writeToFile();
|
await writeToFile();
|
||||||
if (enableRawOutput && rawOutputLines.length > 0) {
|
if (enableRawOutput && rawOutputLines.length > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -288,10 +313,6 @@ export class AgentExecutor {
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
clearInterval(streamHeartbeat);
|
|
||||||
if (writeTimeout) clearTimeout(writeTimeout);
|
|
||||||
if (rawWriteTimeout) clearTimeout(rawWriteTimeout);
|
|
||||||
}
|
}
|
||||||
return { responseText, specDetected, tasksCompleted, aborted };
|
return { responseText, specDetected, tasksCompleted, aborted };
|
||||||
}
|
}
|
||||||
@@ -351,8 +372,13 @@ export class AgentExecutor {
|
|||||||
taskPrompts.taskExecution.taskPromptTemplate,
|
taskPrompts.taskExecution.taskPromptTemplate,
|
||||||
userFeedback
|
userFeedback
|
||||||
);
|
);
|
||||||
|
const taskMaxTurns = sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS;
|
||||||
|
logger.info(
|
||||||
|
`[executeTasksLoop] Feature ${featureId}, task ${task.id} (${taskIndex + 1}/${tasks.length}): ` +
|
||||||
|
`maxTurns=${taskMaxTurns} (sdkOptions.maxTurns=${sdkOptions?.maxTurns ?? 'undefined'})`
|
||||||
|
);
|
||||||
const taskStream = provider.executeQuery(
|
const taskStream = provider.executeQuery(
|
||||||
this.buildExecOpts(options, taskPrompt, Math.min(sdkOptions?.maxTurns ?? 100, 100))
|
this.buildExecOpts(options, taskPrompt, taskMaxTurns)
|
||||||
);
|
);
|
||||||
let taskOutput = '',
|
let taskOutput = '',
|
||||||
taskStartDetected = false,
|
taskStartDetected = false,
|
||||||
@@ -571,7 +597,7 @@ export class AgentExecutor {
|
|||||||
});
|
});
|
||||||
let revText = '';
|
let revText = '';
|
||||||
for await (const msg of provider.executeQuery(
|
for await (const msg of provider.executeQuery(
|
||||||
this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns ?? 100)
|
this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS)
|
||||||
)) {
|
)) {
|
||||||
if (msg.type === 'assistant' && msg.message?.content)
|
if (msg.type === 'assistant' && msg.message?.content)
|
||||||
for (const b of msg.message.content)
|
for (const b of msg.message.content)
|
||||||
@@ -657,7 +683,7 @@ export class AgentExecutor {
|
|||||||
return { responseText, tasksCompleted };
|
return { responseText, tasksCompleted };
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildExecOpts(o: AgentExecutionOptions, prompt: string, maxTurns?: number) {
|
private buildExecOpts(o: AgentExecutionOptions, prompt: string, maxTurns: number) {
|
||||||
return {
|
return {
|
||||||
prompt,
|
prompt,
|
||||||
model: o.effectiveBareModel,
|
model: o.effectiveBareModel,
|
||||||
@@ -689,7 +715,7 @@ export class AgentExecutor {
|
|||||||
.replace(/\{\{approvedPlan\}\}/g, planContent);
|
.replace(/\{\{approvedPlan\}\}/g, planContent);
|
||||||
let responseText = initialResponseText;
|
let responseText = initialResponseText;
|
||||||
for await (const msg of provider.executeQuery(
|
for await (const msg of provider.executeQuery(
|
||||||
this.buildExecOpts(options, contPrompt, options.sdkOptions?.maxTurns)
|
this.buildExecOpts(options, contPrompt, options.sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS)
|
||||||
)) {
|
)) {
|
||||||
if (msg.type === 'assistant' && msg.message?.content)
|
if (msg.type === 'assistant' && msg.message?.content)
|
||||||
for (const b of msg.message.content) {
|
for (const b of msg.message.content) {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
getSubagentsConfiguration,
|
getSubagentsConfiguration,
|
||||||
getCustomSubagents,
|
getCustomSubagents,
|
||||||
getProviderByModelId,
|
getProviderByModelId,
|
||||||
|
getDefaultMaxTurnsSetting,
|
||||||
} from '../lib/settings-helpers.js';
|
} from '../lib/settings-helpers.js';
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
@@ -437,6 +438,9 @@ export class AgentService {
|
|||||||
const modelForSdk = providerResolvedModel || model;
|
const modelForSdk = providerResolvedModel || model;
|
||||||
const sessionModelForSdk = providerResolvedModel ? undefined : session.model;
|
const sessionModelForSdk = providerResolvedModel ? undefined : session.model;
|
||||||
|
|
||||||
|
// Read user-configured max turns from settings
|
||||||
|
const userMaxTurns = await getDefaultMaxTurnsSetting(this.settingsService, '[AgentService]');
|
||||||
|
|
||||||
const sdkOptions = createChatOptions({
|
const sdkOptions = createChatOptions({
|
||||||
cwd: effectiveWorkDir,
|
cwd: effectiveWorkDir,
|
||||||
model: modelForSdk,
|
model: modelForSdk,
|
||||||
@@ -445,6 +449,7 @@ export class AgentService {
|
|||||||
abortController: session.abortController!,
|
abortController: session.abortController!,
|
||||||
autoLoadClaudeMd,
|
autoLoadClaudeMd,
|
||||||
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
|
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
|
||||||
|
maxTurns: userMaxTurns, // User-configured max turns from settings
|
||||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,13 @@ import { resolveModelString } from '@automaker/model-resolver';
|
|||||||
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
|
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
|
||||||
import { getFeatureDir } from '@automaker/platform';
|
import { getFeatureDir } from '@automaker/platform';
|
||||||
import * as secureFs from '../../lib/secure-fs.js';
|
import * as secureFs from '../../lib/secure-fs.js';
|
||||||
import { validateWorkingDirectory } from '../../lib/sdk-options.js';
|
import { validateWorkingDirectory, createAutoModeOptions } from '../../lib/sdk-options.js';
|
||||||
import { getPromptCustomization, getProviderByModelId } from '../../lib/settings-helpers.js';
|
import {
|
||||||
|
getPromptCustomization,
|
||||||
|
getProviderByModelId,
|
||||||
|
getMCPServersFromSettings,
|
||||||
|
getDefaultMaxTurnsSetting,
|
||||||
|
} from '../../lib/settings-helpers.js';
|
||||||
import { execGitCommand } from '@automaker/git-utils';
|
import { execGitCommand } from '@automaker/git-utils';
|
||||||
import { TypedEventBus } from '../typed-event-bus.js';
|
import { TypedEventBus } from '../typed-event-bus.js';
|
||||||
import { ConcurrencyManager } from '../concurrency-manager.js';
|
import { ConcurrencyManager } from '../concurrency-manager.js';
|
||||||
@@ -234,6 +239,45 @@ export class AutoModeServiceFacade {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build sdkOptions with proper maxTurns and allowedTools for auto-mode.
|
||||||
|
// Without this, maxTurns would be undefined, causing providers to use their
|
||||||
|
// internal defaults which may be much lower than intended (e.g., Codex CLI's
|
||||||
|
// default turn limit can cause feature runs to stop prematurely).
|
||||||
|
const autoLoadClaudeMd = opts?.autoLoadClaudeMd ?? false;
|
||||||
|
let mcpServers: Record<string, unknown> | undefined;
|
||||||
|
try {
|
||||||
|
if (settingsService) {
|
||||||
|
const servers = await getMCPServersFromSettings(settingsService, '[AutoModeFacade]');
|
||||||
|
if (Object.keys(servers).length > 0) {
|
||||||
|
mcpServers = servers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// MCP servers are optional - continue without them
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read user-configured max turns from settings
|
||||||
|
const userMaxTurns = await getDefaultMaxTurnsSetting(settingsService, '[AutoModeFacade]');
|
||||||
|
|
||||||
|
const sdkOpts = createAutoModeOptions({
|
||||||
|
cwd: workDir,
|
||||||
|
model: resolvedModel,
|
||||||
|
systemPrompt: opts?.systemPrompt,
|
||||||
|
abortController,
|
||||||
|
autoLoadClaudeMd,
|
||||||
|
thinkingLevel: opts?.thinkingLevel,
|
||||||
|
maxTurns: userMaxTurns,
|
||||||
|
mcpServers: mcpServers as
|
||||||
|
| Record<string, import('@automaker/types').McpServerConfig>
|
||||||
|
| undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[createRunAgentFn] Feature ${featureId}: model=${resolvedModel}, ` +
|
||||||
|
`maxTurns=${sdkOpts.maxTurns}, allowedTools=${(sdkOpts.allowedTools as string[])?.length ?? 'default'}, ` +
|
||||||
|
`provider=${provider.getName()}`
|
||||||
|
);
|
||||||
|
|
||||||
await agentExecutor.execute(
|
await agentExecutor.execute(
|
||||||
{
|
{
|
||||||
workDir,
|
workDir,
|
||||||
@@ -254,6 +298,15 @@ export class AutoModeServiceFacade {
|
|||||||
effectiveBareModel,
|
effectiveBareModel,
|
||||||
credentials,
|
credentials,
|
||||||
claudeCompatibleProvider,
|
claudeCompatibleProvider,
|
||||||
|
mcpServers,
|
||||||
|
sdkOptions: {
|
||||||
|
maxTurns: sdkOpts.maxTurns,
|
||||||
|
allowedTools: sdkOpts.allowedTools as string[] | undefined,
|
||||||
|
systemPrompt: sdkOpts.systemPrompt,
|
||||||
|
settingSources: sdkOpts.settingSources as
|
||||||
|
| Array<'user' | 'project' | 'local'>
|
||||||
|
| undefined,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
waitForApproval: (fId, projPath) => planApprovalService.waitForApproval(fId, projPath),
|
waitForApproval: (fId, projPath) => planApprovalService.waitForApproval(fId, projPath),
|
||||||
@@ -702,16 +755,19 @@ export class AutoModeServiceFacade {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
const runningEntryForVerify = this.concurrencyManager.getRunningFeature(featureId);
|
||||||
featureId,
|
if (runningEntryForVerify?.isAutoMode) {
|
||||||
featureName: feature?.title,
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
branchName: feature?.branchName ?? null,
|
featureId,
|
||||||
passes: allPassed,
|
featureName: feature?.title,
|
||||||
message: allPassed
|
branchName: feature?.branchName ?? null,
|
||||||
? 'All verification checks passed'
|
passes: allPassed,
|
||||||
: `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`,
|
message: allPassed
|
||||||
projectPath: this.projectPath,
|
? 'All verification checks passed'
|
||||||
});
|
: `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`,
|
||||||
|
projectPath: this.projectPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return allPassed;
|
return allPassed;
|
||||||
}
|
}
|
||||||
@@ -761,14 +817,17 @@ export class AutoModeServiceFacade {
|
|||||||
await execGitCommand(['commit', '-m', commitMessage], workDir);
|
await execGitCommand(['commit', '-m', commitMessage], workDir);
|
||||||
const hash = await execGitCommand(['rev-parse', 'HEAD'], workDir);
|
const hash = await execGitCommand(['rev-parse', 'HEAD'], workDir);
|
||||||
|
|
||||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
const runningEntryForCommit = this.concurrencyManager.getRunningFeature(featureId);
|
||||||
featureId,
|
if (runningEntryForCommit?.isAutoMode) {
|
||||||
featureName: feature?.title,
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
branchName: feature?.branchName ?? null,
|
featureId,
|
||||||
passes: true,
|
featureName: feature?.title,
|
||||||
message: `Changes committed: ${hash.trim().substring(0, 8)}`,
|
branchName: feature?.branchName ?? null,
|
||||||
projectPath: this.projectPath,
|
passes: true,
|
||||||
});
|
message: `Changes committed: ${hash.trim().substring(0, 8)}`,
|
||||||
|
projectPath: this.projectPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return hash.trim();
|
return hash.trim();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
426
apps/server/src/services/branch-sync-service.ts
Normal file
426
apps/server/src/services/branch-sync-service.ts
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
/**
|
||||||
|
* branch-sync-service - Sync a local base branch with its remote tracking branch
|
||||||
|
*
|
||||||
|
* Provides logic to detect remote tracking branches, check whether a branch
|
||||||
|
* is checked out in any worktree, and fast-forward a local branch to match
|
||||||
|
* its remote counterpart. Extracted from the worktree create route so
|
||||||
|
* the git logic is decoupled from HTTP request/response handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createLogger, getErrorMessage } from '@automaker/utils';
|
||||||
|
import { execGitCommand } from '../lib/git.js';
|
||||||
|
|
||||||
|
const logger = createLogger('BranchSyncService');
|
||||||
|
|
||||||
|
/** Timeout for git fetch operations (30 seconds) */
|
||||||
|
const FETCH_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of attempting to sync a base branch with its remote.
|
||||||
|
*/
|
||||||
|
export interface BaseBranchSyncResult {
|
||||||
|
/** Whether the sync was attempted */
|
||||||
|
attempted: boolean;
|
||||||
|
/** Whether the sync succeeded */
|
||||||
|
synced: boolean;
|
||||||
|
/** Whether the ref was resolved (but not synced, e.g. remote ref, tag, or commit hash) */
|
||||||
|
resolved?: boolean;
|
||||||
|
/** The remote that was synced from (e.g. 'origin') */
|
||||||
|
remote?: string;
|
||||||
|
/** The commit hash the base branch points to after sync */
|
||||||
|
commitHash?: string;
|
||||||
|
/** Human-readable message about the sync result */
|
||||||
|
message?: string;
|
||||||
|
/** Whether the branch had diverged (local commits ahead of remote) */
|
||||||
|
diverged?: boolean;
|
||||||
|
/** Whether the user can proceed with a stale local copy */
|
||||||
|
canProceedWithStale?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the remote tracking branch for a given local branch.
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the git repository
|
||||||
|
* @param branchName - Local branch name to check (e.g. 'main')
|
||||||
|
* @returns Object with remote name and remote branch, or null if no tracking branch
|
||||||
|
*/
|
||||||
|
export async function getTrackingBranch(
|
||||||
|
projectPath: string,
|
||||||
|
branchName: string
|
||||||
|
): Promise<{ remote: string; remoteBranch: string } | null> {
|
||||||
|
try {
|
||||||
|
// git rev-parse --abbrev-ref <branch>@{upstream} returns e.g. "origin/main"
|
||||||
|
const upstream = await execGitCommand(
|
||||||
|
['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`],
|
||||||
|
projectPath
|
||||||
|
);
|
||||||
|
const trimmed = upstream.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
// First, attempt to determine the remote name explicitly via git config
|
||||||
|
// so that remotes whose names contain slashes are handled correctly.
|
||||||
|
let remote: string | null = null;
|
||||||
|
try {
|
||||||
|
const configRemote = await execGitCommand(
|
||||||
|
['config', '--get', `branch.${branchName}.remote`],
|
||||||
|
projectPath
|
||||||
|
);
|
||||||
|
const configRemoteTrimmed = configRemote.trim();
|
||||||
|
if (configRemoteTrimmed) {
|
||||||
|
remote = configRemoteTrimmed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// git config lookup failed — will fall back to string splitting below
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remote) {
|
||||||
|
// Strip the known remote prefix (plus the separating '/') to get the remote branch.
|
||||||
|
// The upstream string is expected to be "<remote>/<remoteBranch>".
|
||||||
|
const prefix = `${remote}/`;
|
||||||
|
if (trimmed.startsWith(prefix)) {
|
||||||
|
return {
|
||||||
|
remote,
|
||||||
|
remoteBranch: trimmed.substring(prefix.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Upstream doesn't start with the expected prefix — fall through to split
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back: split on the FIRST slash, which favors the common case of
|
||||||
|
// single-name remotes with slash-containing branch names (e.g.
|
||||||
|
// "origin/feature/foo" → remote="origin", remoteBranch="feature/foo").
|
||||||
|
// Remotes with slashes in their names are uncommon and are already handled
|
||||||
|
// by the git-config lookup above; this fallback only runs when that lookup
|
||||||
|
// fails, so optimizing for single-name remotes is the safer default.
|
||||||
|
const slashIndex = trimmed.indexOf('/');
|
||||||
|
if (slashIndex > 0) {
|
||||||
|
return {
|
||||||
|
remote: trimmed.substring(0, slashIndex),
|
||||||
|
remoteBranch: trimmed.substring(slashIndex + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
// No upstream tracking branch configured
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a branch is checked out in ANY worktree (main or linked).
|
||||||
|
* Uses `git worktree list --porcelain` to enumerate all worktrees and
|
||||||
|
* checks if any of them has the given branch as their HEAD.
|
||||||
|
*
|
||||||
|
* Returns the absolute path of the worktree where the branch is checked out,
|
||||||
|
* or null if the branch is not checked out anywhere. Callers can use the
|
||||||
|
* returned path to run commands (e.g. `git merge`) inside the correct worktree.
|
||||||
|
*
|
||||||
|
* This prevents using `git update-ref` on a branch that is checked out in
|
||||||
|
* a linked worktree, which would desync that worktree's HEAD.
|
||||||
|
*/
|
||||||
|
export async function isBranchCheckedOut(
|
||||||
|
projectPath: string,
|
||||||
|
branchName: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const stdout = await execGitCommand(['worktree', 'list', '--porcelain'], projectPath);
|
||||||
|
const lines = stdout.split('\n');
|
||||||
|
let currentWorktreePath: string | null = null;
|
||||||
|
let currentBranch: string | null = null;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('worktree ')) {
|
||||||
|
currentWorktreePath = line.slice(9);
|
||||||
|
} else if (line.startsWith('branch ')) {
|
||||||
|
currentBranch = line.slice(7).replace('refs/heads/', '');
|
||||||
|
} else if (line === '') {
|
||||||
|
// End of a worktree entry — check for match, then reset for the next
|
||||||
|
if (currentBranch === branchName && currentWorktreePath) {
|
||||||
|
return currentWorktreePath;
|
||||||
|
}
|
||||||
|
currentWorktreePath = null;
|
||||||
|
currentBranch = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the last entry (if output doesn't end with a blank line)
|
||||||
|
if (currentBranch === branchName && currentWorktreePath) {
|
||||||
|
return currentWorktreePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a BaseBranchSyncResult for cases where we proceed with a stale local copy.
|
||||||
|
* Extracts the repeated pattern of getting the short commit hash with a fallback.
|
||||||
|
*/
|
||||||
|
export async function buildStaleResult(
|
||||||
|
projectPath: string,
|
||||||
|
branchName: string,
|
||||||
|
remote: string | undefined,
|
||||||
|
message: string,
|
||||||
|
extra?: Partial<BaseBranchSyncResult>
|
||||||
|
): Promise<BaseBranchSyncResult> {
|
||||||
|
let commitHash: string | undefined;
|
||||||
|
try {
|
||||||
|
const hash = await execGitCommand(['rev-parse', '--short', branchName], projectPath);
|
||||||
|
commitHash = hash.trim();
|
||||||
|
} catch {
|
||||||
|
/* ignore — commit hash is non-critical */
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
attempted: true,
|
||||||
|
synced: false,
|
||||||
|
remote,
|
||||||
|
commitHash,
|
||||||
|
message,
|
||||||
|
canProceedWithStale: true,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Sync Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a local base branch with its remote tracking branch using fast-forward only.
|
||||||
|
*
|
||||||
|
* This function:
|
||||||
|
* 1. Detects the remote tracking branch for the given local branch
|
||||||
|
* 2. Fetches latest from that remote (unless skipFetch is true)
|
||||||
|
* 3. Attempts a fast-forward-only update of the local branch
|
||||||
|
* 4. If the branch has diverged, reports the divergence and allows proceeding with stale copy
|
||||||
|
* 5. If no remote tracking branch exists, skips silently
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the git repository
|
||||||
|
* @param branchName - The local branch name to sync (e.g. 'main')
|
||||||
|
* @param skipFetch - When true, skip the internal git fetch (caller has already fetched)
|
||||||
|
* @returns Sync result with status information
|
||||||
|
*/
|
||||||
|
export async function syncBaseBranch(
|
||||||
|
projectPath: string,
|
||||||
|
branchName: string,
|
||||||
|
skipFetch = false
|
||||||
|
): Promise<BaseBranchSyncResult> {
|
||||||
|
// Check if the branch exists as a local branch (under refs/heads/).
|
||||||
|
// This correctly handles branch names containing slashes (e.g. "feature/abc",
|
||||||
|
// "fix/issue-123") which are valid local branch names, not remote refs.
|
||||||
|
let existsLocally = false;
|
||||||
|
try {
|
||||||
|
await execGitCommand(['rev-parse', '--verify', `refs/heads/${branchName}`], projectPath);
|
||||||
|
existsLocally = true;
|
||||||
|
} catch {
|
||||||
|
existsLocally = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsLocally) {
|
||||||
|
// Not a local branch — check if it's a valid ref (remote ref, tag, or commit hash).
|
||||||
|
// No synchronization is performed here; we only resolve the ref to a commit hash.
|
||||||
|
try {
|
||||||
|
const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath);
|
||||||
|
return {
|
||||||
|
attempted: false,
|
||||||
|
synced: false,
|
||||||
|
resolved: true,
|
||||||
|
commitHash: commitHash.trim(),
|
||||||
|
message: `Ref '${branchName}' resolved (not a local branch; no sync performed)`,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
attempted: false,
|
||||||
|
synced: false,
|
||||||
|
message: `Ref '${branchName}' not found`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect remote tracking branch
|
||||||
|
const tracking = await getTrackingBranch(projectPath, branchName);
|
||||||
|
if (!tracking) {
|
||||||
|
// No remote tracking branch — skip silently
|
||||||
|
logger.info(`Branch '${branchName}' has no remote tracking branch, skipping sync`);
|
||||||
|
try {
|
||||||
|
const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath);
|
||||||
|
return {
|
||||||
|
attempted: false,
|
||||||
|
synced: false,
|
||||||
|
commitHash: commitHash.trim(),
|
||||||
|
message: `Branch '${branchName}' has no remote tracking branch`,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
attempted: false,
|
||||||
|
synced: false,
|
||||||
|
message: `Branch '${branchName}' has no remote tracking branch`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Syncing base branch '${branchName}' from ${tracking.remote}/${tracking.remoteBranch}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch the specific remote unless the caller has already performed a fetch
|
||||||
|
// (e.g. via `git fetch --all`) and passed skipFetch=true to avoid redundant work.
|
||||||
|
if (!skipFetch) {
|
||||||
|
try {
|
||||||
|
const fetchController = new AbortController();
|
||||||
|
const fetchTimer = setTimeout(() => fetchController.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
await execGitCommand(
|
||||||
|
['fetch', tracking.remote, tracking.remoteBranch, '--quiet'],
|
||||||
|
projectPath,
|
||||||
|
undefined,
|
||||||
|
fetchController
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(fetchTimer);
|
||||||
|
}
|
||||||
|
} catch (fetchErr) {
|
||||||
|
// Fetch failed — network error, auth error, etc.
|
||||||
|
// Allow proceeding with stale local copy
|
||||||
|
const errMsg = getErrorMessage(fetchErr);
|
||||||
|
logger.warn(`Failed to fetch ${tracking.remote}/${tracking.remoteBranch}: ${errMsg}`);
|
||||||
|
return buildStaleResult(
|
||||||
|
projectPath,
|
||||||
|
branchName,
|
||||||
|
tracking.remote,
|
||||||
|
`Failed to fetch from remote: ${errMsg}. Proceeding with local copy.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(`Skipping fetch for '${branchName}' (caller already fetched from remotes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the local branch is behind, ahead, or diverged from the remote
|
||||||
|
const remoteRef = `${tracking.remote}/${tracking.remoteBranch}`;
|
||||||
|
try {
|
||||||
|
// Count commits ahead and behind
|
||||||
|
const revListOutput = await execGitCommand(
|
||||||
|
['rev-list', '--left-right', '--count', `${branchName}...${remoteRef}`],
|
||||||
|
projectPath
|
||||||
|
);
|
||||||
|
const parts = revListOutput.trim().split(/\s+/);
|
||||||
|
const ahead = parseInt(parts[0], 10) || 0;
|
||||||
|
const behind = parseInt(parts[1], 10) || 0;
|
||||||
|
|
||||||
|
if (ahead === 0 && behind === 0) {
|
||||||
|
// Already up to date
|
||||||
|
const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath);
|
||||||
|
logger.info(`Branch '${branchName}' is already up to date with ${remoteRef}`);
|
||||||
|
return {
|
||||||
|
attempted: true,
|
||||||
|
synced: true,
|
||||||
|
remote: tracking.remote,
|
||||||
|
commitHash: commitHash.trim(),
|
||||||
|
message: `Branch '${branchName}' is already up to date`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ahead > 0 && behind > 0) {
|
||||||
|
// Branch has diverged — cannot fast-forward
|
||||||
|
logger.warn(
|
||||||
|
`Branch '${branchName}' has diverged from ${remoteRef} (${ahead} ahead, ${behind} behind)`
|
||||||
|
);
|
||||||
|
return buildStaleResult(
|
||||||
|
projectPath,
|
||||||
|
branchName,
|
||||||
|
tracking.remote,
|
||||||
|
`Branch '${branchName}' has diverged from ${remoteRef} (${ahead} commit(s) ahead, ${behind} behind). Using local copy to avoid overwriting local commits.`,
|
||||||
|
{ diverged: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ahead > 0 && behind === 0) {
|
||||||
|
// Local is ahead — nothing to pull, already has everything from remote plus more
|
||||||
|
const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath);
|
||||||
|
logger.info(`Branch '${branchName}' is ${ahead} commit(s) ahead of ${remoteRef}`);
|
||||||
|
return {
|
||||||
|
attempted: true,
|
||||||
|
synced: true,
|
||||||
|
remote: tracking.remote,
|
||||||
|
commitHash: commitHash.trim(),
|
||||||
|
message: `Branch '${branchName}' is ${ahead} commit(s) ahead of remote`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// behind > 0 && ahead === 0 — can fast-forward
|
||||||
|
logger.info(
|
||||||
|
`Branch '${branchName}' is ${behind} commit(s) behind ${remoteRef}, fast-forwarding`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine whether the branch is currently checked out (returns the
|
||||||
|
// worktree path where it is checked out, or null if not checked out)
|
||||||
|
const worktreePath = await isBranchCheckedOut(projectPath, branchName);
|
||||||
|
|
||||||
|
if (worktreePath) {
|
||||||
|
// Branch is checked out in a worktree — use git merge --ff-only
|
||||||
|
// Run the merge inside the worktree that has the branch checked out
|
||||||
|
try {
|
||||||
|
await execGitCommand(['merge', '--ff-only', remoteRef], worktreePath);
|
||||||
|
} catch (mergeErr) {
|
||||||
|
const errMsg = getErrorMessage(mergeErr);
|
||||||
|
logger.warn(`Fast-forward merge failed for '${branchName}': ${errMsg}`);
|
||||||
|
return buildStaleResult(
|
||||||
|
projectPath,
|
||||||
|
branchName,
|
||||||
|
tracking.remote,
|
||||||
|
`Fast-forward merge failed: ${errMsg}. Proceeding with local copy.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Branch is NOT checked out — use git update-ref to fast-forward without checkout
|
||||||
|
// This is safe because we already verified the branch is strictly behind (ahead === 0)
|
||||||
|
try {
|
||||||
|
const remoteCommit = await execGitCommand(['rev-parse', remoteRef], projectPath);
|
||||||
|
await execGitCommand(
|
||||||
|
['update-ref', `refs/heads/${branchName}`, remoteCommit.trim()],
|
||||||
|
projectPath
|
||||||
|
);
|
||||||
|
} catch (updateErr) {
|
||||||
|
const errMsg = getErrorMessage(updateErr);
|
||||||
|
logger.warn(`update-ref failed for '${branchName}': ${errMsg}`);
|
||||||
|
return buildStaleResult(
|
||||||
|
projectPath,
|
||||||
|
branchName,
|
||||||
|
tracking.remote,
|
||||||
|
`Failed to fast-forward branch: ${errMsg}. Proceeding with local copy.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successfully fast-forwarded
|
||||||
|
const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath);
|
||||||
|
logger.info(`Successfully synced '${branchName}' to ${commitHash.trim()} from ${remoteRef}`);
|
||||||
|
return {
|
||||||
|
attempted: true,
|
||||||
|
synced: true,
|
||||||
|
remote: tracking.remote,
|
||||||
|
commitHash: commitHash.trim(),
|
||||||
|
message: `Fast-forwarded '${branchName}' by ${behind} commit(s) from ${remoteRef}`,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
// Unexpected error during rev-list or merge — proceed with stale
|
||||||
|
const errMsg = getErrorMessage(err);
|
||||||
|
logger.warn(`Unexpected error syncing '${branchName}': ${errMsg}`);
|
||||||
|
return buildStaleResult(
|
||||||
|
projectPath,
|
||||||
|
branchName,
|
||||||
|
tracking.remote,
|
||||||
|
`Sync failed: ${errMsg}. Proceeding with local copy.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@ const logger = createLogger('DevServerService');
|
|||||||
// Maximum scrollback buffer size (characters) - matches TerminalService pattern
|
// Maximum scrollback buffer size (characters) - matches TerminalService pattern
|
||||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server
|
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server
|
||||||
|
|
||||||
|
// Timeout (ms) before falling back to the allocated port if URL detection hasn't succeeded.
|
||||||
|
// This handles cases where the dev server output format is not recognized by any pattern.
|
||||||
|
const URL_DETECTION_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
// URL patterns for detecting full URLs from dev server output.
|
// URL patterns for detecting full URLs from dev server output.
|
||||||
// Defined once at module level to avoid reallocation on every call to detectUrlFromOutput.
|
// Defined once at module level to avoid reallocation on every call to detectUrlFromOutput.
|
||||||
// Ordered from most specific (framework-specific) to least specific.
|
// Ordered from most specific (framework-specific) to least specific.
|
||||||
@@ -88,6 +92,8 @@ const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
|
|||||||
|
|
||||||
export interface DevServerInfo {
|
export interface DevServerInfo {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
|
/** The port originally reserved by findAvailablePort() – never mutated after startDevServer sets it */
|
||||||
|
allocatedPort: number;
|
||||||
port: number;
|
port: number;
|
||||||
url: string;
|
url: string;
|
||||||
process: ChildProcess | null;
|
process: ChildProcess | null;
|
||||||
@@ -102,6 +108,8 @@ export interface DevServerInfo {
|
|||||||
stopping: boolean;
|
stopping: boolean;
|
||||||
// Flag to indicate if URL has been detected from output
|
// Flag to indicate if URL has been detected from output
|
||||||
urlDetected: boolean;
|
urlDetected: boolean;
|
||||||
|
// Timer for URL detection timeout fallback
|
||||||
|
urlDetectionTimeout: NodeJS.Timeout | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Port allocation starts at 3001 to avoid conflicts with common dev ports
|
// Port allocation starts at 3001 to avoid conflicts with common dev ports
|
||||||
@@ -124,6 +132,32 @@ class DevServerService {
|
|||||||
this.emitter = emitter;
|
this.emitter = emitter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prune a stale server entry whose process has exited without cleanup.
|
||||||
|
* Clears any pending timers, removes the port from allocatedPorts, deletes
|
||||||
|
* the entry from runningServers, and emits the "dev-server:stopped" event
|
||||||
|
* so all callers consistently notify the frontend when pruning entries.
|
||||||
|
*
|
||||||
|
* @param worktreePath - The key used in runningServers
|
||||||
|
* @param server - The DevServerInfo entry to prune
|
||||||
|
*/
|
||||||
|
private pruneStaleServer(worktreePath: string, server: DevServerInfo): void {
|
||||||
|
if (server.flushTimeout) clearTimeout(server.flushTimeout);
|
||||||
|
if (server.urlDetectionTimeout) clearTimeout(server.urlDetectionTimeout);
|
||||||
|
// Use allocatedPort (immutable) to free the reserved slot; server.port may have
|
||||||
|
// been mutated by detectUrlFromOutput to reflect the actual detected port.
|
||||||
|
this.allocatedPorts.delete(server.allocatedPort);
|
||||||
|
this.runningServers.delete(worktreePath);
|
||||||
|
if (this.emitter) {
|
||||||
|
this.emitter.emit('dev-server:stopped', {
|
||||||
|
worktreePath,
|
||||||
|
port: server.port, // Report the externally-visible (detected) port
|
||||||
|
exitCode: server.process?.exitCode ?? null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append data to scrollback buffer with size limit enforcement
|
* Append data to scrollback buffer with size limit enforcement
|
||||||
* Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE
|
* Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE
|
||||||
@@ -253,6 +287,12 @@ class DevServerService {
|
|||||||
server.url = detectedUrl;
|
server.url = detectedUrl;
|
||||||
server.urlDetected = true;
|
server.urlDetected = true;
|
||||||
|
|
||||||
|
// Clear the URL detection timeout since we found the URL
|
||||||
|
if (server.urlDetectionTimeout) {
|
||||||
|
clearTimeout(server.urlDetectionTimeout);
|
||||||
|
server.urlDetectionTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Update the port to match the detected URL's actual port
|
// Update the port to match the detected URL's actual port
|
||||||
const detectedPort = this.extractPortFromUrl(detectedUrl);
|
const detectedPort = this.extractPortFromUrl(detectedUrl);
|
||||||
if (detectedPort && detectedPort !== server.port) {
|
if (detectedPort && detectedPort !== server.port) {
|
||||||
@@ -291,6 +331,12 @@ class DevServerService {
|
|||||||
server.url = detectedUrl;
|
server.url = detectedUrl;
|
||||||
server.urlDetected = true;
|
server.urlDetected = true;
|
||||||
|
|
||||||
|
// Clear the URL detection timeout since we found the port
|
||||||
|
if (server.urlDetectionTimeout) {
|
||||||
|
clearTimeout(server.urlDetectionTimeout);
|
||||||
|
server.urlDetectionTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (detectedPort !== server.port) {
|
if (detectedPort !== server.port) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Port mismatch: allocated ${server.port}, detected ${detectedPort} from ${description}`
|
`Port mismatch: allocated ${server.port}, detected ${detectedPort} from ${description}`
|
||||||
@@ -660,6 +706,7 @@ class DevServerService {
|
|||||||
const hostname = process.env.HOSTNAME || 'localhost';
|
const hostname = process.env.HOSTNAME || 'localhost';
|
||||||
const serverInfo: DevServerInfo = {
|
const serverInfo: DevServerInfo = {
|
||||||
worktreePath,
|
worktreePath,
|
||||||
|
allocatedPort: port, // Immutable: records which port we reserved; never changed after this point
|
||||||
port,
|
port,
|
||||||
url: `http://${hostname}:${port}`, // Initial URL, may be updated by detectUrlFromOutput
|
url: `http://${hostname}:${port}`, // Initial URL, may be updated by detectUrlFromOutput
|
||||||
process: devProcess,
|
process: devProcess,
|
||||||
@@ -669,6 +716,7 @@ class DevServerService {
|
|||||||
flushTimeout: null,
|
flushTimeout: null,
|
||||||
stopping: false,
|
stopping: false,
|
||||||
urlDetected: false, // Will be set to true when actual URL is detected from output
|
urlDetected: false, // Will be set to true when actual URL is detected from output
|
||||||
|
urlDetectionTimeout: null, // Will be set after server starts successfully
|
||||||
};
|
};
|
||||||
|
|
||||||
// Capture stdout with buffer management and event emission
|
// Capture stdout with buffer management and event emission
|
||||||
@@ -692,18 +740,24 @@ class DevServerService {
|
|||||||
serverInfo.flushTimeout = null;
|
serverInfo.flushTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear URL detection timeout to prevent stale fallback emission
|
||||||
|
if (serverInfo.urlDetectionTimeout) {
|
||||||
|
clearTimeout(serverInfo.urlDetectionTimeout);
|
||||||
|
serverInfo.urlDetectionTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Emit stopped event (only if not already stopping - prevents duplicate events)
|
// Emit stopped event (only if not already stopping - prevents duplicate events)
|
||||||
if (this.emitter && !serverInfo.stopping) {
|
if (this.emitter && !serverInfo.stopping) {
|
||||||
this.emitter.emit('dev-server:stopped', {
|
this.emitter.emit('dev-server:stopped', {
|
||||||
worktreePath,
|
worktreePath,
|
||||||
port,
|
port: serverInfo.port, // Use the detected port (may differ from allocated port if detectUrlFromOutput updated it)
|
||||||
exitCode,
|
exitCode,
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.allocatedPorts.delete(port);
|
this.allocatedPorts.delete(serverInfo.allocatedPort);
|
||||||
this.runningServers.delete(worktreePath);
|
this.runningServers.delete(worktreePath);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -749,6 +803,43 @@ class DevServerService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set up URL detection timeout fallback.
|
||||||
|
// If URL detection hasn't succeeded after URL_DETECTION_TIMEOUT_MS, check if
|
||||||
|
// the allocated port is actually in use (server probably started successfully)
|
||||||
|
// and emit a url-detected event with the allocated port as fallback.
|
||||||
|
// Also re-scan the scrollback buffer in case the URL was printed before
|
||||||
|
// our patterns could match (e.g., it was split across multiple data chunks).
|
||||||
|
serverInfo.urlDetectionTimeout = setTimeout(() => {
|
||||||
|
serverInfo.urlDetectionTimeout = null;
|
||||||
|
|
||||||
|
// Only run fallback if server is still running and URL wasn't detected
|
||||||
|
if (serverInfo.stopping || serverInfo.urlDetected || !this.runningServers.has(worktreePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-scan the entire scrollback buffer for URL patterns
|
||||||
|
// This catches cases where the URL was split across multiple output chunks
|
||||||
|
logger.info(`URL detection timeout for ${worktreePath}, re-scanning scrollback buffer`);
|
||||||
|
this.detectUrlFromOutput(serverInfo, serverInfo.scrollbackBuffer);
|
||||||
|
|
||||||
|
// If still not detected after full rescan, use the allocated port as fallback
|
||||||
|
if (!serverInfo.urlDetected) {
|
||||||
|
logger.info(`URL detection fallback: using allocated port ${port} for ${worktreePath}`);
|
||||||
|
const fallbackUrl = `http://${hostname}:${port}`;
|
||||||
|
serverInfo.url = fallbackUrl;
|
||||||
|
serverInfo.urlDetected = true;
|
||||||
|
|
||||||
|
if (this.emitter) {
|
||||||
|
this.emitter.emit('dev-server:url-detected', {
|
||||||
|
worktreePath,
|
||||||
|
url: fallbackUrl,
|
||||||
|
port,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, URL_DETECTION_TIMEOUT_MS);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
@@ -794,6 +885,12 @@ class DevServerService {
|
|||||||
server.flushTimeout = null;
|
server.flushTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up URL detection timeout
|
||||||
|
if (server.urlDetectionTimeout) {
|
||||||
|
clearTimeout(server.urlDetectionTimeout);
|
||||||
|
server.urlDetectionTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Clear any pending output buffer
|
// Clear any pending output buffer
|
||||||
server.outputBuffer = '';
|
server.outputBuffer = '';
|
||||||
|
|
||||||
@@ -812,8 +909,10 @@ class DevServerService {
|
|||||||
server.process.kill('SIGTERM');
|
server.process.kill('SIGTERM');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Free the port
|
// Free the originally-reserved port slot (allocatedPort is immutable and always
|
||||||
this.allocatedPorts.delete(server.port);
|
// matches what was added to allocatedPorts in startDevServer; server.port may
|
||||||
|
// have been updated by detectUrlFromOutput to the actual detected port).
|
||||||
|
this.allocatedPorts.delete(server.allocatedPort);
|
||||||
this.runningServers.delete(worktreePath);
|
this.runningServers.delete(worktreePath);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -827,6 +926,7 @@ class DevServerService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* List all running dev servers
|
* List all running dev servers
|
||||||
|
* Also verifies that each server's process is still alive, removing stale entries
|
||||||
*/
|
*/
|
||||||
listDevServers(): {
|
listDevServers(): {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -836,14 +936,37 @@ class DevServerService {
|
|||||||
port: number;
|
port: number;
|
||||||
url: string;
|
url: string;
|
||||||
urlDetected: boolean;
|
urlDetected: boolean;
|
||||||
|
startedAt: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
} {
|
} {
|
||||||
|
// Prune any servers whose process has died without us being notified
|
||||||
|
// This handles edge cases where the process exited but the 'exit' event was missed
|
||||||
|
const stalePaths: string[] = [];
|
||||||
|
for (const [worktreePath, server] of this.runningServers) {
|
||||||
|
// Check if exitCode is a number (not null/undefined) - indicates process has exited
|
||||||
|
if (server.process && typeof server.process.exitCode === 'number') {
|
||||||
|
logger.info(
|
||||||
|
`Pruning stale server entry for ${worktreePath} (process exited with code ${server.process.exitCode})`
|
||||||
|
);
|
||||||
|
stalePaths.push(worktreePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const stalePath of stalePaths) {
|
||||||
|
const server = this.runningServers.get(stalePath);
|
||||||
|
if (server) {
|
||||||
|
// Delegate to the shared helper so timers, ports, and the stopped event
|
||||||
|
// are all handled consistently with isRunning and getServerInfo.
|
||||||
|
this.pruneStaleServer(stalePath, server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const servers = Array.from(this.runningServers.values()).map((s) => ({
|
const servers = Array.from(this.runningServers.values()).map((s) => ({
|
||||||
worktreePath: s.worktreePath,
|
worktreePath: s.worktreePath,
|
||||||
port: s.port,
|
port: s.port,
|
||||||
url: s.url,
|
url: s.url,
|
||||||
urlDetected: s.urlDetected,
|
urlDetected: s.urlDetected,
|
||||||
|
startedAt: s.startedAt.toISOString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -853,17 +976,33 @@ class DevServerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a worktree has a running dev server
|
* Check if a worktree has a running dev server.
|
||||||
|
* Also prunes stale entries where the process has exited.
|
||||||
*/
|
*/
|
||||||
isRunning(worktreePath: string): boolean {
|
isRunning(worktreePath: string): boolean {
|
||||||
return this.runningServers.has(worktreePath);
|
const server = this.runningServers.get(worktreePath);
|
||||||
|
if (!server) return false;
|
||||||
|
// Prune stale entry if the process has exited
|
||||||
|
if (server.process && typeof server.process.exitCode === 'number') {
|
||||||
|
this.pruneStaleServer(worktreePath, server);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get info for a specific worktree's dev server
|
* Get info for a specific worktree's dev server.
|
||||||
|
* Also prunes stale entries where the process has exited.
|
||||||
*/
|
*/
|
||||||
getServerInfo(worktreePath: string): DevServerInfo | undefined {
|
getServerInfo(worktreePath: string): DevServerInfo | undefined {
|
||||||
return this.runningServers.get(worktreePath);
|
const server = this.runningServers.get(worktreePath);
|
||||||
|
if (!server) return undefined;
|
||||||
|
// Prune stale entry if the process has exited
|
||||||
|
if (server.process && typeof server.process.exitCode === 'number') {
|
||||||
|
this.pruneStaleServer(worktreePath, server);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -891,6 +1030,15 @@ class DevServerService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prune stale entry if the process has been killed or has exited
|
||||||
|
if (server.process && (server.process.killed || server.process.exitCode != null)) {
|
||||||
|
this.pruneStaleServer(worktreePath, server);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `No dev server running for worktree: ${worktreePath}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
|
|||||||
@@ -170,13 +170,15 @@ export class EventHookService {
|
|||||||
|
|
||||||
// Build context for variable substitution
|
// Build context for variable substitution
|
||||||
// Use loaded featureName (from feature.title) or fall back to payload.featureName
|
// Use loaded featureName (from feature.title) or fall back to payload.featureName
|
||||||
|
// Only populate error/errorType for error triggers - don't leak success messages into error fields
|
||||||
|
const isErrorTrigger = trigger === 'feature_error' || trigger === 'auto_mode_error';
|
||||||
const context: HookContext = {
|
const context: HookContext = {
|
||||||
featureId: payload.featureId,
|
featureId: payload.featureId,
|
||||||
featureName: featureName || payload.featureName,
|
featureName: featureName || payload.featureName,
|
||||||
projectPath: payload.projectPath,
|
projectPath: payload.projectPath,
|
||||||
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
|
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
|
||||||
error: payload.error || payload.message,
|
error: isErrorTrigger ? payload.error || payload.message : undefined,
|
||||||
errorType: payload.errorType,
|
errorType: isErrorTrigger ? payload.errorType : undefined,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
eventType: trigger,
|
eventType: trigger,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -441,28 +441,32 @@ Please continue from where you left off and complete all remaining tasks. Use th
|
|||||||
if (hasIncompleteTasks)
|
if (hasIncompleteTasks)
|
||||||
completionMessage += ` (${completedTasks}/${totalTasks} tasks completed)`;
|
completionMessage += ` (${completedTasks}/${totalTasks} tasks completed)`;
|
||||||
|
|
||||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
if (isAutoMode) {
|
||||||
featureId,
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
featureName: feature.title,
|
featureId,
|
||||||
branchName: feature.branchName ?? null,
|
featureName: feature.title,
|
||||||
passes: true,
|
branchName: feature.branchName ?? null,
|
||||||
message: completionMessage,
|
passes: true,
|
||||||
projectPath,
|
message: completionMessage,
|
||||||
model: tempRunningFeature.model,
|
projectPath,
|
||||||
provider: tempRunningFeature.provider,
|
model: tempRunningFeature.model,
|
||||||
});
|
provider: tempRunningFeature.provider,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorInfo = classifyError(error);
|
const errorInfo = classifyError(error);
|
||||||
if (errorInfo.isAbort) {
|
if (errorInfo.isAbort) {
|
||||||
await this.updateFeatureStatusFn(projectPath, featureId, 'interrupted');
|
await this.updateFeatureStatusFn(projectPath, featureId, 'interrupted');
|
||||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
if (isAutoMode) {
|
||||||
featureId,
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
featureName: feature?.title,
|
featureId,
|
||||||
branchName: feature?.branchName ?? null,
|
featureName: feature?.title,
|
||||||
passes: false,
|
branchName: feature?.branchName ?? null,
|
||||||
message: 'Feature stopped by user',
|
passes: false,
|
||||||
projectPath,
|
message: 'Feature stopped by user',
|
||||||
});
|
projectPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.error(`Feature ${featureId} failed:`, error);
|
logger.error(`Feature ${featureId} failed:`, error);
|
||||||
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
|
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
|
||||||
|
|||||||
103
apps/server/src/services/github-pr-comment.service.ts
Normal file
103
apps/server/src/services/github-pr-comment.service.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* GitHub PR Comment Service
|
||||||
|
*
|
||||||
|
* Domain logic for resolving/unresolving PR review threads via the
|
||||||
|
* GitHub GraphQL API. Extracted from the route handler so the route
|
||||||
|
* only deals with request/response plumbing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { execEnv } from '../lib/exec-utils.js';
|
||||||
|
|
||||||
|
/** Timeout for GitHub GraphQL API requests in milliseconds */
|
||||||
|
const GITHUB_API_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
interface GraphQLMutationResponse {
|
||||||
|
data?: {
|
||||||
|
resolveReviewThread?: {
|
||||||
|
thread?: { isResolved: boolean; id: string } | null;
|
||||||
|
} | null;
|
||||||
|
unresolveReviewThread?: {
|
||||||
|
thread?: { isResolved: boolean; id: string } | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
errors?: Array<{ message: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a GraphQL mutation to resolve or unresolve a review thread.
|
||||||
|
*/
|
||||||
|
export async function executeReviewThreadMutation(
|
||||||
|
projectPath: string,
|
||||||
|
threadId: string,
|
||||||
|
resolve: boolean
|
||||||
|
): Promise<{ isResolved: boolean }> {
|
||||||
|
const mutationName = resolve ? 'resolveReviewThread' : 'unresolveReviewThread';
|
||||||
|
|
||||||
|
const mutation = `
|
||||||
|
mutation ${resolve ? 'ResolveThread' : 'UnresolveThread'}($threadId: ID!) {
|
||||||
|
${mutationName}(input: { threadId: $threadId }) {
|
||||||
|
thread {
|
||||||
|
id
|
||||||
|
isResolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const variables = { threadId };
|
||||||
|
const requestBody = JSON.stringify({ query: mutation, variables });
|
||||||
|
|
||||||
|
// Declare timeoutId before registering the error handler to avoid TDZ confusion
|
||||||
|
let timeoutId: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
const response = await new Promise<GraphQLMutationResponse>((res, rej) => {
|
||||||
|
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
|
||||||
|
cwd: projectPath,
|
||||||
|
env: execEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
gh.on('error', (err) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
rej(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
gh.kill();
|
||||||
|
rej(new Error('GitHub GraphQL API request timed out'));
|
||||||
|
}, GITHUB_API_TIMEOUT_MS);
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
|
||||||
|
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
|
||||||
|
|
||||||
|
gh.on('close', (code) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (code !== 0) {
|
||||||
|
return rej(new Error(`gh process exited with code ${code}: ${stderr}`));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res(JSON.parse(stdout));
|
||||||
|
} catch (e) {
|
||||||
|
rej(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gh.stdin.write(requestBody);
|
||||||
|
gh.stdin.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.errors && response.errors.length > 0) {
|
||||||
|
throw new Error(response.errors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadData = resolve
|
||||||
|
? response.data?.resolveReviewThread?.thread
|
||||||
|
: response.data?.unresolveReviewThread?.thread;
|
||||||
|
|
||||||
|
if (!threadData) {
|
||||||
|
throw new Error('No thread data returned from GitHub API');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isResolved: threadData.isResolved };
|
||||||
|
}
|
||||||
@@ -226,14 +226,17 @@ export class PipelineOrchestrator {
|
|||||||
logger.warn(`Step ${pipelineInfo.stepId} no longer exists, completing feature`);
|
logger.warn(`Step ${pipelineInfo.stepId} no longer exists, completing feature`);
|
||||||
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||||
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
|
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
|
||||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
const runningEntryForStep = this.concurrencyManager.getRunningFeature(featureId);
|
||||||
featureId,
|
if (runningEntryForStep?.isAutoMode) {
|
||||||
featureName: feature.title,
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
branchName: feature.branchName ?? null,
|
featureId,
|
||||||
passes: true,
|
featureName: feature.title,
|
||||||
message: 'Pipeline step no longer exists',
|
branchName: feature.branchName ?? null,
|
||||||
projectPath,
|
passes: true,
|
||||||
});
|
message: 'Pipeline step no longer exists',
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,14 +275,17 @@ export class PipelineOrchestrator {
|
|||||||
);
|
);
|
||||||
if (!pipelineService.isPipelineStatus(nextStatus)) {
|
if (!pipelineService.isPipelineStatus(nextStatus)) {
|
||||||
await this.updateFeatureStatusFn(projectPath, featureId, nextStatus);
|
await this.updateFeatureStatusFn(projectPath, featureId, nextStatus);
|
||||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
const runningEntryForExcluded = this.concurrencyManager.getRunningFeature(featureId);
|
||||||
featureId,
|
if (runningEntryForExcluded?.isAutoMode) {
|
||||||
featureName: feature.title,
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
branchName: feature.branchName ?? null,
|
featureId,
|
||||||
passes: true,
|
featureName: feature.title,
|
||||||
message: 'Pipeline completed (remaining steps excluded)',
|
branchName: feature.branchName ?? null,
|
||||||
projectPath,
|
passes: true,
|
||||||
});
|
message: 'Pipeline completed (remaining steps excluded)',
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextStepId = pipelineService.getStepIdFromStatus(nextStatus);
|
const nextStepId = pipelineService.getStepIdFromStatus(nextStatus);
|
||||||
@@ -294,14 +300,17 @@ export class PipelineOrchestrator {
|
|||||||
if (stepsToExecute.length === 0) {
|
if (stepsToExecute.length === 0) {
|
||||||
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||||
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
|
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
|
||||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
const runningEntryForAllExcluded = this.concurrencyManager.getRunningFeature(featureId);
|
||||||
featureId,
|
if (runningEntryForAllExcluded?.isAutoMode) {
|
||||||
featureName: feature.title,
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
branchName: feature.branchName ?? null,
|
featureId,
|
||||||
passes: true,
|
featureName: feature.title,
|
||||||
message: 'Pipeline completed (all steps excluded)',
|
branchName: feature.branchName ?? null,
|
||||||
projectPath,
|
passes: true,
|
||||||
});
|
message: 'Pipeline completed (all steps excluded)',
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,25 +379,29 @@ export class PipelineOrchestrator {
|
|||||||
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
|
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
|
||||||
}
|
}
|
||||||
logger.info(`Pipeline resume completed for feature ${featureId}`);
|
logger.info(`Pipeline resume completed for feature ${featureId}`);
|
||||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
if (runningEntry.isAutoMode) {
|
||||||
featureId,
|
|
||||||
featureName: feature.title,
|
|
||||||
branchName: feature.branchName ?? null,
|
|
||||||
passes: true,
|
|
||||||
message: 'Pipeline resumed successfully',
|
|
||||||
projectPath,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const errorInfo = classifyError(error);
|
|
||||||
if (errorInfo.isAbort) {
|
|
||||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
featureId,
|
featureId,
|
||||||
featureName: feature.title,
|
featureName: feature.title,
|
||||||
branchName: feature.branchName ?? null,
|
branchName: feature.branchName ?? null,
|
||||||
passes: false,
|
passes: true,
|
||||||
message: 'Pipeline stopped by user',
|
message: 'Pipeline resumed successfully',
|
||||||
projectPath,
|
projectPath,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorInfo = classifyError(error);
|
||||||
|
if (errorInfo.isAbort) {
|
||||||
|
if (runningEntry.isAutoMode) {
|
||||||
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
|
featureId,
|
||||||
|
featureName: feature.title,
|
||||||
|
branchName: feature.branchName ?? null,
|
||||||
|
passes: false,
|
||||||
|
message: 'Pipeline stopped by user',
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.error(`Pipeline resume failed for ${featureId}:`, error);
|
logger.error(`Pipeline resume failed for ${featureId}:`, error);
|
||||||
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
|
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
|
||||||
@@ -537,14 +550,17 @@ export class PipelineOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Auto-merge successful for feature ${featureId}`);
|
logger.info(`Auto-merge successful for feature ${featureId}`);
|
||||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
const runningEntryForMerge = this.concurrencyManager.getRunningFeature(featureId);
|
||||||
featureId,
|
if (runningEntryForMerge?.isAutoMode) {
|
||||||
featureName: feature.title,
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
branchName,
|
featureId,
|
||||||
passes: true,
|
featureName: feature.title,
|
||||||
message: 'Pipeline completed and merged',
|
branchName,
|
||||||
projectPath,
|
passes: true,
|
||||||
});
|
message: 'Pipeline completed and merged',
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Merge failed for ${featureId}:`, error);
|
logger.error(`Merge failed for ${featureId}:`, error);
|
||||||
|
|||||||
431
apps/server/src/services/pr-review-comments.service.ts
Normal file
431
apps/server/src/services/pr-review-comments.service.ts
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
/**
|
||||||
|
* PR Review Comments Service
|
||||||
|
*
|
||||||
|
* Domain logic for fetching PR review comments, enriching them with
|
||||||
|
* resolved-thread status, and sorting. Extracted from the route handler
|
||||||
|
* so the route only deals with request/response plumbing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn, execFile } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import { execEnv, logError } from '../lib/exec-utils.js';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
// ── Public types (re-exported for callers) ──
|
||||||
|
|
||||||
|
export interface PRReviewComment {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
body: string;
|
||||||
|
path?: string;
|
||||||
|
line?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
isReviewComment: boolean;
|
||||||
|
/** Whether this is an outdated review comment (code has changed since) */
|
||||||
|
isOutdated?: boolean;
|
||||||
|
/** Whether the review thread containing this comment has been resolved */
|
||||||
|
isResolved?: boolean;
|
||||||
|
/** The GraphQL node ID of the review thread (used for resolve/unresolve mutations) */
|
||||||
|
threadId?: string;
|
||||||
|
/** The diff hunk context for the comment */
|
||||||
|
diffHunk?: string;
|
||||||
|
/** The side of the diff (LEFT or RIGHT) */
|
||||||
|
side?: string;
|
||||||
|
/** The commit ID the comment was made on */
|
||||||
|
commitId?: string;
|
||||||
|
/** Whether the comment author is a bot/app account */
|
||||||
|
isBot?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListPRReviewCommentsResult {
|
||||||
|
success: boolean;
|
||||||
|
comments?: PRReviewComment[];
|
||||||
|
totalCount?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal types ──
|
||||||
|
|
||||||
|
/** Timeout for GitHub GraphQL API requests in milliseconds */
|
||||||
|
const GITHUB_API_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
/** Maximum number of pagination pages to prevent infinite loops */
|
||||||
|
const MAX_PAGINATION_PAGES = 20;
|
||||||
|
|
||||||
|
interface GraphQLReviewThreadComment {
|
||||||
|
databaseId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphQLReviewThread {
|
||||||
|
id: string;
|
||||||
|
isResolved: boolean;
|
||||||
|
comments: {
|
||||||
|
pageInfo?: {
|
||||||
|
hasNextPage: boolean;
|
||||||
|
endCursor?: string | null;
|
||||||
|
};
|
||||||
|
nodes: GraphQLReviewThreadComment[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphQLResponse {
|
||||||
|
data?: {
|
||||||
|
repository?: {
|
||||||
|
pullRequest?: {
|
||||||
|
reviewThreads?: {
|
||||||
|
nodes: GraphQLReviewThread[];
|
||||||
|
pageInfo?: {
|
||||||
|
hasNextPage: boolean;
|
||||||
|
endCursor?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
errors?: Array<{ message: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewThreadInfo {
|
||||||
|
isResolved: boolean;
|
||||||
|
threadId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Logger ──
|
||||||
|
|
||||||
|
const logger = createLogger('PRReviewCommentsService');
|
||||||
|
|
||||||
|
// ── Service functions ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a GraphQL query via the `gh` CLI and return the parsed response.
|
||||||
|
*/
|
||||||
|
async function executeGraphQL(projectPath: string, requestBody: string): Promise<GraphQLResponse> {
|
||||||
|
let timeoutId: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
const response = await new Promise<GraphQLResponse>((resolve, reject) => {
|
||||||
|
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
|
||||||
|
cwd: projectPath,
|
||||||
|
env: execEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
gh.on('error', (err) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
gh.kill();
|
||||||
|
reject(new Error('GitHub GraphQL API request timed out'));
|
||||||
|
}, GITHUB_API_TIMEOUT_MS);
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
|
||||||
|
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
|
||||||
|
|
||||||
|
gh.on('close', (code) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (code !== 0) {
|
||||||
|
return reject(new Error(`gh process exited with code ${code}: ${stderr}`));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(stdout));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gh.stdin.on('error', () => {
|
||||||
|
// Ignore stdin errors (e.g. when the child process is killed)
|
||||||
|
});
|
||||||
|
gh.stdin.write(requestBody);
|
||||||
|
gh.stdin.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.errors && response.errors.length > 0) {
|
||||||
|
throw new Error(response.errors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch review thread resolved status and thread IDs using GitHub GraphQL API.
|
||||||
|
* Uses cursor-based pagination to handle PRs with more than 100 review threads.
|
||||||
|
* Returns a map of comment ID (string) -> { isResolved, threadId }.
|
||||||
|
*/
|
||||||
|
export async function fetchReviewThreadResolvedStatus(
|
||||||
|
projectPath: string,
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
prNumber: number
|
||||||
|
): Promise<Map<string, ReviewThreadInfo>> {
|
||||||
|
const resolvedMap = new Map<string, ReviewThreadInfo>();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query GetPRReviewThreads(
|
||||||
|
$owner: String!
|
||||||
|
$repo: String!
|
||||||
|
$prNumber: Int!
|
||||||
|
$cursor: String
|
||||||
|
) {
|
||||||
|
repository(owner: $owner, name: $repo) {
|
||||||
|
pullRequest(number: $prNumber) {
|
||||||
|
reviewThreads(first: 100, after: $cursor) {
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
isResolved
|
||||||
|
comments(first: 100) {
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
nodes {
|
||||||
|
databaseId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let cursor: string | null = null;
|
||||||
|
let pageCount = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const variables = { owner, repo, prNumber, cursor };
|
||||||
|
const requestBody = JSON.stringify({ query, variables });
|
||||||
|
const response = await executeGraphQL(projectPath, requestBody);
|
||||||
|
|
||||||
|
const reviewThreads = response.data?.repository?.pullRequest?.reviewThreads;
|
||||||
|
const threads = reviewThreads?.nodes ?? [];
|
||||||
|
|
||||||
|
for (const thread of threads) {
|
||||||
|
if (thread.comments.pageInfo?.hasNextPage) {
|
||||||
|
logger.debug(
|
||||||
|
`Review thread ${thread.id} in PR #${prNumber} has >100 comments — ` +
|
||||||
|
'some comments may be missing resolved status'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const info: ReviewThreadInfo = { isResolved: thread.isResolved, threadId: thread.id };
|
||||||
|
for (const comment of thread.comments.nodes) {
|
||||||
|
resolvedMap.set(String(comment.databaseId), info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageInfo = reviewThreads?.pageInfo;
|
||||||
|
if (pageInfo?.hasNextPage && pageInfo.endCursor) {
|
||||||
|
cursor = pageInfo.endCursor;
|
||||||
|
pageCount++;
|
||||||
|
logger.debug(
|
||||||
|
`Fetching next page of review threads for PR #${prNumber} (page ${pageCount + 1})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
cursor = null;
|
||||||
|
}
|
||||||
|
} while (cursor && pageCount < MAX_PAGINATION_PAGES);
|
||||||
|
|
||||||
|
if (pageCount >= MAX_PAGINATION_PAGES) {
|
||||||
|
logger.warn(
|
||||||
|
`PR #${prNumber} in ${owner}/${repo} has more than ${MAX_PAGINATION_PAGES * 100} review threads — ` +
|
||||||
|
'pagination limit reached. Some comments may be missing resolved status.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Log but don't fail — resolved status is best-effort
|
||||||
|
logError(error, 'Failed to fetch PR review thread resolved status');
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all comments for a PR (regular, inline review, and review body comments)
|
||||||
|
*/
|
||||||
|
export async function fetchPRReviewComments(
|
||||||
|
projectPath: string,
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
prNumber: number
|
||||||
|
): Promise<PRReviewComment[]> {
|
||||||
|
const allComments: PRReviewComment[] = [];
|
||||||
|
|
||||||
|
// Fetch review thread resolved status in parallel with comment fetching
|
||||||
|
const resolvedStatusPromise = fetchReviewThreadResolvedStatus(projectPath, owner, repo, prNumber);
|
||||||
|
|
||||||
|
// 1. Fetch regular PR comments (issue-level comments)
|
||||||
|
// Uses the REST API issues endpoint instead of `gh pr view --json comments`
|
||||||
|
// because the latter uses GraphQL internally where bot/app authors can return
|
||||||
|
// null, causing bot comments to be silently dropped or display as "unknown".
|
||||||
|
try {
|
||||||
|
const issueCommentsEndpoint = `repos/${owner}/${repo}/issues/${prNumber}/comments`;
|
||||||
|
const { stdout: commentsOutput } = await execFileAsync(
|
||||||
|
'gh',
|
||||||
|
['api', issueCommentsEndpoint, '--paginate'],
|
||||||
|
{
|
||||||
|
cwd: projectPath,
|
||||||
|
env: execEnv,
|
||||||
|
maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large PRs
|
||||||
|
timeout: GITHUB_API_TIMEOUT_MS,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const commentsData = JSON.parse(commentsOutput);
|
||||||
|
const regularComments = (Array.isArray(commentsData) ? commentsData : []).map(
|
||||||
|
(c: {
|
||||||
|
id: number;
|
||||||
|
user: { login: string; avatar_url?: string; type?: string } | null;
|
||||||
|
body: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
performed_via_github_app?: { slug: string } | null;
|
||||||
|
}) => ({
|
||||||
|
id: String(c.id),
|
||||||
|
author: c.user?.login || c.performed_via_github_app?.slug || 'unknown',
|
||||||
|
avatarUrl: c.user?.avatar_url,
|
||||||
|
body: c.body,
|
||||||
|
createdAt: c.created_at,
|
||||||
|
updatedAt: c.updated_at,
|
||||||
|
isReviewComment: false,
|
||||||
|
isOutdated: false,
|
||||||
|
isBot: c.user?.type === 'Bot' || !!c.performed_via_github_app,
|
||||||
|
// Regular PR comments are not part of review threads, so not resolvable
|
||||||
|
isResolved: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
allComments.push(...regularComments);
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Failed to fetch regular PR comments');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch inline review comments (code-level comments with file/line info)
|
||||||
|
try {
|
||||||
|
const reviewsEndpoint = `repos/${owner}/${repo}/pulls/${prNumber}/comments`;
|
||||||
|
const { stdout: reviewsOutput } = await execFileAsync(
|
||||||
|
'gh',
|
||||||
|
['api', reviewsEndpoint, '--paginate'],
|
||||||
|
{
|
||||||
|
cwd: projectPath,
|
||||||
|
env: execEnv,
|
||||||
|
maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large PRs
|
||||||
|
timeout: GITHUB_API_TIMEOUT_MS,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const reviewsData = JSON.parse(reviewsOutput);
|
||||||
|
const reviewComments = (Array.isArray(reviewsData) ? reviewsData : []).map(
|
||||||
|
(c: {
|
||||||
|
id: number;
|
||||||
|
user: { login: string; avatar_url?: string; type?: string } | null;
|
||||||
|
body: string;
|
||||||
|
path: string;
|
||||||
|
line?: number;
|
||||||
|
original_line?: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
diff_hunk?: string;
|
||||||
|
side?: string;
|
||||||
|
commit_id?: string;
|
||||||
|
position?: number | null;
|
||||||
|
performed_via_github_app?: { slug: string } | null;
|
||||||
|
}) => ({
|
||||||
|
id: String(c.id),
|
||||||
|
author: c.user?.login || c.performed_via_github_app?.slug || 'unknown',
|
||||||
|
avatarUrl: c.user?.avatar_url,
|
||||||
|
body: c.body,
|
||||||
|
path: c.path,
|
||||||
|
line: c.line ?? c.original_line,
|
||||||
|
createdAt: c.created_at,
|
||||||
|
updatedAt: c.updated_at,
|
||||||
|
isReviewComment: true,
|
||||||
|
// A review comment is "outdated" if position is null (code has changed)
|
||||||
|
isOutdated: c.position === null,
|
||||||
|
// isResolved will be filled in below from GraphQL data
|
||||||
|
isResolved: false,
|
||||||
|
isBot: c.user?.type === 'Bot' || !!c.performed_via_github_app,
|
||||||
|
diffHunk: c.diff_hunk,
|
||||||
|
side: c.side,
|
||||||
|
commitId: c.commit_id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
allComments.push(...reviewComments);
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Failed to fetch inline review comments');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fetch review body comments (summary text submitted with each review)
|
||||||
|
// These are the top-level comments written when submitting a review
|
||||||
|
// (Approve, Request Changes, Comment). They are separate from inline code comments
|
||||||
|
// and issue-level comments. Only include reviews that have a non-empty body.
|
||||||
|
try {
|
||||||
|
const reviewsEndpoint = `repos/${owner}/${repo}/pulls/${prNumber}/reviews`;
|
||||||
|
const { stdout: reviewBodiesOutput } = await execFileAsync(
|
||||||
|
'gh',
|
||||||
|
['api', reviewsEndpoint, '--paginate'],
|
||||||
|
{
|
||||||
|
cwd: projectPath,
|
||||||
|
env: execEnv,
|
||||||
|
maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large PRs
|
||||||
|
timeout: GITHUB_API_TIMEOUT_MS,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const reviewBodiesData = JSON.parse(reviewBodiesOutput);
|
||||||
|
const reviewBodyComments = (Array.isArray(reviewBodiesData) ? reviewBodiesData : [])
|
||||||
|
.filter(
|
||||||
|
(r: { body?: string; state?: string }) =>
|
||||||
|
r.body && r.body.trim().length > 0 && r.state !== 'PENDING'
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(r: {
|
||||||
|
id: number;
|
||||||
|
user: { login: string; avatar_url?: string; type?: string } | null;
|
||||||
|
body: string;
|
||||||
|
state: string;
|
||||||
|
submitted_at: string;
|
||||||
|
performed_via_github_app?: { slug: string } | null;
|
||||||
|
}) => ({
|
||||||
|
id: `review-${r.id}`,
|
||||||
|
author: r.user?.login || r.performed_via_github_app?.slug || 'unknown',
|
||||||
|
avatarUrl: r.user?.avatar_url,
|
||||||
|
body: r.body,
|
||||||
|
createdAt: r.submitted_at,
|
||||||
|
isReviewComment: false,
|
||||||
|
isOutdated: false,
|
||||||
|
isResolved: false,
|
||||||
|
isBot: r.user?.type === 'Bot' || !!r.performed_via_github_app,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
allComments.push(...reviewBodyComments);
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Failed to fetch review body comments');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for resolved status and apply to inline review comments
|
||||||
|
const resolvedMap = await resolvedStatusPromise;
|
||||||
|
for (const comment of allComments) {
|
||||||
|
if (comment.isReviewComment && resolvedMap.has(comment.id)) {
|
||||||
|
const info = resolvedMap.get(comment.id)!;
|
||||||
|
comment.isResolved = info.isResolved;
|
||||||
|
comment.threadId = info.threadId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by createdAt descending (newest first)
|
||||||
|
allComments.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
|
||||||
|
return allComments;
|
||||||
|
}
|
||||||
@@ -198,7 +198,7 @@ describe('claude-provider.ts', () => {
|
|||||||
expect(typeof callArgs.prompt).not.toBe('string');
|
expect(typeof callArgs.prompt).not.toBe('string');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use maxTurns default of 100', async () => {
|
it('should use maxTurns default of 1000', async () => {
|
||||||
vi.mocked(sdk.query).mockReturnValue(
|
vi.mocked(sdk.query).mockReturnValue(
|
||||||
(async function* () {
|
(async function* () {
|
||||||
yield { type: 'text', text: 'test' };
|
yield { type: 'text', text: 'test' };
|
||||||
@@ -216,7 +216,7 @@ describe('claude-provider.ts', () => {
|
|||||||
expect(sdk.query).toHaveBeenCalledWith({
|
expect(sdk.query).toHaveBeenCalledWith({
|
||||||
prompt: 'Test',
|
prompt: 'Test',
|
||||||
options: expect.objectContaining({
|
options: expect.objectContaining({
|
||||||
maxTurns: 100,
|
maxTurns: 1000,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
580
apps/server/tests/unit/services/event-hook-service.test.ts
Normal file
580
apps/server/tests/unit/services/event-hook-service.test.ts
Normal file
@@ -0,0 +1,580 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { EventHookService } from '../../../src/services/event-hook-service.js';
|
||||||
|
import type { EventEmitter, EventCallback, EventType } from '../../../src/lib/events.js';
|
||||||
|
import type { SettingsService } from '../../../src/services/settings-service.js';
|
||||||
|
import type { EventHistoryService } from '../../../src/services/event-history-service.js';
|
||||||
|
import type { FeatureLoader } from '../../../src/services/feature-loader.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock EventEmitter for testing
|
||||||
|
*/
|
||||||
|
function createMockEventEmitter(): EventEmitter & {
|
||||||
|
subscribers: Set<EventCallback>;
|
||||||
|
simulateEvent: (type: EventType, payload: unknown) => void;
|
||||||
|
} {
|
||||||
|
const subscribers = new Set<EventCallback>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribers,
|
||||||
|
emit(type: EventType, payload: unknown) {
|
||||||
|
for (const callback of subscribers) {
|
||||||
|
callback(type, payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
subscribe(callback: EventCallback) {
|
||||||
|
subscribers.add(callback);
|
||||||
|
return () => {
|
||||||
|
subscribers.delete(callback);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
simulateEvent(type: EventType, payload: unknown) {
|
||||||
|
for (const callback of subscribers) {
|
||||||
|
callback(type, payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock SettingsService
|
||||||
|
*/
|
||||||
|
function createMockSettingsService(hooks: unknown[] = []): SettingsService {
|
||||||
|
return {
|
||||||
|
getGlobalSettings: vi.fn().mockResolvedValue({ eventHooks: hooks }),
|
||||||
|
} as unknown as SettingsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock EventHistoryService
|
||||||
|
*/
|
||||||
|
function createMockEventHistoryService() {
|
||||||
|
return {
|
||||||
|
storeEvent: vi.fn().mockResolvedValue({ id: 'test-event-id' }),
|
||||||
|
} as unknown as EventHistoryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock FeatureLoader
|
||||||
|
*/
|
||||||
|
function createMockFeatureLoader(features: Record<string, { title: string }> = {}) {
|
||||||
|
return {
|
||||||
|
get: vi.fn().mockImplementation((_projectPath: string, featureId: string) => {
|
||||||
|
return Promise.resolve(features[featureId] || null);
|
||||||
|
}),
|
||||||
|
} as unknown as FeatureLoader;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EventHookService', () => {
|
||||||
|
let service: EventHookService;
|
||||||
|
let mockEmitter: ReturnType<typeof createMockEventEmitter>;
|
||||||
|
let mockSettingsService: ReturnType<typeof createMockSettingsService>;
|
||||||
|
let mockEventHistoryService: ReturnType<typeof createMockEventHistoryService>;
|
||||||
|
let mockFeatureLoader: ReturnType<typeof createMockFeatureLoader>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new EventHookService();
|
||||||
|
mockEmitter = createMockEventEmitter();
|
||||||
|
mockSettingsService = createMockSettingsService();
|
||||||
|
mockEventHistoryService = createMockEventHistoryService();
|
||||||
|
mockFeatureLoader = createMockFeatureLoader();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
service.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initialize', () => {
|
||||||
|
it('should subscribe to the event emitter', () => {
|
||||||
|
service.initialize(mockEmitter, mockSettingsService, mockEventHistoryService);
|
||||||
|
expect(mockEmitter.subscribers.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log initialization', () => {
|
||||||
|
service.initialize(mockEmitter, mockSettingsService);
|
||||||
|
expect(mockEmitter.subscribers.size).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('destroy', () => {
|
||||||
|
it('should unsubscribe from the event emitter', () => {
|
||||||
|
service.initialize(mockEmitter, mockSettingsService);
|
||||||
|
expect(mockEmitter.subscribers.size).toBe(1);
|
||||||
|
|
||||||
|
service.destroy();
|
||||||
|
expect(mockEmitter.subscribers.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('event mapping - auto_mode_feature_complete', () => {
|
||||||
|
it('should map to feature_success when passes is true', async () => {
|
||||||
|
service.initialize(
|
||||||
|
mockEmitter,
|
||||||
|
mockSettingsService,
|
||||||
|
mockEventHistoryService,
|
||||||
|
mockFeatureLoader
|
||||||
|
);
|
||||||
|
|
||||||
|
mockEmitter.simulateEvent('auto-mode:event', {
|
||||||
|
type: 'auto_mode_feature_complete',
|
||||||
|
featureId: 'feat-1',
|
||||||
|
featureName: 'Test Feature',
|
||||||
|
passes: true,
|
||||||
|
message: 'Feature completed in 30s',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow async processing
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||||
|
.calls[0][0];
|
||||||
|
expect(storeCall.trigger).toBe('feature_success');
|
||||||
|
expect(storeCall.passes).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map to feature_error when passes is false', async () => {
|
||||||
|
service.initialize(
|
||||||
|
mockEmitter,
|
||||||
|
mockSettingsService,
|
||||||
|
mockEventHistoryService,
|
||||||
|
mockFeatureLoader
|
||||||
|
);
|
||||||
|
|
||||||
|
mockEmitter.simulateEvent('auto-mode:event', {
|
||||||
|
type: 'auto_mode_feature_complete',
|
||||||
|
featureId: 'feat-1',
|
||||||
|
featureName: 'Test Feature',
|
||||||
|
passes: false,
|
||||||
|
message: 'Feature stopped by user',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||||
|
.calls[0][0];
|
||||||
|
expect(storeCall.trigger).toBe('feature_error');
|
||||||
|
expect(storeCall.passes).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT populate error field for successful feature completion', async () => {
|
||||||
|
service.initialize(
|
||||||
|
mockEmitter,
|
||||||
|
mockSettingsService,
|
||||||
|
mockEventHistoryService,
|
||||||
|
mockFeatureLoader
|
||||||
|
);
|
||||||
|
|
||||||
|
mockEmitter.simulateEvent('auto-mode:event', {
|
||||||
|
type: 'auto_mode_feature_complete',
|
||||||
|
featureId: 'feat-1',
|
||||||
|
featureName: 'Test Feature',
|
||||||
|
passes: true,
|
||||||
|
message: 'Feature completed in 30s - auto-verified',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||||
|
.calls[0][0];
|
||||||
|
expect(storeCall.trigger).toBe('feature_success');
|
||||||
|
// Critical: error should NOT contain the success message
|
||||||
|
expect(storeCall.error).toBeUndefined();
|
||||||
|
expect(storeCall.errorType).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should populate error field for failed feature completion', async () => {
|
||||||
|
service.initialize(
|
||||||
|
mockEmitter,
|
||||||
|
mockSettingsService,
|
||||||
|
mockEventHistoryService,
|
||||||
|
mockFeatureLoader
|
||||||
|
);
|
||||||
|
|
||||||
|
mockEmitter.simulateEvent('auto-mode:event', {
|
||||||
|
type: 'auto_mode_feature_complete',
|
||||||
|
featureId: 'feat-1',
|
||||||
|
featureName: 'Test Feature',
|
||||||
|
passes: false,
|
||||||
|
message: 'Feature stopped by user',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||||
|
.calls[0][0];
|
||||||
|
expect(storeCall.trigger).toBe('feature_error');
|
||||||
|
// Error field should be populated for error triggers
|
||||||
|
expect(storeCall.error).toBe('Feature stopped by user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('event mapping - auto_mode_error', () => {
|
||||||
|
it('should map to feature_error when featureId is present', async () => {
|
||||||
|
service.initialize(
|
||||||
|
mockEmitter,
|
||||||
|
mockSettingsService,
|
||||||
|
mockEventHistoryService,
|
||||||
|
mockFeatureLoader
|
||||||
|
);
|
||||||
|
|
||||||
|
mockEmitter.simulateEvent('auto-mode:event', {
|
||||||
|
type: 'auto_mode_error',
|
||||||
|
featureId: 'feat-1',
|
||||||
|
error: 'Network timeout',
|
||||||
|
errorType: 'network',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||||
|
.calls[0][0];
|
||||||
|
expect(storeCall.trigger).toBe('feature_error');
|
||||||
|
expect(storeCall.error).toBe('Network timeout');
|
||||||
|
expect(storeCall.errorType).toBe('network');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map to auto_mode_error when featureId is not present', async () => {
|
||||||
|
service.initialize(
|
||||||
|
mockEmitter,
|
||||||
|
mockSettingsService,
|
||||||
|
mockEventHistoryService,
|
||||||
|
mockFeatureLoader
|
||||||
|
);
|
||||||
|
|
||||||
|
mockEmitter.simulateEvent('auto-mode:event', {
|
||||||
|
type: 'auto_mode_error',
|
||||||
|
error: 'System error',
|
||||||
|
errorType: 'system',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||||
|
.calls[0][0];
|
||||||
|
expect(storeCall.trigger).toBe('auto_mode_error');
|
||||||
|
expect(storeCall.error).toBe('System error');
|
||||||
|
expect(storeCall.errorType).toBe('system');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('event mapping - auto_mode_idle', () => {
|
||||||
|
it('should map to auto_mode_complete', async () => {
|
||||||
|
service.initialize(
|
||||||
|
mockEmitter,
|
||||||
|
mockSettingsService,
|
||||||
|
mockEventHistoryService,
|
||||||
|
mockFeatureLoader
|
||||||
|
);
|
||||||
|
|
||||||
|
mockEmitter.simulateEvent('auto-mode:event', {
|
||||||
|
type: 'auto_mode_idle',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||||
|
.calls[0][0];
|
||||||
|
expect(storeCall.trigger).toBe('auto_mode_complete');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('event mapping - feature:created', () => {
|
||||||
|
it('should trigger feature_created hook', async () => {
|
||||||
|
service.initialize(
|
||||||
|
mockEmitter,
|
||||||
|
mockSettingsService,
|
||||||
|
mockEventHistoryService,
|
||||||
|
mockFeatureLoader
|
||||||
|
);
|
||||||
|
|
||||||
|
mockEmitter.simulateEvent('feature:created', {
|
||||||
|
featureId: 'feat-1',
|
||||||
|
featureName: 'New Feature',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||||
|
.calls[0][0];
|
||||||
|
expect(storeCall.trigger).toBe('feature_created');
|
||||||
|
expect(storeCall.featureId).toBe('feat-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('event mapping - unhandled events', () => {
|
||||||
|
it('should ignore auto-mode events with unrecognized types', async () => {
|
||||||
|
service.initialize(
|
||||||
|
mockEmitter,
|
||||||
|
mockSettingsService,
|
||||||
|
mockEventHistoryService,
|
||||||
|
mockFeatureLoader
|
||||||
|
);
|
||||||
|
|
||||||
|
mockEmitter.simulateEvent('auto-mode:event', {
|
||||||
|
type: 'auto_mode_progress',
|
||||||
|
featureId: 'feat-1',
|
||||||
|
content: 'Working...',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Give it time to process
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(mockEventHistoryService.storeEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore events without a type', async () => {
|
||||||
|
service.initialize(
|
||||||
|
mockEmitter,
|
||||||
|
mockSettingsService,
|
||||||
|
mockEventHistoryService,
|
||||||
|
mockFeatureLoader
|
||||||
|
);
|
||||||
|
|
||||||
|
mockEmitter.simulateEvent('auto-mode:event', {
|
||||||
|
featureId: 'feat-1',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(mockEventHistoryService.storeEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hook execution', () => {
|
||||||
|
it('should execute matching enabled hooks for feature_success', async () => {
|
||||||
|
const hooks = [
|
||||||
|
{
|
||||||
|
id: 'hook-1',
|
||||||
|
enabled: true,
|
||||||
|
trigger: 'feature_success',
|
||||||
|
name: 'Success Hook',
|
||||||
|
action: {
|
||||||
|
type: 'shell',
|
||||||
|
command: 'echo "success"',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hook-2',
|
||||||
|
enabled: true,
|
||||||
|
trigger: 'feature_error',
|
||||||
|
name: 'Error Hook',
|
||||||
|
action: {
|
||||||
|
type: 'shell',
|
||||||
|
command: 'echo "error"',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockSettingsService = createMockSettingsService(hooks);
|
||||||
|
service.initialize(
|
||||||
|
mockEmitter,
|
||||||
|
mockSettingsService,
|
||||||
|
mockEventHistoryService,
|
||||||
|
mockFeatureLoader
|
||||||
|
);
|
||||||
|
|
||||||
|
mockEmitter.simulateEvent('auto-mode:event', {
|
||||||
|
type: 'auto_mode_feature_complete',
|
||||||
|
featureId: 'feat-1',
|
||||||
|
featureName: 'Test Feature',
|
||||||
|
passes: true,
|
||||||
|
message: 'Feature completed in 30s',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockSettingsService.getGlobalSettings).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The error hook should NOT have been triggered for a success event
|
||||||
|
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||||
|
.calls[0][0];
|
||||||
|
expect(storeCall.trigger).toBe('feature_success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT execute error hooks when feature completes successfully', async () => {
|
||||||
|
// This is the key regression test for the bug:
|
||||||
|
// "Error event hook fired when a feature completes successfully"
|
||||||
|
const errorHookCommand = vi.fn();
|
||||||
|
const hooks = [
|
||||||
|
{
|
||||||
|
id: 'hook-error',
|
||||||
|
enabled: true,
|
||||||
|
trigger: 'feature_error',
|
||||||
|
name: 'Error Notification',
|
||||||
|
action: {
|
||||||
|
type: 'shell',
|
||||||
|
command: 'echo "ERROR FIRED"',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockSettingsService = createMockSettingsService(hooks);
|
||||||
|
service.initialize(
|
||||||
|
mockEmitter,
|
||||||
|
mockSettingsService,
|
||||||
|
mockEventHistoryService,
|
||||||
|
mockFeatureLoader
|
||||||
|
);
|
||||||
|
|
||||||
|
mockEmitter.simulateEvent('auto-mode:event', {
|
||||||
|
type: 'auto_mode_feature_complete',
|
||||||
|
featureId: 'feat-1',
|
||||||
|
featureName: 'Test Feature',
|
||||||
|
passes: true,
|
||||||
|
message: 'Feature completed in 30s - auto-verified',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the trigger was feature_success, not feature_error
|
||||||
|
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||||
|
.calls[0][0];
|
||||||
|
expect(storeCall.trigger).toBe('feature_success');
|
||||||
|
// And no error information should be present
|
||||||
|
expect(storeCall.error).toBeUndefined();
|
||||||
|
expect(storeCall.errorType).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('feature name loading', () => {
|
||||||
|
it('should load feature name from feature loader when not in payload', async () => {
|
||||||
|
mockFeatureLoader = createMockFeatureLoader({
|
||||||
|
'feat-1': { title: 'Loaded Feature Title' },
|
||||||
|
});
|
||||||
|
|
||||||
|
service.initialize(
|
||||||
|
mockEmitter,
|
||||||
|
mockSettingsService,
|
||||||
|
mockEventHistoryService,
|
||||||
|
mockFeatureLoader
|
||||||
|
);
|
||||||
|
|
||||||
|
mockEmitter.simulateEvent('auto-mode:event', {
|
||||||
|
type: 'auto_mode_feature_complete',
|
||||||
|
featureId: 'feat-1',
|
||||||
|
passes: true,
|
||||||
|
message: 'Done',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||||
|
.calls[0][0];
|
||||||
|
expect(storeCall.featureName).toBe('Loaded Feature Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to payload featureName when loader fails', async () => {
|
||||||
|
mockFeatureLoader = createMockFeatureLoader({}); // Empty - no features found
|
||||||
|
|
||||||
|
service.initialize(
|
||||||
|
mockEmitter,
|
||||||
|
mockSettingsService,
|
||||||
|
mockEventHistoryService,
|
||||||
|
mockFeatureLoader
|
||||||
|
);
|
||||||
|
|
||||||
|
mockEmitter.simulateEvent('auto-mode:event', {
|
||||||
|
type: 'auto_mode_feature_complete',
|
||||||
|
featureId: 'feat-1',
|
||||||
|
featureName: 'Fallback Name',
|
||||||
|
passes: true,
|
||||||
|
message: 'Done',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||||
|
.calls[0][0];
|
||||||
|
expect(storeCall.featureName).toBe('Fallback Name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error context for error events', () => {
|
||||||
|
it('should use payload.error when available for error triggers', async () => {
|
||||||
|
service.initialize(
|
||||||
|
mockEmitter,
|
||||||
|
mockSettingsService,
|
||||||
|
mockEventHistoryService,
|
||||||
|
mockFeatureLoader
|
||||||
|
);
|
||||||
|
|
||||||
|
mockEmitter.simulateEvent('auto-mode:event', {
|
||||||
|
type: 'auto_mode_error',
|
||||||
|
featureId: 'feat-1',
|
||||||
|
error: 'Authentication failed',
|
||||||
|
errorType: 'auth',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||||
|
.calls[0][0];
|
||||||
|
expect(storeCall.error).toBe('Authentication failed');
|
||||||
|
expect(storeCall.errorType).toBe('auth');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to payload.message for error field in error triggers', async () => {
|
||||||
|
service.initialize(
|
||||||
|
mockEmitter,
|
||||||
|
mockSettingsService,
|
||||||
|
mockEventHistoryService,
|
||||||
|
mockFeatureLoader
|
||||||
|
);
|
||||||
|
|
||||||
|
mockEmitter.simulateEvent('auto-mode:event', {
|
||||||
|
type: 'auto_mode_feature_complete',
|
||||||
|
featureId: 'feat-1',
|
||||||
|
passes: false,
|
||||||
|
message: 'Feature stopped by user',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeCall = (mockEventHistoryService.storeEvent as ReturnType<typeof vi.fn>).mock
|
||||||
|
.calls[0][0];
|
||||||
|
expect(storeCall.trigger).toBe('feature_error');
|
||||||
|
expect(storeCall.error).toBe('Feature stopped by user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -175,7 +175,10 @@ describe('execution-service.ts', () => {
|
|||||||
} as unknown as TypedEventBus;
|
} as unknown as TypedEventBus;
|
||||||
|
|
||||||
mockConcurrencyManager = {
|
mockConcurrencyManager = {
|
||||||
acquire: vi.fn().mockImplementation(({ featureId }) => createRunningFeature(featureId)),
|
acquire: vi.fn().mockImplementation(({ featureId, isAutoMode }) => ({
|
||||||
|
...createRunningFeature(featureId),
|
||||||
|
isAutoMode: isAutoMode ?? false,
|
||||||
|
})),
|
||||||
release: vi.fn(),
|
release: vi.fn(),
|
||||||
getRunningFeature: vi.fn(),
|
getRunningFeature: vi.fn(),
|
||||||
isRunning: vi.fn(),
|
isRunning: vi.fn(),
|
||||||
@@ -550,8 +553,8 @@ describe('execution-service.ts', () => {
|
|||||||
expect(mockRunAgentFn).not.toHaveBeenCalled();
|
expect(mockRunAgentFn).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits feature_complete event on success', async () => {
|
it('emits feature_complete event on success when isAutoMode is true', async () => {
|
||||||
await service.executeFeature('/test/project', 'feature-1');
|
await service.executeFeature('/test/project', 'feature-1', false, true);
|
||||||
|
|
||||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
||||||
'auto_mode_feature_complete',
|
'auto_mode_feature_complete',
|
||||||
@@ -561,6 +564,15 @@ describe('execution-service.ts', () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not emit feature_complete event on success when isAutoMode is false', async () => {
|
||||||
|
await service.executeFeature('/test/project', 'feature-1', false, false);
|
||||||
|
|
||||||
|
const completeCalls = vi
|
||||||
|
.mocked(mockEventBus.emitAutoModeEvent)
|
||||||
|
.mock.calls.filter((call) => call[0] === 'auto_mode_feature_complete');
|
||||||
|
expect(completeCalls.length).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('executeFeature - approved plan handling', () => {
|
describe('executeFeature - approved plan handling', () => {
|
||||||
@@ -1110,7 +1122,7 @@ describe('execution-service.ts', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles abort signal without error event', async () => {
|
it('handles abort signal without error event (emits feature_complete when isAutoMode=true)', async () => {
|
||||||
const abortError = new Error('abort');
|
const abortError = new Error('abort');
|
||||||
abortError.name = 'AbortError';
|
abortError.name = 'AbortError';
|
||||||
mockRunAgentFn = vi.fn().mockRejectedValue(abortError);
|
mockRunAgentFn = vi.fn().mockRejectedValue(abortError);
|
||||||
@@ -1136,7 +1148,7 @@ describe('execution-service.ts', () => {
|
|||||||
mockLoadContextFilesFn
|
mockLoadContextFilesFn
|
||||||
);
|
);
|
||||||
|
|
||||||
await svc.executeFeature('/test/project', 'feature-1');
|
await svc.executeFeature('/test/project', 'feature-1', false, true);
|
||||||
|
|
||||||
// Should emit feature_complete with stopped by user
|
// Should emit feature_complete with stopped by user
|
||||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
||||||
@@ -1155,6 +1167,47 @@ describe('execution-service.ts', () => {
|
|||||||
expect(errorCalls.length).toBe(0);
|
expect(errorCalls.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles abort signal without emitting feature_complete when isAutoMode=false', async () => {
|
||||||
|
const abortError = new Error('abort');
|
||||||
|
abortError.name = 'AbortError';
|
||||||
|
mockRunAgentFn = vi.fn().mockRejectedValue(abortError);
|
||||||
|
|
||||||
|
const svc = new ExecutionService(
|
||||||
|
mockEventBus,
|
||||||
|
mockConcurrencyManager,
|
||||||
|
mockWorktreeResolver,
|
||||||
|
mockSettingsService,
|
||||||
|
mockRunAgentFn,
|
||||||
|
mockExecutePipelineFn,
|
||||||
|
mockUpdateFeatureStatusFn,
|
||||||
|
mockLoadFeatureFn,
|
||||||
|
mockGetPlanningPromptPrefixFn,
|
||||||
|
mockSaveFeatureSummaryFn,
|
||||||
|
mockRecordLearningsFn,
|
||||||
|
mockContextExistsFn,
|
||||||
|
mockResumeFeatureFn,
|
||||||
|
mockTrackFailureFn,
|
||||||
|
mockSignalPauseFn,
|
||||||
|
mockRecordSuccessFn,
|
||||||
|
mockSaveExecutionStateFn,
|
||||||
|
mockLoadContextFilesFn
|
||||||
|
);
|
||||||
|
|
||||||
|
await svc.executeFeature('/test/project', 'feature-1', false, false);
|
||||||
|
|
||||||
|
// Should NOT emit feature_complete when isAutoMode is false
|
||||||
|
const completeCalls = vi
|
||||||
|
.mocked(mockEventBus.emitAutoModeEvent)
|
||||||
|
.mock.calls.filter((call) => call[0] === 'auto_mode_feature_complete');
|
||||||
|
expect(completeCalls.length).toBe(0);
|
||||||
|
|
||||||
|
// Should NOT emit error event (abort is not an error)
|
||||||
|
const errorCalls = vi
|
||||||
|
.mocked(mockEventBus.emitAutoModeEvent)
|
||||||
|
.mock.calls.filter((call) => call[0] === 'auto_mode_error');
|
||||||
|
expect(errorCalls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('releases running feature even on error', async () => {
|
it('releases running feature even on error', async () => {
|
||||||
const testError = new Error('Test error');
|
const testError = new Error('Test error');
|
||||||
mockRunAgentFn = vi.fn().mockRejectedValue(testError);
|
mockRunAgentFn = vi.fn().mockRejectedValue(testError);
|
||||||
@@ -1339,8 +1392,8 @@ describe('execution-service.ts', () => {
|
|||||||
it('handles missing agent output gracefully', async () => {
|
it('handles missing agent output gracefully', async () => {
|
||||||
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
|
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||||
|
|
||||||
// Should not throw
|
// Should not throw (isAutoMode=true so event is emitted)
|
||||||
await service.executeFeature('/test/project', 'feature-1');
|
await service.executeFeature('/test/project', 'feature-1', false, true);
|
||||||
|
|
||||||
// Feature should still complete successfully
|
// Feature should still complete successfully
|
||||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -170,14 +170,16 @@ describe('PipelineOrchestrator', () => {
|
|||||||
} as unknown as WorktreeResolver;
|
} as unknown as WorktreeResolver;
|
||||||
|
|
||||||
mockConcurrencyManager = {
|
mockConcurrencyManager = {
|
||||||
acquire: vi.fn().mockReturnValue({
|
acquire: vi.fn().mockImplementation(({ featureId, isAutoMode }) => ({
|
||||||
featureId: 'feature-1',
|
featureId,
|
||||||
projectPath: '/test/project',
|
projectPath: '/test/project',
|
||||||
abortController: new AbortController(),
|
abortController: new AbortController(),
|
||||||
branchName: null,
|
branchName: null,
|
||||||
worktreePath: null,
|
worktreePath: null,
|
||||||
}),
|
isAutoMode: isAutoMode ?? false,
|
||||||
|
})),
|
||||||
release: vi.fn(),
|
release: vi.fn(),
|
||||||
|
getRunningFeature: vi.fn().mockReturnValue(undefined),
|
||||||
} as unknown as ConcurrencyManager;
|
} as unknown as ConcurrencyManager;
|
||||||
|
|
||||||
mockSettingsService = null;
|
mockSettingsService = null;
|
||||||
@@ -541,8 +543,18 @@ describe('PipelineOrchestrator', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit auto_mode_feature_complete on success', async () => {
|
it('should emit auto_mode_feature_complete on success when isAutoMode is true', async () => {
|
||||||
vi.mocked(performMerge).mockResolvedValue({ success: true });
|
vi.mocked(performMerge).mockResolvedValue({ success: true });
|
||||||
|
vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue({
|
||||||
|
featureId: 'feature-1',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
abortController: new AbortController(),
|
||||||
|
branchName: null,
|
||||||
|
worktreePath: null,
|
||||||
|
isAutoMode: true,
|
||||||
|
startTime: Date.now(),
|
||||||
|
leaseCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
const context = createMergeContext();
|
const context = createMergeContext();
|
||||||
await orchestrator.attemptMerge(context);
|
await orchestrator.attemptMerge(context);
|
||||||
@@ -553,6 +565,19 @@ describe('PipelineOrchestrator', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not emit auto_mode_feature_complete on success when isAutoMode is false', async () => {
|
||||||
|
vi.mocked(performMerge).mockResolvedValue({ success: true });
|
||||||
|
vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const context = createMergeContext();
|
||||||
|
await orchestrator.attemptMerge(context);
|
||||||
|
|
||||||
|
const completeCalls = vi
|
||||||
|
.mocked(mockEventBus.emitAutoModeEvent)
|
||||||
|
.mock.calls.filter((call) => call[0] === 'auto_mode_feature_complete');
|
||||||
|
expect(completeCalls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return needsAgentResolution true on conflict', async () => {
|
it('should return needsAgentResolution true on conflict', async () => {
|
||||||
vi.mocked(performMerge).mockResolvedValue({
|
vi.mocked(performMerge).mockResolvedValue({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -623,13 +648,24 @@ describe('PipelineOrchestrator', () => {
|
|||||||
expect(mockExecuteFeatureFn).toHaveBeenCalled();
|
expect(mockExecuteFeatureFn).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should complete feature when step no longer exists', async () => {
|
it('should complete feature when step no longer exists and emit event when isAutoMode=true', async () => {
|
||||||
const invalidPipelineInfo: PipelineStatusInfo = {
|
const invalidPipelineInfo: PipelineStatusInfo = {
|
||||||
...validPipelineInfo,
|
...validPipelineInfo,
|
||||||
stepIndex: -1,
|
stepIndex: -1,
|
||||||
step: null,
|
step: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue({
|
||||||
|
featureId: 'feature-1',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
abortController: new AbortController(),
|
||||||
|
branchName: null,
|
||||||
|
worktreePath: null,
|
||||||
|
isAutoMode: true,
|
||||||
|
startTime: Date.now(),
|
||||||
|
leaseCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
await orchestrator.resumePipeline('/test/project', testFeature, true, invalidPipelineInfo);
|
await orchestrator.resumePipeline('/test/project', testFeature, true, invalidPipelineInfo);
|
||||||
|
|
||||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||||
@@ -642,6 +678,28 @@ describe('PipelineOrchestrator', () => {
|
|||||||
expect.objectContaining({ message: expect.stringContaining('no longer exists') })
|
expect.objectContaining({ message: expect.stringContaining('no longer exists') })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not emit feature_complete when step no longer exists and isAutoMode=false', async () => {
|
||||||
|
const invalidPipelineInfo: PipelineStatusInfo = {
|
||||||
|
...validPipelineInfo,
|
||||||
|
stepIndex: -1,
|
||||||
|
step: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(undefined);
|
||||||
|
|
||||||
|
await orchestrator.resumePipeline('/test/project', testFeature, true, invalidPipelineInfo);
|
||||||
|
|
||||||
|
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||||
|
'/test/project',
|
||||||
|
'feature-1',
|
||||||
|
'verified'
|
||||||
|
);
|
||||||
|
const completeCalls = vi
|
||||||
|
.mocked(mockEventBus.emitAutoModeEvent)
|
||||||
|
.mock.calls.filter((call) => call[0] === 'auto_mode_feature_complete');
|
||||||
|
expect(completeCalls.length).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('resumeFromStep', () => {
|
describe('resumeFromStep', () => {
|
||||||
@@ -666,7 +724,7 @@ describe('PipelineOrchestrator', () => {
|
|||||||
expect(mockRunAgentFn).toHaveBeenCalled();
|
expect(mockRunAgentFn).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should complete feature when all remaining steps excluded', async () => {
|
it('should complete feature when all remaining steps excluded and emit event when isAutoMode=true', async () => {
|
||||||
const featureWithAllExcluded: Feature = {
|
const featureWithAllExcluded: Feature = {
|
||||||
...testFeature,
|
...testFeature,
|
||||||
excludedPipelineSteps: ['step-1', 'step-2'],
|
excludedPipelineSteps: ['step-1', 'step-2'],
|
||||||
@@ -674,6 +732,16 @@ describe('PipelineOrchestrator', () => {
|
|||||||
|
|
||||||
vi.mocked(pipelineService.getNextStatus).mockReturnValue('verified');
|
vi.mocked(pipelineService.getNextStatus).mockReturnValue('verified');
|
||||||
vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(false);
|
vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(false);
|
||||||
|
vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue({
|
||||||
|
featureId: 'feature-1',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
abortController: new AbortController(),
|
||||||
|
branchName: null,
|
||||||
|
worktreePath: null,
|
||||||
|
isAutoMode: true,
|
||||||
|
startTime: Date.now(),
|
||||||
|
leaseCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
await orchestrator.resumeFromStep(
|
await orchestrator.resumeFromStep(
|
||||||
'/test/project',
|
'/test/project',
|
||||||
@@ -1033,7 +1101,7 @@ describe('PipelineOrchestrator', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles all steps excluded during resume', async () => {
|
it('handles all steps excluded during resume and emits event when isAutoMode=true', async () => {
|
||||||
const featureWithAllExcluded: Feature = {
|
const featureWithAllExcluded: Feature = {
|
||||||
...testFeature,
|
...testFeature,
|
||||||
excludedPipelineSteps: ['step-1', 'step-2'],
|
excludedPipelineSteps: ['step-1', 'step-2'],
|
||||||
@@ -1041,6 +1109,16 @@ describe('PipelineOrchestrator', () => {
|
|||||||
|
|
||||||
vi.mocked(pipelineService.getNextStatus).mockReturnValue('verified');
|
vi.mocked(pipelineService.getNextStatus).mockReturnValue('verified');
|
||||||
vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(false);
|
vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(false);
|
||||||
|
vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue({
|
||||||
|
featureId: 'feature-1',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
abortController: new AbortController(),
|
||||||
|
branchName: null,
|
||||||
|
worktreePath: null,
|
||||||
|
isAutoMode: true,
|
||||||
|
startTime: Date.now(),
|
||||||
|
leaseCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
await orchestrator.resumeFromStep(
|
await orchestrator.resumeFromStep(
|
||||||
'/test/project',
|
'/test/project',
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ const eslintConfig = defineConfig([
|
|||||||
Electron: 'readonly',
|
Electron: 'readonly',
|
||||||
// Console
|
// Console
|
||||||
console: 'readonly',
|
console: 'readonly',
|
||||||
|
// Structured clone (modern browser/Node API)
|
||||||
|
structuredClone: 'readonly',
|
||||||
// Vite defines
|
// Vite defines
|
||||||
__APP_VERSION__: 'readonly',
|
__APP_VERSION__: 'readonly',
|
||||||
__APP_BUILD_HASH__: 'readonly',
|
__APP_BUILD_HASH__: 'readonly',
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
|
# Map for conditional WebSocket upgrade header
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Proxy API and WebSocket requests to the backend server container
|
||||||
|
# Handles both HTTP API calls and WebSocket upgrades (/api/events, /api/terminal/ws)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://server:3008;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,6 @@ export { FileBrowserDialog } from './file-browser-dialog';
|
|||||||
export { NewProjectModal } from './new-project-modal';
|
export { NewProjectModal } from './new-project-modal';
|
||||||
export { SandboxRejectionScreen } from './sandbox-rejection-screen';
|
export { SandboxRejectionScreen } from './sandbox-rejection-screen';
|
||||||
export { SandboxRiskDialog } from './sandbox-risk-dialog';
|
export { SandboxRiskDialog } from './sandbox-risk-dialog';
|
||||||
|
export { PRCommentResolutionDialog } from './pr-comment-resolution-dialog';
|
||||||
|
export type { PRCommentResolutionPRInfo } from './pr-comment-resolution-dialog';
|
||||||
export { WorkspacePickerModal } from './workspace-picker-modal';
|
export { WorkspacePickerModal } from './workspace-picker-modal';
|
||||||
|
|||||||
1148
apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx
Normal file
1148
apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect, startTransition } from 'react';
|
||||||
import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react';
|
import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react';
|
||||||
import { useNavigate, useLocation } from '@tanstack/react-router';
|
import { useNavigate, useLocation } from '@tanstack/react-router';
|
||||||
import { cn, isMac } from '@/lib/utils';
|
import { cn, isMac } from '@/lib/utils';
|
||||||
@@ -103,10 +103,26 @@ export function ProjectSwitcher() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleProjectClick = useCallback(
|
const handleProjectClick = useCallback(
|
||||||
(project: Project) => {
|
async (project: Project) => {
|
||||||
setCurrentProject(project);
|
try {
|
||||||
// Navigate to board view when switching projects
|
// Ensure .automaker directory structure exists before switching
|
||||||
navigate({ to: '/board' });
|
await initializeProject(project.path);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize project during switch:', error);
|
||||||
|
// Continue with switch even if initialization fails -
|
||||||
|
// the project may already be initialized
|
||||||
|
}
|
||||||
|
// Wrap in startTransition to let React batch the project switch and
|
||||||
|
// navigation into a single low-priority update. Without this, the two
|
||||||
|
// synchronous calls fire separate renders where currentProject points
|
||||||
|
// to the new project but per-project state (worktrees, features) is
|
||||||
|
// still stale, causing a cascade of effects and store mutations that
|
||||||
|
// can trigger React error #185 (maximum update depth exceeded).
|
||||||
|
startTransition(() => {
|
||||||
|
setCurrentProject(project);
|
||||||
|
// Navigate to board view when switching projects
|
||||||
|
navigate({ to: '/board' });
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[setCurrentProject, navigate]
|
[setCurrentProject, navigate]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Folder,
|
Folder,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -15,6 +16,8 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store';
|
import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store';
|
||||||
|
import { initializeProject } from '@/lib/project-init';
|
||||||
|
import type { Project } from '@/lib/electron';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -87,6 +90,22 @@ export function ProjectSelectorWithOptions({
|
|||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
|
// Wrap setCurrentProject to ensure .automaker is initialized before switching
|
||||||
|
const setCurrentProjectWithInit = useCallback(
|
||||||
|
async (p: Project) => {
|
||||||
|
try {
|
||||||
|
// Ensure .automaker directory structure exists before switching
|
||||||
|
await initializeProject(p.path);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize project during switch:', error);
|
||||||
|
// Continue with switch even if initialization fails -
|
||||||
|
// the project may already be initialized
|
||||||
|
}
|
||||||
|
setCurrentProject(p);
|
||||||
|
},
|
||||||
|
[setCurrentProject]
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
projectSearchQuery,
|
projectSearchQuery,
|
||||||
setProjectSearchQuery,
|
setProjectSearchQuery,
|
||||||
@@ -99,7 +118,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
currentProject,
|
currentProject,
|
||||||
isProjectPickerOpen,
|
isProjectPickerOpen,
|
||||||
setIsProjectPickerOpen,
|
setIsProjectPickerOpen,
|
||||||
setCurrentProject,
|
setCurrentProject: setCurrentProjectWithInit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
|
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
|
||||||
@@ -107,6 +126,14 @@ export function ProjectSelectorWithOptions({
|
|||||||
const { globalTheme, setProjectTheme, setPreviewTheme, handlePreviewEnter, handlePreviewLeave } =
|
const { globalTheme, setProjectTheme, setPreviewTheme, handlePreviewEnter, handlePreviewLeave } =
|
||||||
useProjectTheme();
|
useProjectTheme();
|
||||||
|
|
||||||
|
const handleSelectProject = useCallback(
|
||||||
|
async (p: Project) => {
|
||||||
|
await setCurrentProjectWithInit(p);
|
||||||
|
setIsProjectPickerOpen(false);
|
||||||
|
},
|
||||||
|
[setCurrentProjectWithInit, setIsProjectPickerOpen]
|
||||||
|
);
|
||||||
|
|
||||||
if (!sidebarOpen || projects.length === 0) {
|
if (!sidebarOpen || projects.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -204,10 +231,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
project={project}
|
project={project}
|
||||||
currentProjectId={currentProject?.id}
|
currentProjectId={currentProject?.id}
|
||||||
isHighlighted={index === selectedProjectIndex}
|
isHighlighted={index === selectedProjectIndex}
|
||||||
onSelect={(p) => {
|
onSelect={handleSelectProject}
|
||||||
setCurrentProject(p);
|
|
||||||
setIsProjectPickerOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ interface UseProjectPickerProps {
|
|||||||
currentProject: Project | null;
|
currentProject: Project | null;
|
||||||
isProjectPickerOpen: boolean;
|
isProjectPickerOpen: boolean;
|
||||||
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||||
setCurrentProject: (project: Project) => void;
|
setCurrentProject: (project: Project) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProjectPicker({
|
export function useProjectPicker({
|
||||||
@@ -92,9 +92,9 @@ export function useProjectPicker({
|
|||||||
}, [selectedProjectIndex, isProjectPickerOpen, filteredProjects, scrollToProject]);
|
}, [selectedProjectIndex, isProjectPickerOpen, filteredProjects, scrollToProject]);
|
||||||
|
|
||||||
// Handle selecting the currently highlighted project
|
// Handle selecting the currently highlighted project
|
||||||
const selectHighlightedProject = useCallback(() => {
|
const selectHighlightedProject = useCallback(async () => {
|
||||||
if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) {
|
if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) {
|
||||||
setCurrentProject(filteredProjects[selectedProjectIndex]);
|
await setCurrentProject(filteredProjects[selectedProjectIndex]);
|
||||||
setIsProjectPickerOpen(false);
|
setIsProjectPickerOpen(false);
|
||||||
}
|
}
|
||||||
}, [filteredProjects, selectedProjectIndex, setCurrentProject, setIsProjectPickerOpen]);
|
}, [filteredProjects, selectedProjectIndex, setCurrentProject, setIsProjectPickerOpen]);
|
||||||
@@ -108,7 +108,9 @@ export function useProjectPicker({
|
|||||||
setIsProjectPickerOpen(false);
|
setIsProjectPickerOpen(false);
|
||||||
} else if (event.key === 'Enter') {
|
} else if (event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
selectHighlightedProject();
|
selectHighlightedProject().catch(() => {
|
||||||
|
/* Error already logged upstream */
|
||||||
|
});
|
||||||
} else if (event.key === 'ArrowDown') {
|
} else if (event.key === 'ArrowDown') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSelectedProjectIndex((prev) => (prev < filteredProjects.length - 1 ? prev + 1 : prev));
|
setSelectedProjectIndex((prev) => (prev < filteredProjects.length - 1 ? prev + 1 : prev));
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface SortableProjectItemProps {
|
|||||||
project: Project;
|
project: Project;
|
||||||
currentProjectId: string | undefined;
|
currentProjectId: string | undefined;
|
||||||
isHighlighted: boolean;
|
isHighlighted: boolean;
|
||||||
onSelect: (project: Project) => void;
|
onSelect: (project: Project) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThemeMenuItemProps {
|
export interface ThemeMenuItemProps {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
ArchiveRestore,
|
ArchiveRestore,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn, pathsEqual } from '@/lib/utils';
|
||||||
import type { SessionListItem } from '@/types/electron';
|
import type { SessionListItem } from '@/types/electron';
|
||||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
@@ -93,6 +93,7 @@ interface SessionManagerProps {
|
|||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
onSelectSession: (sessionId: string | null) => void;
|
onSelectSession: (sessionId: string | null) => void;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
|
workingDirectory?: string; // Current worktree path for scoping sessions
|
||||||
isCurrentSessionThinking?: boolean;
|
isCurrentSessionThinking?: boolean;
|
||||||
onQuickCreateRef?: React.MutableRefObject<(() => Promise<void>) | null>;
|
onQuickCreateRef?: React.MutableRefObject<(() => Promise<void>) | null>;
|
||||||
}
|
}
|
||||||
@@ -101,6 +102,7 @@ export function SessionManager({
|
|||||||
currentSessionId,
|
currentSessionId,
|
||||||
onSelectSession,
|
onSelectSession,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
workingDirectory,
|
||||||
isCurrentSessionThinking = false,
|
isCurrentSessionThinking = false,
|
||||||
onQuickCreateRef,
|
onQuickCreateRef,
|
||||||
}: SessionManagerProps) {
|
}: SessionManagerProps) {
|
||||||
@@ -153,6 +155,7 @@ export function SessionManager({
|
|||||||
if (result.data) {
|
if (result.data) {
|
||||||
await checkRunningSessions(result.data);
|
await checkRunningSessions(result.data);
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
}, [queryClient, refetchSessions, checkRunningSessions]);
|
}, [queryClient, refetchSessions, checkRunningSessions]);
|
||||||
|
|
||||||
// Check running state on initial load (runs only once when sessions first load)
|
// Check running state on initial load (runs only once when sessions first load)
|
||||||
@@ -177,6 +180,9 @@ export function SessionManager({
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [sessions, runningSessions.size, isCurrentSessionThinking, checkRunningSessions]);
|
}, [sessions, runningSessions.size, isCurrentSessionThinking, checkRunningSessions]);
|
||||||
|
|
||||||
|
// Effective working directory for session creation (worktree path or project path)
|
||||||
|
const effectiveWorkingDirectory = workingDirectory || projectPath;
|
||||||
|
|
||||||
// Create new session with random name
|
// Create new session with random name
|
||||||
const handleCreateSession = async () => {
|
const handleCreateSession = async () => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
@@ -184,7 +190,7 @@ export function SessionManager({
|
|||||||
|
|
||||||
const sessionName = newSessionName.trim() || generateRandomSessionName();
|
const sessionName = newSessionName.trim() || generateRandomSessionName();
|
||||||
|
|
||||||
const result = await api.sessions.create(sessionName, projectPath, projectPath);
|
const result = await api.sessions.create(sessionName, projectPath, effectiveWorkingDirectory);
|
||||||
|
|
||||||
if (result.success && result.session?.id) {
|
if (result.success && result.session?.id) {
|
||||||
setNewSessionName('');
|
setNewSessionName('');
|
||||||
@@ -195,19 +201,19 @@ export function SessionManager({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Create new session directly with a random name (one-click)
|
// Create new session directly with a random name (one-click)
|
||||||
const handleQuickCreateSession = async () => {
|
const handleQuickCreateSession = useCallback(async () => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.sessions) return;
|
if (!api?.sessions) return;
|
||||||
|
|
||||||
const sessionName = generateRandomSessionName();
|
const sessionName = generateRandomSessionName();
|
||||||
|
|
||||||
const result = await api.sessions.create(sessionName, projectPath, projectPath);
|
const result = await api.sessions.create(sessionName, projectPath, effectiveWorkingDirectory);
|
||||||
|
|
||||||
if (result.success && result.session?.id) {
|
if (result.success && result.session?.id) {
|
||||||
await invalidateSessions();
|
await invalidateSessions();
|
||||||
onSelectSession(result.session.id);
|
onSelectSession(result.session.id);
|
||||||
}
|
}
|
||||||
};
|
}, [effectiveWorkingDirectory, projectPath, invalidateSessions, onSelectSession]);
|
||||||
|
|
||||||
// Expose the quick create function via ref for keyboard shortcuts
|
// Expose the quick create function via ref for keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -219,7 +225,7 @@ export function SessionManager({
|
|||||||
onQuickCreateRef.current = null;
|
onQuickCreateRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [onQuickCreateRef, projectPath]);
|
}, [onQuickCreateRef, handleQuickCreateSession]);
|
||||||
|
|
||||||
// Rename session
|
// Rename session
|
||||||
const handleRenameSession = async (sessionId: string) => {
|
const handleRenameSession = async (sessionId: string) => {
|
||||||
@@ -292,10 +298,16 @@ export function SessionManager({
|
|||||||
|
|
||||||
const result = await api.sessions.delete(sessionId);
|
const result = await api.sessions.delete(sessionId);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
await invalidateSessions();
|
const refetchResult = await invalidateSessions();
|
||||||
if (currentSessionId === sessionId) {
|
if (currentSessionId === sessionId) {
|
||||||
// Switch to another session or create a new one
|
// Switch to another session using fresh data, excluding the deleted session
|
||||||
const activeSessionsList = sessions.filter((s) => !s.isArchived);
|
// Filter to sessions within the same worktree to avoid jumping to a different worktree
|
||||||
|
const freshSessions = refetchResult?.data ?? [];
|
||||||
|
const activeSessionsList = freshSessions.filter((s) => {
|
||||||
|
if (s.isArchived || s.id === sessionId) return false;
|
||||||
|
const sessionDir = s.workingDirectory || s.projectPath;
|
||||||
|
return pathsEqual(sessionDir, effectiveWorkingDirectory);
|
||||||
|
});
|
||||||
if (activeSessionsList.length > 0) {
|
if (activeSessionsList.length > 0) {
|
||||||
onSelectSession(activeSessionsList[0].id);
|
onSelectSession(activeSessionsList[0].id);
|
||||||
}
|
}
|
||||||
@@ -318,8 +330,16 @@ export function SessionManager({
|
|||||||
setIsDeleteAllArchivedDialogOpen(false);
|
setIsDeleteAllArchivedDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeSessions = sessions.filter((s) => !s.isArchived);
|
// Filter sessions by current working directory (worktree scoping)
|
||||||
const archivedSessions = sessions.filter((s) => s.isArchived);
|
const scopedSessions = sessions.filter((s) => {
|
||||||
|
const sessionDir = s.workingDirectory || s.projectPath;
|
||||||
|
// Match sessions whose workingDirectory matches the current effective directory
|
||||||
|
// Use pathsEqual for cross-platform path normalization (trailing slashes, separators)
|
||||||
|
return pathsEqual(sessionDir, effectiveWorkingDirectory);
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeSessions = scopedSessions.filter((s) => !s.isArchived);
|
||||||
|
const archivedSessions = scopedSessions.filter((s) => s.isArchived);
|
||||||
const displayedSessions = activeTab === 'active' ? activeSessions : archivedSessions;
|
const displayedSessions = activeTab === 'active' ? activeSessions : archivedSessions;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
205
apps/ui/src/components/ui/app-error-boundary.tsx
Normal file
205
apps/ui/src/components/ui/app-error-boundary.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { Component, type ReactNode, type ErrorInfo } from 'react';
|
||||||
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
|
||||||
|
const logger = createLogger('AppErrorBoundary');
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
isCrashLoop: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Key used to track recent crash timestamps for crash loop detection */
|
||||||
|
const CRASH_TIMESTAMPS_KEY = 'automaker-crash-timestamps';
|
||||||
|
/** Number of crashes within the time window that constitutes a crash loop */
|
||||||
|
const CRASH_LOOP_THRESHOLD = 3;
|
||||||
|
/** Time window in ms for crash loop detection (30 seconds) */
|
||||||
|
const CRASH_LOOP_WINDOW_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root-level error boundary for the entire application.
|
||||||
|
*
|
||||||
|
* Catches uncaught React errors that would otherwise show TanStack Router's
|
||||||
|
* default "Something went wrong!" screen with a raw error message.
|
||||||
|
*
|
||||||
|
* Provides a user-friendly error screen with a reload button to recover.
|
||||||
|
* This is especially important for transient errors during initial app load
|
||||||
|
* (e.g., race conditions during auth/hydration on fresh browser sessions).
|
||||||
|
*
|
||||||
|
* Includes crash loop detection: if the app crashes 3+ times within 30 seconds,
|
||||||
|
* the UI cache is automatically cleared to break loops caused by stale cached
|
||||||
|
* worktree paths or other corrupt persisted state.
|
||||||
|
*/
|
||||||
|
export class AppErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null, isCrashLoop: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
logger.error('Uncaught application error:', {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
componentStack: errorInfo.componentStack,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track crash timestamps to detect crash loops.
|
||||||
|
// If the app crashes multiple times in quick succession, it's likely due to
|
||||||
|
// stale cached data (e.g., worktree paths that no longer exist on disk).
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
const raw = sessionStorage.getItem(CRASH_TIMESTAMPS_KEY);
|
||||||
|
const timestamps: number[] = raw ? JSON.parse(raw) : [];
|
||||||
|
timestamps.push(now);
|
||||||
|
// Keep only timestamps within the detection window
|
||||||
|
const recent = timestamps.filter((t) => now - t < CRASH_LOOP_WINDOW_MS);
|
||||||
|
sessionStorage.setItem(CRASH_TIMESTAMPS_KEY, JSON.stringify(recent));
|
||||||
|
|
||||||
|
if (recent.length >= CRASH_LOOP_THRESHOLD) {
|
||||||
|
logger.error(
|
||||||
|
`Crash loop detected (${recent.length} crashes in ${CRASH_LOOP_WINDOW_MS}ms) — clearing UI cache`
|
||||||
|
);
|
||||||
|
// Auto-clear the UI cache to break the loop
|
||||||
|
localStorage.removeItem('automaker-ui-cache');
|
||||||
|
sessionStorage.removeItem(CRASH_TIMESTAMPS_KEY);
|
||||||
|
this.setState({ isCrashLoop: true });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Storage may be unavailable — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReload = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClearCacheAndReload = () => {
|
||||||
|
// Clear the UI cache store that persists worktree selections and other UI state.
|
||||||
|
// This breaks crash loops caused by stale worktree paths that no longer exist on disk.
|
||||||
|
try {
|
||||||
|
localStorage.removeItem('automaker-ui-cache');
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable in some contexts
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-screen w-full flex-col items-center justify-center gap-6 bg-background p-6 text-foreground"
|
||||||
|
data-testid="app-error-boundary"
|
||||||
|
>
|
||||||
|
{/* Logo matching the app shell in index.html */}
|
||||||
|
<svg
|
||||||
|
className="h-14 w-14 opacity-90"
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
className="fill-foreground/[0.08]"
|
||||||
|
x="16"
|
||||||
|
y="16"
|
||||||
|
width="224"
|
||||||
|
height="224"
|
||||||
|
rx="56"
|
||||||
|
/>
|
||||||
|
<g
|
||||||
|
className="stroke-foreground/70"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="20"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M92 92 L52 128 L92 164" />
|
||||||
|
<path d="M144 72 L116 184" />
|
||||||
|
<path d="M164 92 L204 128 L164 164" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h1 className="text-xl font-semibold">Something went wrong</h1>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-md">
|
||||||
|
{this.state.isCrashLoop
|
||||||
|
? 'The application crashed repeatedly, likely due to stale cached data. The cache has been cleared automatically. Reload to continue.'
|
||||||
|
: 'The application encountered an unexpected error. This is usually temporary and can be resolved by reloading the page.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={this.handleReload}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground shadow-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||||
|
<path d="M3 3v5h5" />
|
||||||
|
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||||
|
<path d="M16 21h5v-5" />
|
||||||
|
</svg>
|
||||||
|
Reload Page
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={this.handleClearCacheAndReload}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M3 6h18" />
|
||||||
|
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||||
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
Clear Cache & Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsible technical details for debugging */}
|
||||||
|
{this.state.error && (
|
||||||
|
<details className="text-xs text-muted-foreground max-w-lg w-full">
|
||||||
|
<summary className="cursor-pointer hover:text-foreground text-center">
|
||||||
|
Technical details
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 p-3 bg-muted/50 rounded-md text-left overflow-auto max-h-32 border border-border">
|
||||||
|
{this.state.error.stack || this.state.error.message}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,9 +20,13 @@ import { AgentInputArea } from './agent-view/input-area';
|
|||||||
const LG_BREAKPOINT = 1024;
|
const LG_BREAKPOINT = 1024;
|
||||||
|
|
||||||
export function AgentView() {
|
export function AgentView() {
|
||||||
const { currentProject } = useAppStore();
|
const { currentProject, getCurrentWorktree } = useAppStore();
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Get the current worktree to scope sessions and agent working directory
|
||||||
|
const currentWorktree = currentProject ? getCurrentWorktree(currentProject.path) : null;
|
||||||
|
const effectiveWorkingDirectory = currentWorktree?.path || currentProject?.path;
|
||||||
// Initialize session manager state - starts as true to match SSR
|
// Initialize session manager state - starts as true to match SSR
|
||||||
// Then updates on mount based on actual screen size to prevent hydration mismatch
|
// Then updates on mount based on actual screen size to prevent hydration mismatch
|
||||||
const [showSessionManager, setShowSessionManager] = useState(true);
|
const [showSessionManager, setShowSessionManager] = useState(true);
|
||||||
@@ -52,9 +56,10 @@ export function AgentView() {
|
|||||||
// Guard to prevent concurrent invocations of handleCreateSessionFromEmptyState
|
// Guard to prevent concurrent invocations of handleCreateSessionFromEmptyState
|
||||||
const createSessionInFlightRef = useRef(false);
|
const createSessionInFlightRef = useRef(false);
|
||||||
|
|
||||||
// Session management hook
|
// Session management hook - scoped to current worktree
|
||||||
const { currentSessionId, handleSelectSession } = useAgentSession({
|
const { currentSessionId, handleSelectSession } = useAgentSession({
|
||||||
projectPath: currentProject?.path,
|
projectPath: currentProject?.path,
|
||||||
|
workingDirectory: effectiveWorkingDirectory,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use the Electron agent hook (only if we have a session)
|
// Use the Electron agent hook (only if we have a session)
|
||||||
@@ -71,7 +76,7 @@ export function AgentView() {
|
|||||||
clearServerQueue,
|
clearServerQueue,
|
||||||
} = useElectronAgent({
|
} = useElectronAgent({
|
||||||
sessionId: currentSessionId || '',
|
sessionId: currentSessionId || '',
|
||||||
workingDirectory: currentProject?.path,
|
workingDirectory: effectiveWorkingDirectory,
|
||||||
model: modelSelection.model,
|
model: modelSelection.model,
|
||||||
thinkingLevel: modelSelection.thinkingLevel,
|
thinkingLevel: modelSelection.thinkingLevel,
|
||||||
onToolUse: (toolName) => {
|
onToolUse: (toolName) => {
|
||||||
@@ -229,6 +234,7 @@ export function AgentView() {
|
|||||||
currentSessionId={currentSessionId}
|
currentSessionId={currentSessionId}
|
||||||
onSelectSession={handleSelectSession}
|
onSelectSession={handleSelectSession}
|
||||||
projectPath={currentProject.path}
|
projectPath={currentProject.path}
|
||||||
|
workingDirectory={effectiveWorkingDirectory}
|
||||||
isCurrentSessionThinking={isProcessing}
|
isCurrentSessionThinking={isProcessing}
|
||||||
onQuickCreateRef={quickCreateSessionRef}
|
onQuickCreateRef={quickCreateSessionRef}
|
||||||
/>
|
/>
|
||||||
@@ -248,6 +254,7 @@ export function AgentView() {
|
|||||||
showSessionManager={showSessionManager}
|
showSessionManager={showSessionManager}
|
||||||
onToggleSessionManager={() => setShowSessionManager(!showSessionManager)}
|
onToggleSessionManager={() => setShowSessionManager(!showSessionManager)}
|
||||||
onClearChat={handleClearChat}
|
onClearChat={handleClearChat}
|
||||||
|
worktreeBranch={currentWorktree?.branch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Bot, PanelLeftClose, PanelLeft, Wrench, Trash2 } from 'lucide-react';
|
import { Bot, PanelLeftClose, PanelLeft, Wrench, Trash2, GitBranch } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
interface AgentHeaderProps {
|
interface AgentHeaderProps {
|
||||||
@@ -11,6 +11,7 @@ interface AgentHeaderProps {
|
|||||||
showSessionManager: boolean;
|
showSessionManager: boolean;
|
||||||
onToggleSessionManager: () => void;
|
onToggleSessionManager: () => void;
|
||||||
onClearChat: () => void;
|
onClearChat: () => void;
|
||||||
|
worktreeBranch?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentHeader({
|
export function AgentHeader({
|
||||||
@@ -23,6 +24,7 @@ export function AgentHeader({
|
|||||||
showSessionManager,
|
showSessionManager,
|
||||||
onToggleSessionManager,
|
onToggleSessionManager,
|
||||||
onClearChat,
|
onClearChat,
|
||||||
|
worktreeBranch,
|
||||||
}: AgentHeaderProps) {
|
}: AgentHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
|
||||||
@@ -32,10 +34,18 @@ export function AgentHeader({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
|
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
{projectName}
|
<span>
|
||||||
{currentSessionId && !isConnected && ' - Connecting...'}
|
{projectName}
|
||||||
</p>
|
{currentSessionId && !isConnected && ' - Connecting...'}
|
||||||
|
</span>
|
||||||
|
{worktreeBranch && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs bg-muted/50 px-2 py-0.5 rounded-full border border-border">
|
||||||
|
<GitBranch className="w-3 h-3 shrink-0" />
|
||||||
|
<span className="max-w-[180px] truncate">{worktreeBranch}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const logger = createLogger('AgentSession');
|
|||||||
|
|
||||||
interface UseAgentSessionOptions {
|
interface UseAgentSessionOptions {
|
||||||
projectPath: string | undefined;
|
projectPath: string | undefined;
|
||||||
|
workingDirectory?: string; // Current worktree path for per-worktree session persistence
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseAgentSessionResult {
|
interface UseAgentSessionResult {
|
||||||
@@ -13,49 +14,56 @@ interface UseAgentSessionResult {
|
|||||||
handleSelectSession: (sessionId: string | null) => void;
|
handleSelectSession: (sessionId: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAgentSession({ projectPath }: UseAgentSessionOptions): UseAgentSessionResult {
|
export function useAgentSession({
|
||||||
|
projectPath,
|
||||||
|
workingDirectory,
|
||||||
|
}: UseAgentSessionOptions): UseAgentSessionResult {
|
||||||
const { setLastSelectedSession, getLastSelectedSession } = useAppStore();
|
const { setLastSelectedSession, getLastSelectedSession } = useAppStore();
|
||||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Track if initial session has been loaded
|
// Track if initial session has been loaded
|
||||||
const initialSessionLoadedRef = useRef(false);
|
const initialSessionLoadedRef = useRef(false);
|
||||||
|
|
||||||
|
// Use workingDirectory as the persistence key so sessions are scoped per worktree
|
||||||
|
const persistenceKey = workingDirectory || projectPath;
|
||||||
|
|
||||||
// Handle session selection with persistence
|
// Handle session selection with persistence
|
||||||
const handleSelectSession = useCallback(
|
const handleSelectSession = useCallback(
|
||||||
(sessionId: string | null) => {
|
(sessionId: string | null) => {
|
||||||
setCurrentSessionId(sessionId);
|
setCurrentSessionId(sessionId);
|
||||||
// Persist the selection for this project
|
// Persist the selection for this worktree/project
|
||||||
if (projectPath) {
|
if (persistenceKey) {
|
||||||
setLastSelectedSession(projectPath, sessionId);
|
setLastSelectedSession(persistenceKey, sessionId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectPath, setLastSelectedSession]
|
[persistenceKey, setLastSelectedSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Restore last selected session when switching to Agent view or when project changes
|
// Restore last selected session when switching to Agent view or when worktree changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectPath) {
|
if (!persistenceKey) {
|
||||||
// No project, reset
|
// No project, reset
|
||||||
setCurrentSessionId(null);
|
setCurrentSessionId(null);
|
||||||
initialSessionLoadedRef.current = false;
|
initialSessionLoadedRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only restore once per project
|
// Only restore once per persistence key
|
||||||
if (initialSessionLoadedRef.current) return;
|
if (initialSessionLoadedRef.current) return;
|
||||||
initialSessionLoadedRef.current = true;
|
initialSessionLoadedRef.current = true;
|
||||||
|
|
||||||
const lastSessionId = getLastSelectedSession(projectPath);
|
const lastSessionId = getLastSelectedSession(persistenceKey);
|
||||||
if (lastSessionId) {
|
if (lastSessionId) {
|
||||||
logger.info('Restoring last selected session:', lastSessionId);
|
logger.info('Restoring last selected session:', lastSessionId);
|
||||||
setCurrentSessionId(lastSessionId);
|
setCurrentSessionId(lastSessionId);
|
||||||
}
|
}
|
||||||
}, [projectPath, getLastSelectedSession]);
|
}, [persistenceKey, getLastSelectedSession]);
|
||||||
|
|
||||||
// Reset initialSessionLoadedRef when project changes
|
// Reset when worktree/project changes - clear current session and allow restore
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initialSessionLoadedRef.current = false;
|
initialSessionLoadedRef.current = false;
|
||||||
}, [projectPath]);
|
setCurrentSessionId(null);
|
||||||
|
}, [persistenceKey]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -33,7 +33,11 @@ import { getHttpApiClient } from '@/lib/http-api-client';
|
|||||||
import type { BacklogPlanResult, FeatureStatusWithPipeline } from '@automaker/types';
|
import type { BacklogPlanResult, FeatureStatusWithPipeline } from '@automaker/types';
|
||||||
import { pathsEqual } from '@/lib/utils';
|
import { pathsEqual } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
|
import {
|
||||||
|
BoardBackgroundModal,
|
||||||
|
PRCommentResolutionDialog,
|
||||||
|
type PRCommentResolutionPRInfo,
|
||||||
|
} from '@/components/dialogs';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||||
import { resolveModelString } from '@automaker/model-resolver';
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
@@ -184,6 +188,9 @@ export function BoardView() {
|
|||||||
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
||||||
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
||||||
const [showMergeRebaseDialog, setShowMergeRebaseDialog] = useState(false);
|
const [showMergeRebaseDialog, setShowMergeRebaseDialog] = useState(false);
|
||||||
|
const [showPRCommentDialog, setShowPRCommentDialog] = useState(false);
|
||||||
|
const [prCommentDialogPRInfo, setPRCommentDialogPRInfo] =
|
||||||
|
useState<PRCommentResolutionPRInfo | null>(null);
|
||||||
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<WorktreeInfo | null>(
|
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<WorktreeInfo | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -429,35 +436,103 @@ export function BoardView() {
|
|||||||
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
|
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
|
||||||
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
|
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
|
||||||
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
||||||
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
|
|
||||||
const worktrees = useMemo(
|
// Track the previous worktree path to detect worktree switches
|
||||||
() =>
|
const prevWorktreePathRef = useRef<string | null | undefined>(undefined);
|
||||||
currentProject
|
|
||||||
? (worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES)
|
// When the active worktree changes, invalidate feature queries to ensure
|
||||||
: EMPTY_WORKTREES,
|
// feature cards (especially their todo lists / planSpec tasks) render fresh data.
|
||||||
[currentProject, worktreesByProject]
|
// Without this, cards that unmount when filtered out and remount when the user
|
||||||
|
// switches back may show stale or missing todo list data until the next polling cycle.
|
||||||
|
useEffect(() => {
|
||||||
|
// Skip the initial mount (prevWorktreePathRef starts as undefined)
|
||||||
|
if (prevWorktreePathRef.current === undefined) {
|
||||||
|
prevWorktreePathRef.current = currentWorktreePath;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only invalidate when the worktree actually changed
|
||||||
|
if (prevWorktreePathRef.current !== currentWorktreePath && currentProject?.path) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prevWorktreePathRef.current = currentWorktreePath;
|
||||||
|
}, [currentWorktreePath, currentProject?.path, queryClient]);
|
||||||
|
|
||||||
|
// Select worktrees for the current project directly from the store.
|
||||||
|
// Using a project-scoped selector prevents re-renders when OTHER projects'
|
||||||
|
// worktrees change (the old selector subscribed to the entire worktreesByProject
|
||||||
|
// object, causing unnecessary re-renders that cascaded into selectedWorktree →
|
||||||
|
// useAutoMode → refreshStatus → setAutoModeRunning → store update → re-render loop
|
||||||
|
// that could trigger React error #185 on initial project open).
|
||||||
|
const currentProjectPath = currentProject?.path;
|
||||||
|
const worktrees = useAppStore(
|
||||||
|
useCallback(
|
||||||
|
(s) =>
|
||||||
|
currentProjectPath
|
||||||
|
? (s.worktreesByProject[currentProjectPath] ?? EMPTY_WORKTREES)
|
||||||
|
: EMPTY_WORKTREES,
|
||||||
|
[currentProjectPath]
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get the branch for the currently selected worktree
|
// Get the branch for the currently selected worktree
|
||||||
// Find the worktree that matches the current selection, or use main worktree
|
// Find the worktree that matches the current selection, or use main worktree
|
||||||
|
//
|
||||||
|
// IMPORTANT: Stabilize the returned object reference using a ref to prevent
|
||||||
|
// cascading re-renders during project switches. The spread `{ ...found, ... }`
|
||||||
|
// creates a new object every time, even when the underlying data is identical.
|
||||||
|
// Without stabilization, the new reference propagates to useAutoMode and other
|
||||||
|
// consumers, contributing to the re-render cascade that triggers React error #185.
|
||||||
|
const prevSelectedWorktreeRef = useRef<WorktreeInfo | undefined>(undefined);
|
||||||
const selectedWorktree = useMemo((): WorktreeInfo | undefined => {
|
const selectedWorktree = useMemo((): WorktreeInfo | undefined => {
|
||||||
let found;
|
let found;
|
||||||
|
let usedFallback = false;
|
||||||
if (currentWorktreePath === null) {
|
if (currentWorktreePath === null) {
|
||||||
// Primary worktree selected - find the main worktree
|
// Primary worktree selected - find the main worktree
|
||||||
found = worktrees.find((w) => w.isMain);
|
found = worktrees.find((w) => w.isMain);
|
||||||
} else {
|
} else {
|
||||||
// Specific worktree selected - find it by path
|
// Specific worktree selected - find it by path
|
||||||
found = worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
|
found = worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
|
||||||
|
// If the selected worktree no longer exists (e.g. just deleted),
|
||||||
|
// fall back to main to prevent rendering with undefined worktree.
|
||||||
|
// onDeleted will call setCurrentWorktree(…, null) to reset properly.
|
||||||
|
if (!found) {
|
||||||
|
found = worktrees.find((w) => w.isMain);
|
||||||
|
usedFallback = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
prevSelectedWorktreeRef.current = undefined;
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
if (!found) return undefined;
|
|
||||||
// Ensure all required WorktreeInfo fields are present
|
// Ensure all required WorktreeInfo fields are present
|
||||||
return {
|
const result: WorktreeInfo = {
|
||||||
...found,
|
...found,
|
||||||
isCurrent:
|
isCurrent:
|
||||||
found.isCurrent ??
|
found.isCurrent ??
|
||||||
(currentWorktreePath !== null ? pathsEqual(found.path, currentWorktreePath) : found.isMain),
|
(usedFallback
|
||||||
|
? found.isMain // treat main as current during the transient fallback render
|
||||||
|
: currentWorktreePath !== null
|
||||||
|
? pathsEqual(found.path, currentWorktreePath)
|
||||||
|
: found.isMain),
|
||||||
hasWorktree: found.hasWorktree ?? true,
|
hasWorktree: found.hasWorktree ?? true,
|
||||||
};
|
};
|
||||||
|
// Return the previous reference if the key fields haven't changed,
|
||||||
|
// preventing downstream hooks from seeing a "new" worktree on every render.
|
||||||
|
const prev = prevSelectedWorktreeRef.current;
|
||||||
|
if (
|
||||||
|
prev &&
|
||||||
|
prev.path === result.path &&
|
||||||
|
prev.branch === result.branch &&
|
||||||
|
prev.isMain === result.isMain &&
|
||||||
|
prev.isCurrent === result.isCurrent &&
|
||||||
|
prev.hasWorktree === result.hasWorktree
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
prevSelectedWorktreeRef.current = result;
|
||||||
|
return result;
|
||||||
}, [worktrees, currentWorktreePath]);
|
}, [worktrees, currentWorktreePath]);
|
||||||
|
|
||||||
// Auto mode hook - pass current worktree to get worktree-specific state
|
// Auto mode hook - pass current worktree to get worktree-specific state
|
||||||
@@ -922,26 +997,39 @@ export function BoardView() {
|
|||||||
[handleAddFeature, handleStartImplementation]
|
[handleAddFeature, handleStartImplementation]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handler for addressing PR comments - creates a feature and starts it automatically
|
// Handler for managing PR comments - opens the PR Comment Resolution dialog
|
||||||
const handleAddressPRComments = useCallback(
|
const handleAddressPRComments = useCallback((worktree: WorktreeInfo, prInfo: PRInfo) => {
|
||||||
|
setPRCommentDialogPRInfo({
|
||||||
|
number: prInfo.number,
|
||||||
|
title: prInfo.title,
|
||||||
|
// Pass the worktree's branch so features are created on the correct worktree
|
||||||
|
headRefName: worktree.branch,
|
||||||
|
});
|
||||||
|
setShowPRCommentDialog(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handler for auto-addressing PR comments - immediately creates and starts a feature task
|
||||||
|
const handleAutoAddressPRComments = useCallback(
|
||||||
async (worktree: WorktreeInfo, prInfo: PRInfo) => {
|
async (worktree: WorktreeInfo, prInfo: PRInfo) => {
|
||||||
// Use a simple prompt that instructs the agent to read and address PR feedback
|
if (!prInfo.number) {
|
||||||
// The agent will fetch the PR comments directly, which is more reliable and up-to-date
|
toast.error('Cannot address PR comments', {
|
||||||
const prNumber = prInfo.number;
|
description: 'No PR number available for this worktree.',
|
||||||
const description = `Read the review requests on PR #${prNumber} and address any feedback the best you can.`;
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const featureData = {
|
const featureData = {
|
||||||
title: `Address PR #${prNumber} Review Comments`,
|
title: `Address PR #${prInfo.number} Review Comments`,
|
||||||
category: 'PR Review',
|
category: 'Maintenance',
|
||||||
description,
|
description: `Read the review requests on PR #${prInfo.number} and address any feedback the best you can.`,
|
||||||
images: [],
|
images: [],
|
||||||
imagePaths: [],
|
imagePaths: [],
|
||||||
skipTests: defaultSkipTests,
|
skipTests: defaultSkipTests,
|
||||||
model: 'opus' as const,
|
model: resolveModelString('opus'),
|
||||||
thinkingLevel: 'none' as const,
|
thinkingLevel: 'none' as const,
|
||||||
branchName: worktree.branch,
|
branchName: worktree.branch,
|
||||||
workMode: 'custom' as const, // Use the worktree's branch
|
workMode: 'custom' as const,
|
||||||
priority: 1, // High priority for PR feedback
|
priority: 1,
|
||||||
planningMode: 'skip' as const,
|
planningMode: 'skip' as const,
|
||||||
requirePlanApproval: false,
|
requirePlanApproval: false,
|
||||||
};
|
};
|
||||||
@@ -988,7 +1076,7 @@ export function BoardView() {
|
|||||||
images: [],
|
images: [],
|
||||||
imagePaths: [],
|
imagePaths: [],
|
||||||
skipTests: defaultSkipTests,
|
skipTests: defaultSkipTests,
|
||||||
model: 'opus' as const,
|
model: resolveModelString('opus'),
|
||||||
thinkingLevel: 'none' as const,
|
thinkingLevel: 'none' as const,
|
||||||
branchName: conflictInfo.targetBranch,
|
branchName: conflictInfo.targetBranch,
|
||||||
workMode: 'custom' as const, // Use the target branch where conflicts need to be resolved
|
workMode: 'custom' as const, // Use the target branch where conflicts need to be resolved
|
||||||
@@ -1508,6 +1596,7 @@ export function BoardView() {
|
|||||||
setShowCreateBranchDialog(true);
|
setShowCreateBranchDialog(true);
|
||||||
}}
|
}}
|
||||||
onAddressPRComments={handleAddressPRComments}
|
onAddressPRComments={handleAddressPRComments}
|
||||||
|
onAutoAddressPRComments={handleAutoAddressPRComments}
|
||||||
onResolveConflicts={handleResolveConflicts}
|
onResolveConflicts={handleResolveConflicts}
|
||||||
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
|
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
|
||||||
onBranchSwitchConflict={handleBranchSwitchConflict}
|
onBranchSwitchConflict={handleBranchSwitchConflict}
|
||||||
@@ -1901,6 +1990,16 @@ export function BoardView() {
|
|||||||
}
|
}
|
||||||
defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)}
|
defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)}
|
||||||
onDeleted={(deletedWorktree, _deletedBranch) => {
|
onDeleted={(deletedWorktree, _deletedBranch) => {
|
||||||
|
// If the deleted worktree was currently selected, immediately reset to main
|
||||||
|
// to prevent the UI from trying to render a non-existent worktree view
|
||||||
|
if (
|
||||||
|
currentWorktreePath !== null &&
|
||||||
|
pathsEqual(currentWorktreePath, deletedWorktree.path)
|
||||||
|
) {
|
||||||
|
const mainBranch = worktrees.find((w) => w.isMain)?.branch || 'main';
|
||||||
|
setCurrentWorktree(currentProject.path, null, mainBranch);
|
||||||
|
}
|
||||||
|
|
||||||
// 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) => {
|
||||||
// Match by branch name since worktreePath is no longer stored
|
// Match by branch name since worktreePath is no longer stored
|
||||||
@@ -1985,6 +2084,18 @@ export function BoardView() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* PR Comment Resolution Dialog */}
|
||||||
|
{prCommentDialogPRInfo && (
|
||||||
|
<PRCommentResolutionDialog
|
||||||
|
open={showPRCommentDialog}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setShowPRCommentDialog(open);
|
||||||
|
if (!open) setPRCommentDialogPRInfo(null);
|
||||||
|
}}
|
||||||
|
pr={prCommentDialogPRInfo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Init Script Indicator - floating overlay for worktree init script status */}
|
{/* Init Script Indicator - floating overlay for worktree init script status */}
|
||||||
{getShowInitScriptIndicator(currentProject.path) && (
|
{getShowInitScriptIndicator(currentProject.path) && (
|
||||||
<InitScriptIndicator projectPath={currentProject.path} />
|
<InitScriptIndicator projectPath={currentProject.path} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { memo, useEffect, useState, useMemo, useRef } from 'react';
|
import { memo, useEffect, useState, useMemo, useRef } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Feature, ThinkingLevel, ReasoningEffort, ParsedTask } from '@/store/app-store';
|
import { Feature, ThinkingLevel, ReasoningEffort, ParsedTask } from '@/store/app-store';
|
||||||
import { getProviderFromModel } from '@/lib/utils';
|
import { getProviderFromModel } from '@/lib/utils';
|
||||||
import { parseAgentContext, formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
|
import { parseAgentContext, formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
|
||||||
@@ -10,6 +11,7 @@ import { getElectronAPI } from '@/lib/electron';
|
|||||||
import { SummaryDialog } from './summary-dialog';
|
import { SummaryDialog } from './summary-dialog';
|
||||||
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
||||||
import { useFeature, useAgentOutput } from '@/hooks/queries';
|
import { useFeature, useAgentOutput } from '@/hooks/queries';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats thinking level for compact display
|
* Formats thinking level for compact display
|
||||||
@@ -58,6 +60,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
|||||||
summary,
|
summary,
|
||||||
isActivelyRunning,
|
isActivelyRunning,
|
||||||
}: AgentInfoPanelProps) {
|
}: AgentInfoPanelProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||||
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
|
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
|
||||||
// Track real-time task status updates from WebSocket events
|
// Track real-time task status updates from WebSocket events
|
||||||
@@ -130,6 +133,25 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
|||||||
pollingInterval,
|
pollingInterval,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// On mount, ensure feature and agent output queries are fresh.
|
||||||
|
// This handles the worktree switch scenario where cards unmount when filtered out
|
||||||
|
// and remount when the user switches back. Without this, the React Query cache
|
||||||
|
// may serve stale data (or no data) for the individual feature query, causing
|
||||||
|
// the todo list to appear empty until the next polling cycle.
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldFetchData && projectPath && feature.id && !contextContent) {
|
||||||
|
// Invalidate both the single feature and agent output queries to trigger immediate refetch
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.single(projectPath, feature.id),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.agentOutput(projectPath, feature.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Only run on mount (feature.id and projectPath identify this specific card instance)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [feature.id, projectPath]);
|
||||||
|
|
||||||
// Parse agent output into agentInfo
|
// Parse agent output into agentInfo
|
||||||
const agentInfo = useMemo(() => {
|
const agentInfo = useMemo(() => {
|
||||||
if (contextContent) {
|
if (contextContent) {
|
||||||
@@ -305,9 +327,11 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
|||||||
|
|
||||||
// Agent Info Panel for non-backlog cards
|
// Agent Info Panel for non-backlog cards
|
||||||
// Show panel if we have agentInfo OR planSpec.tasks (for spec/full mode)
|
// Show panel if we have agentInfo OR planSpec.tasks (for spec/full mode)
|
||||||
|
// OR if the feature has effective todos from any source (handles initial mount after worktree switch)
|
||||||
|
// OR if the feature is actively running (ensures panel stays visible during execution)
|
||||||
// Note: hasPlanSpecTasks is already defined above and includes freshPlanSpec
|
// Note: hasPlanSpecTasks is already defined above and includes freshPlanSpec
|
||||||
// (The backlog case was already handled above and returned early)
|
// (The backlog case was already handled above and returned early)
|
||||||
if (agentInfo || hasPlanSpecTasks) {
|
if (agentInfo || hasPlanSpecTasks || effectiveTodos.length > 0 || isActivelyRunning) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-3 space-y-2 overflow-hidden">
|
<div className="mb-3 space-y-2 overflow-hidden">
|
||||||
|
|||||||
@@ -123,6 +123,18 @@ interface AddFeatureDialogProps {
|
|||||||
* This is used when the "Default to worktree mode" setting is disabled.
|
* This is used when the "Default to worktree mode" setting is disabled.
|
||||||
*/
|
*/
|
||||||
forceCurrentBranchMode?: boolean;
|
forceCurrentBranchMode?: boolean;
|
||||||
|
/**
|
||||||
|
* Pre-filled title for the feature (e.g., from a GitHub issue).
|
||||||
|
*/
|
||||||
|
prefilledTitle?: string;
|
||||||
|
/**
|
||||||
|
* Pre-filled description for the feature (e.g., from a GitHub issue).
|
||||||
|
*/
|
||||||
|
prefilledDescription?: string;
|
||||||
|
/**
|
||||||
|
* Pre-filled category for the feature (e.g., 'From GitHub').
|
||||||
|
*/
|
||||||
|
prefilledCategory?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,6 +161,9 @@ export function AddFeatureDialog({
|
|||||||
projectPath,
|
projectPath,
|
||||||
selectedNonMainWorktreeBranch,
|
selectedNonMainWorktreeBranch,
|
||||||
forceCurrentBranchMode,
|
forceCurrentBranchMode,
|
||||||
|
prefilledTitle,
|
||||||
|
prefilledDescription,
|
||||||
|
prefilledCategory,
|
||||||
}: AddFeatureDialogProps) {
|
}: AddFeatureDialogProps) {
|
||||||
const isSpawnMode = !!parentFeature;
|
const isSpawnMode = !!parentFeature;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -211,6 +226,11 @@ export function AddFeatureDialog({
|
|||||||
wasOpenRef.current = open;
|
wasOpenRef.current = open;
|
||||||
|
|
||||||
if (justOpened) {
|
if (justOpened) {
|
||||||
|
// Initialize with prefilled values if provided, otherwise use defaults
|
||||||
|
setTitle(prefilledTitle ?? '');
|
||||||
|
setDescription(prefilledDescription ?? '');
|
||||||
|
setCategory(prefilledCategory ?? '');
|
||||||
|
|
||||||
setSkipTests(defaultSkipTests);
|
setSkipTests(defaultSkipTests);
|
||||||
// When a non-main worktree is selected, use its branch name for custom mode
|
// When a non-main worktree is selected, use its branch name for custom mode
|
||||||
// Otherwise, use the default branch
|
// Otherwise, use the default branch
|
||||||
@@ -254,6 +274,9 @@ export function AddFeatureDialog({
|
|||||||
forceCurrentBranchMode,
|
forceCurrentBranchMode,
|
||||||
parentFeature,
|
parentFeature,
|
||||||
allFeatures,
|
allFeatures,
|
||||||
|
prefilledTitle,
|
||||||
|
prefilledDescription,
|
||||||
|
prefilledCategory,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Clear requirePlanApproval when planning mode is skip or lite
|
// Clear requirePlanApproval when planning mode is skip or lite
|
||||||
|
|||||||
@@ -105,43 +105,106 @@ export function CreatePRDialog({
|
|||||||
const branchAheadCount = branchesData?.aheadCount ?? 0;
|
const branchAheadCount = branchesData?.aheadCount ?? 0;
|
||||||
const needsPush = !branchHasRemote || branchAheadCount > 0 || !!worktree?.hasChanges;
|
const needsPush = !branchHasRemote || branchAheadCount > 0 || !!worktree?.hasChanges;
|
||||||
|
|
||||||
// Filter out current worktree branch from the list
|
// Determine the active remote to scope branches to.
|
||||||
// When a target remote is selected, only show branches from that remote
|
// For multi-remote: use the selected target remote.
|
||||||
const branches = useMemo(() => {
|
// For single remote: automatically scope to that remote.
|
||||||
if (!branchesData?.branches) return [];
|
const activeRemote = useMemo(() => {
|
||||||
const allBranches = branchesData.branches
|
if (remotes.length === 1) return remotes[0].name;
|
||||||
.map((b) => b.name)
|
if (selectedTargetRemote) return selectedTargetRemote;
|
||||||
.filter((name) => name !== worktree?.branch);
|
return '';
|
||||||
|
}, [remotes, selectedTargetRemote]);
|
||||||
|
|
||||||
// If a target remote is selected and we have remote info with branches,
|
// Filter branches by the active remote and strip remote prefixes for display.
|
||||||
// only show that remote's branches (not branches from other remotes)
|
// Returns display names (e.g. "main") without remote prefix.
|
||||||
if (selectedTargetRemote) {
|
// Also builds a map from display name → full ref (e.g. "origin/main") for PR creation.
|
||||||
const targetRemoteInfo = remotes.find((r) => r.name === selectedTargetRemote);
|
const { branches, branchFullRefMap } = useMemo(() => {
|
||||||
if (targetRemoteInfo?.branches && targetRemoteInfo.branches.length > 0) {
|
if (!branchesData?.branches)
|
||||||
const targetBranchNames = new Set(targetRemoteInfo.branches);
|
return { branches: [], branchFullRefMap: new Map<string, string>() };
|
||||||
// Filter to only include branches that exist on the target remote
|
|
||||||
// Match both short names (e.g. "main") and prefixed names (e.g. "upstream/main")
|
const refMap = new Map<string, string>();
|
||||||
return allBranches.filter((name) => {
|
|
||||||
// Check if the branch name matches a target remote branch directly
|
// If we have an active remote with branch info from the remotes endpoint, use that as the source
|
||||||
if (targetBranchNames.has(name)) return true;
|
const activeRemoteInfo = activeRemote
|
||||||
// Check if it's a prefixed remote branch (e.g. "upstream/main")
|
? remotes.find((r) => r.name === activeRemote)
|
||||||
const prefix = `${selectedTargetRemote}/`;
|
: undefined;
|
||||||
if (name.startsWith(prefix) && targetBranchNames.has(name.slice(prefix.length)))
|
|
||||||
return true;
|
if (activeRemoteInfo?.branches && activeRemoteInfo.branches.length > 0) {
|
||||||
return false;
|
// Use the remote's branch list — these are already short names (e.g. "main")
|
||||||
|
const filteredBranches = activeRemoteInfo.branches
|
||||||
|
.filter((branchName) => {
|
||||||
|
// Exclude the current worktree branch
|
||||||
|
return branchName !== worktree?.branch;
|
||||||
|
})
|
||||||
|
.map((branchName) => {
|
||||||
|
// Map display name to full ref
|
||||||
|
const fullRef = `${activeRemote}/${branchName}`;
|
||||||
|
refMap.set(branchName, fullRef);
|
||||||
|
return branchName;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { branches: filteredBranches, branchFullRefMap: refMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if no remote info available, use the branches from the branches endpoint
|
||||||
|
// Filter and strip prefixes
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const filteredBranches: string[] = [];
|
||||||
|
|
||||||
|
for (const b of branchesData.branches) {
|
||||||
|
// Skip the current worktree branch
|
||||||
|
if (b.name === worktree?.branch) continue;
|
||||||
|
|
||||||
|
if (b.isRemote) {
|
||||||
|
// Remote branch: check if it belongs to the active remote
|
||||||
|
const slashIndex = b.name.indexOf('/');
|
||||||
|
if (slashIndex === -1) continue;
|
||||||
|
|
||||||
|
const remoteName = b.name.substring(0, slashIndex);
|
||||||
|
const branchName = b.name.substring(slashIndex + 1);
|
||||||
|
|
||||||
|
// If we have an active remote, only include branches from that remote
|
||||||
|
if (activeRemote && remoteName !== activeRemote) continue;
|
||||||
|
|
||||||
|
// Strip the remote prefix for display
|
||||||
|
if (!seen.has(branchName)) {
|
||||||
|
seen.add(branchName);
|
||||||
|
filteredBranches.push(branchName);
|
||||||
|
refMap.set(branchName, b.name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Local branch — only include if it has a remote counterpart on the active remote
|
||||||
|
// or if no active remote is set (no remotes at all)
|
||||||
|
if (!activeRemote) {
|
||||||
|
if (!seen.has(b.name)) {
|
||||||
|
seen.add(b.name);
|
||||||
|
filteredBranches.push(b.name);
|
||||||
|
refMap.set(b.name, b.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// When active remote is set, skip local-only branches — the remote version
|
||||||
|
// will be included from the remote branches above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return allBranches;
|
return { branches: filteredBranches, branchFullRefMap: refMap };
|
||||||
}, [branchesData?.branches, worktree?.branch, selectedTargetRemote, remotes]);
|
}, [branchesData?.branches, worktree?.branch, activeRemote, remotes]);
|
||||||
|
|
||||||
// When branches change (e.g. target remote changed), reset base branch if current selection is no longer valid
|
// When branches change (e.g. target remote changed), reset base branch if current selection is no longer valid
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (branches.length > 0 && baseBranch && !branches.includes(baseBranch)) {
|
if (branches.length > 0 && baseBranch && !branches.includes(baseBranch)) {
|
||||||
// Current base branch is not in the filtered list — pick the best match
|
// Current base branch is not in the filtered list — pick the best match
|
||||||
const mainBranch = branches.find((b) => b === 'main' || b === 'master');
|
// Strip any existing remote prefix from the current base branch for comparison
|
||||||
setBaseBranch(mainBranch || branches[0]);
|
const strippedBaseBranch = baseBranch.includes('/')
|
||||||
|
? baseBranch.substring(baseBranch.indexOf('/') + 1)
|
||||||
|
: baseBranch;
|
||||||
|
|
||||||
|
// Check if the stripped version exists in the list
|
||||||
|
if (branches.includes(strippedBaseBranch)) {
|
||||||
|
setBaseBranch(strippedBaseBranch);
|
||||||
|
} else {
|
||||||
|
const mainBranch = branches.find((b) => b === 'main' || b === 'master');
|
||||||
|
setBaseBranch(mainBranch || branches[0]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [branches, baseBranch]);
|
}, [branches, baseBranch]);
|
||||||
|
|
||||||
@@ -234,7 +297,16 @@ export function CreatePRDialog({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const api = getHttpApiClient();
|
const api = getHttpApiClient();
|
||||||
const result = await api.worktree.generatePRDescription(worktree.path, baseBranch);
|
// Resolve the display name to the actual branch name for the API
|
||||||
|
const resolvedRef = branchFullRefMap.get(baseBranch) || baseBranch;
|
||||||
|
// Only strip the remote prefix if the resolved ref differs from the original
|
||||||
|
// (indicating it was resolved from a full ref like "origin/main").
|
||||||
|
// This preserves local branch names that contain slashes (e.g. "release/1.0").
|
||||||
|
const branchNameForApi =
|
||||||
|
resolvedRef !== baseBranch && resolvedRef.includes('/')
|
||||||
|
? resolvedRef.substring(resolvedRef.indexOf('/') + 1)
|
||||||
|
: resolvedRef;
|
||||||
|
const result = await api.worktree.generatePRDescription(worktree.path, branchNameForApi);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (result.title) {
|
if (result.title) {
|
||||||
@@ -270,12 +342,26 @@ export function CreatePRDialog({
|
|||||||
setError('Worktree API not available');
|
setError('Worktree API not available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Resolve the display branch name to the full ref for the API call.
|
||||||
|
// The baseBranch state holds the display name (e.g. "main"), but the API
|
||||||
|
// may need the short name without the remote prefix. We pass the display name
|
||||||
|
// since the backend handles branch resolution. However, if the full ref is
|
||||||
|
// available, we can use it for more precise targeting.
|
||||||
|
const resolvedBaseBranch = branchFullRefMap.get(baseBranch) || baseBranch;
|
||||||
|
// Only strip the remote prefix if the resolved ref differs from the original
|
||||||
|
// (indicating it was resolved from a full ref like "origin/main").
|
||||||
|
// This preserves local branch names that contain slashes (e.g. "release/1.0").
|
||||||
|
const baseBranchForApi =
|
||||||
|
resolvedBaseBranch !== baseBranch && resolvedBaseBranch.includes('/')
|
||||||
|
? resolvedBaseBranch.substring(resolvedBaseBranch.indexOf('/') + 1)
|
||||||
|
: resolvedBaseBranch;
|
||||||
|
|
||||||
const result = await api.worktree.createPR(worktree.path, {
|
const result = await api.worktree.createPR(worktree.path, {
|
||||||
projectPath: projectPath || undefined,
|
projectPath: projectPath || undefined,
|
||||||
commitMessage: commitMessage || undefined,
|
commitMessage: commitMessage || undefined,
|
||||||
prTitle: title || worktree.branch,
|
prTitle: title || worktree.branch,
|
||||||
prBody: body || `Changes from branch ${worktree.branch}`,
|
prBody: body || `Changes from branch ${worktree.branch}`,
|
||||||
baseBranch,
|
baseBranch: baseBranchForApi,
|
||||||
draft: isDraft,
|
draft: isDraft,
|
||||||
remote: selectedRemote || undefined,
|
remote: selectedRemote || undefined,
|
||||||
targetRemote: remotes.length > 1 ? selectedTargetRemote || undefined : undefined,
|
targetRemote: remotes.length > 1 ? selectedTargetRemote || undefined : undefined,
|
||||||
@@ -626,9 +712,13 @@ export function CreatePRDialog({
|
|||||||
onChange={setBaseBranch}
|
onChange={setBaseBranch}
|
||||||
branches={branches}
|
branches={branches}
|
||||||
placeholder="Select base branch..."
|
placeholder="Select base branch..."
|
||||||
disabled={isLoadingBranches}
|
disabled={isLoadingBranches || isLoadingRemotes}
|
||||||
allowCreate={false}
|
allowCreate={false}
|
||||||
emptyMessage="No matching branches found."
|
emptyMessage={
|
||||||
|
activeRemote
|
||||||
|
? `No branches found on remote "${activeRemote}".`
|
||||||
|
: 'No matching branches found.'
|
||||||
|
}
|
||||||
data-testid="base-branch-autocomplete"
|
data-testid="base-branch-autocomplete"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -284,11 +284,33 @@ export function CreateWorktreeDialog({
|
|||||||
|
|
||||||
if (result.success && result.worktree) {
|
if (result.success && result.worktree) {
|
||||||
const baseDesc = effectiveBaseBranch ? ` from ${effectiveBaseBranch}` : '';
|
const baseDesc = effectiveBaseBranch ? ` from ${effectiveBaseBranch}` : '';
|
||||||
toast.success(`Worktree created for branch "${result.worktree.branch}"`, {
|
const commitInfo = result.worktree.baseCommitHash
|
||||||
description: result.worktree.isNew
|
? ` (${result.worktree.baseCommitHash})`
|
||||||
? `New branch created${baseDesc}`
|
: '';
|
||||||
: 'Using existing branch',
|
|
||||||
});
|
// Show sync result feedback
|
||||||
|
const syncResult = result.worktree.syncResult;
|
||||||
|
if (syncResult?.diverged) {
|
||||||
|
// Branch had diverged — warn the user
|
||||||
|
toast.warning(`Worktree created for branch "${result.worktree.branch}"`, {
|
||||||
|
description: `${syncResult.message}`,
|
||||||
|
duration: 8000,
|
||||||
|
});
|
||||||
|
} else if (syncResult && !syncResult.synced && syncResult.message) {
|
||||||
|
// Sync was attempted but failed (network error, etc.)
|
||||||
|
toast.warning(`Worktree created for branch "${result.worktree.branch}"`, {
|
||||||
|
description: `Created with local copy. ${syncResult.message}`,
|
||||||
|
duration: 6000,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Normal success — include commit info if available
|
||||||
|
toast.success(`Worktree created for branch "${result.worktree.branch}"`, {
|
||||||
|
description: result.worktree.isNew
|
||||||
|
? `New branch created${baseDesc}${commitInfo}`
|
||||||
|
: `Using existing branch${commitInfo}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onCreated({ path: result.worktree.path, branch: result.worktree.branch });
|
onCreated({ path: result.worktree.path, branch: result.worktree.branch });
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setBranchName('');
|
setBranchName('');
|
||||||
@@ -414,6 +436,12 @@ export function CreateWorktreeDialog({
|
|||||||
<span>Remote branch — will fetch latest before creating worktree</span>
|
<span>Remote branch — will fetch latest before creating worktree</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isRemoteBaseBranch && baseBranch && !branchFetchError && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<RefreshCw className="w-3 h-3" />
|
||||||
|
<span>Will sync with remote tracking branch if available</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -454,7 +482,7 @@ export function CreateWorktreeDialog({
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" className="mr-2" />
|
||||||
{isRemoteBaseBranch ? 'Fetching & Creating...' : 'Creating...'}
|
{baseBranch.trim() ? 'Syncing & Creating...' : 'Creating...'}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -217,9 +217,15 @@ export function useBoardActions({
|
|||||||
const needsTitleGeneration =
|
const needsTitleGeneration =
|
||||||
!titleWasGenerated && !featureData.title.trim() && featureData.description.trim();
|
!titleWasGenerated && !featureData.title.trim() && featureData.description.trim();
|
||||||
|
|
||||||
const initialStatus = featureData.initialStatus || 'backlog';
|
const {
|
||||||
|
initialStatus: requestedStatus,
|
||||||
|
workMode: _workMode,
|
||||||
|
childDependencies,
|
||||||
|
...restFeatureData
|
||||||
|
} = featureData;
|
||||||
|
const initialStatus = requestedStatus || 'backlog';
|
||||||
const newFeatureData = {
|
const newFeatureData = {
|
||||||
...featureData,
|
...restFeatureData,
|
||||||
title: titleWasGenerated ? titleForBranch : featureData.title,
|
title: titleWasGenerated ? titleForBranch : featureData.title,
|
||||||
titleGenerating: needsTitleGeneration,
|
titleGenerating: needsTitleGeneration,
|
||||||
status: initialStatus,
|
status: initialStatus,
|
||||||
@@ -239,8 +245,8 @@ export function useBoardActions({
|
|||||||
saveCategory(featureData.category);
|
saveCategory(featureData.category);
|
||||||
|
|
||||||
// Handle child dependencies - update other features to depend on this new feature
|
// Handle child dependencies - update other features to depend on this new feature
|
||||||
if (featureData.childDependencies && featureData.childDependencies.length > 0) {
|
if (childDependencies && childDependencies.length > 0) {
|
||||||
for (const childId of featureData.childDependencies) {
|
for (const childId of childDependencies) {
|
||||||
const childFeature = features.find((f) => f.id === childId);
|
const childFeature = features.find((f) => f.id === childId);
|
||||||
if (childFeature) {
|
if (childFeature) {
|
||||||
const childDeps = childFeature.dependencies || [];
|
const childDeps = childFeature.dependencies || [];
|
||||||
@@ -1161,10 +1167,15 @@ export function useBoardActions({
|
|||||||
|
|
||||||
const handleDuplicateFeature = useCallback(
|
const handleDuplicateFeature = useCallback(
|
||||||
async (feature: Feature, asChild: boolean = false) => {
|
async (feature: Feature, asChild: boolean = false) => {
|
||||||
// Copy all feature data, stripping id, status (handled by create), and runtime/state fields
|
// Copy all feature data, stripping id, status (handled by create), and runtime/state fields.
|
||||||
|
// Also strip initialStatus and workMode which are transient creation parameters that
|
||||||
|
// should not carry over to duplicates (initialStatus: 'in_progress' would cause
|
||||||
|
// the duplicate to immediately appear in "In Progress" instead of "Backlog").
|
||||||
const {
|
const {
|
||||||
id: _id,
|
id: _id,
|
||||||
status: _status,
|
status: _status,
|
||||||
|
initialStatus: _initialStatus,
|
||||||
|
workMode: _workMode,
|
||||||
startedAt: _startedAt,
|
startedAt: _startedAt,
|
||||||
error: _error,
|
error: _error,
|
||||||
summary: _summary,
|
summary: _summary,
|
||||||
@@ -1212,6 +1223,8 @@ export function useBoardActions({
|
|||||||
const {
|
const {
|
||||||
id: _id,
|
id: _id,
|
||||||
status: _status,
|
status: _status,
|
||||||
|
initialStatus: _initialStatus,
|
||||||
|
workMode: _workMode,
|
||||||
startedAt: _startedAt,
|
startedAt: _startedAt,
|
||||||
error: _error,
|
error: _error,
|
||||||
summary: _summary,
|
summary: _summary,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -40,6 +41,7 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
XCircle,
|
XCircle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
Settings2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -54,6 +56,7 @@ import {
|
|||||||
import { getEditorIcon } from '@/components/icons/editor-icons';
|
import { getEditorIcon } from '@/components/icons/editor-icons';
|
||||||
import { getTerminalIcon } from '@/components/icons/terminal-icons';
|
import { getTerminalIcon } from '@/components/icons/terminal-icons';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import type { TerminalScript } from '@/components/views/project-settings-view/terminal-scripts-constants';
|
||||||
|
|
||||||
interface WorktreeActionsDropdownProps {
|
interface WorktreeActionsDropdownProps {
|
||||||
worktree: WorktreeInfo;
|
worktree: WorktreeInfo;
|
||||||
@@ -102,6 +105,7 @@ interface WorktreeActionsDropdownProps {
|
|||||||
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;
|
||||||
|
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||||
@@ -128,6 +132,12 @@ interface WorktreeActionsDropdownProps {
|
|||||||
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
|
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
|
||||||
onContinueOperation?: (worktree: WorktreeInfo) => void;
|
onContinueOperation?: (worktree: WorktreeInfo) => void;
|
||||||
hasInitScript: boolean;
|
hasInitScript: boolean;
|
||||||
|
/** Terminal quick scripts configured for the project */
|
||||||
|
terminalScripts?: TerminalScript[];
|
||||||
|
/** Callback to run a terminal quick script in a new terminal session */
|
||||||
|
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
|
||||||
|
/** Callback to open the script editor UI */
|
||||||
|
onEditScripts?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorktreeActionsDropdown({
|
export function WorktreeActionsDropdown({
|
||||||
@@ -166,6 +176,7 @@ export function WorktreeActionsDropdown({
|
|||||||
onCommit,
|
onCommit,
|
||||||
onCreatePR,
|
onCreatePR,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
|
onAutoAddressPRComments,
|
||||||
onResolveConflicts,
|
onResolveConflicts,
|
||||||
onDeleteWorktree,
|
onDeleteWorktree,
|
||||||
onStartDevServer,
|
onStartDevServer,
|
||||||
@@ -184,6 +195,9 @@ export function WorktreeActionsDropdown({
|
|||||||
onAbortOperation,
|
onAbortOperation,
|
||||||
onContinueOperation,
|
onContinueOperation,
|
||||||
hasInitScript,
|
hasInitScript,
|
||||||
|
terminalScripts,
|
||||||
|
onRunTerminalScript,
|
||||||
|
onEditScripts,
|
||||||
}: WorktreeActionsDropdownProps) {
|
}: WorktreeActionsDropdownProps) {
|
||||||
// Get available editors for the "Open In" submenu
|
// Get available editors for the "Open In" submenu
|
||||||
const { editors } = useAvailableEditors();
|
const { editors } = useAvailableEditors();
|
||||||
@@ -238,6 +252,28 @@ export function WorktreeActionsDropdown({
|
|||||||
// Determine if the destructive/bottom section has any visible items
|
// Determine if the destructive/bottom section has any visible items
|
||||||
const hasDestructiveSectionContent = worktree.hasChanges || !worktree.isMain;
|
const hasDestructiveSectionContent = worktree.hasChanges || !worktree.isMain;
|
||||||
|
|
||||||
|
// Pre-compute PR info for the PR submenu (avoids an IIFE in JSX)
|
||||||
|
const prInfo = useMemo<PRInfo | null>(() => {
|
||||||
|
if (!showPRInfo || !worktree.pr) return null;
|
||||||
|
return {
|
||||||
|
number: worktree.pr.number,
|
||||||
|
title: worktree.pr.title,
|
||||||
|
url: worktree.pr.url,
|
||||||
|
state: worktree.pr.state,
|
||||||
|
author: '',
|
||||||
|
body: '',
|
||||||
|
comments: [],
|
||||||
|
reviewComments: [],
|
||||||
|
};
|
||||||
|
}, [showPRInfo, worktree.pr]);
|
||||||
|
|
||||||
|
const viewDevServerLogsItem = (
|
||||||
|
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
|
||||||
|
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||||
|
View Dev Server Logs
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu onOpenChange={onOpenChange}>
|
<DropdownMenu onOpenChange={onOpenChange}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -358,42 +394,59 @@ export function WorktreeActionsDropdown({
|
|||||||
? 'Dev Server Starting...'
|
? 'Dev Server Starting...'
|
||||||
: `Dev Server Running (:${devServerInfo?.port})`}
|
: `Dev Server Running (:${devServerInfo?.port})`}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuItem
|
{devServerInfo != null &&
|
||||||
onClick={() => onOpenDevServerUrl(worktree)}
|
devServerInfo.port != null &&
|
||||||
className="text-xs"
|
devServerInfo.urlDetected !== false && (
|
||||||
disabled={devServerInfo?.urlDetected === false}
|
<DropdownMenuItem
|
||||||
aria-label={
|
onClick={() => onOpenDevServerUrl(worktree)}
|
||||||
devServerInfo?.urlDetected === false
|
className="text-xs"
|
||||||
? 'Open dev server in browser'
|
aria-label={`Open dev server on port ${devServerInfo.port} in browser`}
|
||||||
: `Open dev server on port ${devServerInfo?.port} in browser`
|
>
|
||||||
}
|
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
|
||||||
>
|
Open in Browser
|
||||||
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
|
</DropdownMenuItem>
|
||||||
Open in Browser
|
)}
|
||||||
</DropdownMenuItem>
|
{/* Stop Dev Server - split button: click main area to stop, chevron for view logs */}
|
||||||
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
|
<DropdownMenuSub>
|
||||||
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
<div className="flex items-center">
|
||||||
View Logs
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={() => onStopDevServer(worktree)}
|
||||||
<DropdownMenuItem
|
className="text-xs flex-1 pr-0 rounded-r-none text-destructive focus:text-destructive"
|
||||||
onClick={() => onStopDevServer(worktree)}
|
>
|
||||||
className="text-xs text-destructive focus:text-destructive"
|
<Square className="w-3.5 h-3.5 mr-2" />
|
||||||
>
|
Stop Dev Server
|
||||||
<Square className="w-3.5 h-3.5 mr-2" />
|
</DropdownMenuItem>
|
||||||
Stop Dev Server
|
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||||
</DropdownMenuItem>
|
</div>
|
||||||
|
<DropdownMenuSubContent>{viewDevServerLogsItem}</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuItem
|
{/* Start Dev Server - split button: click main area to start, chevron for view logs */}
|
||||||
onClick={() => onStartDevServer(worktree)}
|
<DropdownMenuSub>
|
||||||
disabled={isStartingDevServer}
|
<div className="flex items-center">
|
||||||
className="text-xs"
|
<DropdownMenuItem
|
||||||
>
|
onClick={() => onStartDevServer(worktree)}
|
||||||
<Play className={cn('w-3.5 h-3.5 mr-2', isStartingDevServer && 'animate-pulse')} />
|
disabled={isStartingDevServer}
|
||||||
{isStartingDevServer ? 'Starting...' : 'Start Dev Server'}
|
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||||
</DropdownMenuItem>
|
>
|
||||||
|
<Play
|
||||||
|
className={cn('w-3.5 h-3.5 mr-2', isStartingDevServer && 'animate-pulse')}
|
||||||
|
/>
|
||||||
|
{isStartingDevServer ? 'Starting...' : 'Start Dev Server'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSubTrigger
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
||||||
|
isStartingDevServer && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
disabled={isStartingDevServer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSubContent>{viewDevServerLogsItem}</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -575,12 +628,57 @@ export function WorktreeActionsDropdown({
|
|||||||
})}
|
})}
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
{!worktree.isMain && hasInitScript && (
|
{/* Scripts submenu - consolidates init script and terminal quick scripts */}
|
||||||
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
|
<DropdownMenuSub>
|
||||||
<RefreshCw className="w-3.5 h-3.5 mr-2" />
|
<DropdownMenuSubTrigger className="text-xs">
|
||||||
Re-run Init Script
|
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||||
</DropdownMenuItem>
|
Scripts
|
||||||
)}
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent className="w-52">
|
||||||
|
{/* Re-run Init Script - always shown for non-main worktrees, disabled when no init script configured or no handler */}
|
||||||
|
{!worktree.isMain && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onRunInitScript(worktree)}
|
||||||
|
className="text-xs"
|
||||||
|
disabled={!hasInitScript}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Re-run Init Script
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Terminal quick scripts */}
|
||||||
|
{terminalScripts && terminalScripts.length > 0 ? (
|
||||||
|
terminalScripts.map((script) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={script.id}
|
||||||
|
onClick={() => onRunTerminalScript?.(worktree, script.command)}
|
||||||
|
className="text-xs"
|
||||||
|
disabled={!onRunTerminalScript}
|
||||||
|
>
|
||||||
|
<Play className="w-3.5 h-3.5 mr-2 shrink-0" />
|
||||||
|
<span className="truncate">{script.name}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem disabled className="text-xs text-muted-foreground">
|
||||||
|
No scripts configured
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{/* Divider before Edit Commands & Scripts */}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onEditScripts?.()}
|
||||||
|
className="text-xs"
|
||||||
|
disabled={!onEditScripts}
|
||||||
|
>
|
||||||
|
<Settings2 className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Edit Commands & Scripts
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||||
{remotes && remotes.length > 1 && onPullWithRemote ? (
|
{remotes && remotes.length > 1 && onPullWithRemote ? (
|
||||||
@@ -815,32 +913,67 @@ export function WorktreeActionsDropdown({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)}
|
)}
|
||||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
{/* View Commits - split button when Cherry Pick is available:
|
||||||
<DropdownMenuItem
|
click main area to view commits directly, chevron opens sub-menu with Cherry Pick */}
|
||||||
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
|
{onCherryPick ? (
|
||||||
disabled={!isGitOpsAvailable}
|
<DropdownMenuSub>
|
||||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
<TooltipWrapper
|
||||||
>
|
showTooltip={!!gitOpsDisabledReason}
|
||||||
<History className="w-3.5 h-3.5 mr-2" />
|
tooltipContent={gitOpsDisabledReason}
|
||||||
View Commits
|
>
|
||||||
{!isGitOpsAvailable && (
|
<div className="flex items-center">
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
{/* Main clickable area - opens commit history directly */}
|
||||||
)}
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
|
||||||
</TooltipWrapper>
|
disabled={!isGitOpsAvailable}
|
||||||
{/* Cherry-pick commits from another branch */}
|
className={cn(
|
||||||
{onCherryPick && (
|
'text-xs flex-1 pr-0 rounded-r-none',
|
||||||
|
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<History className="w-3.5 h-3.5 mr-2" />
|
||||||
|
View Commits
|
||||||
|
{!isGitOpsAvailable && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{/* Chevron trigger for sub-menu containing Cherry Pick */}
|
||||||
|
<DropdownMenuSubTrigger
|
||||||
|
disabled={!isGitOpsAvailable}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
||||||
|
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipWrapper>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
{/* Cherry-pick commits from another branch */}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => isGitOpsAvailable && onCherryPick(worktree)}
|
||||||
|
disabled={!isGitOpsAvailable}
|
||||||
|
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||||
|
>
|
||||||
|
<Cherry className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Cherry Pick
|
||||||
|
{!isGitOpsAvailable && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
) : (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
showTooltip={!!gitOpsDisabledReason}
|
showTooltip={!!gitOpsDisabledReason}
|
||||||
tooltipContent={gitOpsDisabledReason}
|
tooltipContent={gitOpsDisabledReason}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => isGitOpsAvailable && onCherryPick(worktree)}
|
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
|
||||||
disabled={!isGitOpsAvailable}
|
disabled={!isGitOpsAvailable}
|
||||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||||
>
|
>
|
||||||
<Cherry className="w-3.5 h-3.5 mr-2" />
|
<History className="w-3.5 h-3.5 mr-2" />
|
||||||
Cherry Pick
|
View Commits
|
||||||
{!isGitOpsAvailable && (
|
{!isGitOpsAvailable && (
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
@@ -849,81 +982,67 @@ export function WorktreeActionsDropdown({
|
|||||||
)}
|
)}
|
||||||
{(hasChangesSectionContent || hasDestructiveSectionContent) && <DropdownMenuSeparator />}
|
{(hasChangesSectionContent || hasDestructiveSectionContent) && <DropdownMenuSeparator />}
|
||||||
|
|
||||||
{worktree.hasChanges && (
|
{/* View Changes split button - main action views changes directly, chevron reveals stash options.
|
||||||
<DropdownMenuItem onClick={() => onViewChanges(worktree)} className="text-xs">
|
|
||||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
|
||||||
View Changes
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{/* Stash operations - combined submenu or simple item.
|
|
||||||
Only render when at least one action is meaningful:
|
Only render when at least one action is meaningful:
|
||||||
- (worktree.hasChanges && onStashChanges): stashing changes is possible
|
- worktree.hasChanges: View Changes action is available
|
||||||
- onViewStashes: viewing existing stashes is possible
|
- (worktree.hasChanges && onStashChanges): Create Stash action is possible
|
||||||
Without this guard, the item would appear clickable but be a silent no-op
|
- onViewStashes: viewing existing stashes is possible */}
|
||||||
when hasChanges is false and onViewStashes is undefined. */}
|
{/* View Changes split button - show submenu only when there are non-duplicate sub-actions */}
|
||||||
{((worktree.hasChanges && onStashChanges) || onViewStashes) && (
|
{worktree.hasChanges && (onStashChanges || onViewStashes) ? (
|
||||||
<TooltipWrapper showTooltip={!isGitOpsAvailable} tooltipContent={gitOpsDisabledReason}>
|
<DropdownMenuSub>
|
||||||
{onViewStashes && worktree.hasChanges && onStashChanges ? (
|
<div className="flex items-center">
|
||||||
// Both "Stash Changes" (primary) and "View Stashes" (secondary) are available - show split submenu
|
{/* Main clickable area - view changes (primary action) */}
|
||||||
<DropdownMenuSub>
|
<DropdownMenuItem
|
||||||
<div className="flex items-center">
|
onClick={() => onViewChanges(worktree)}
|
||||||
{/* Main clickable area - stash changes (primary action) */}
|
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||||
|
View Changes
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{/* Chevron trigger for submenu with stash options */}
|
||||||
|
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
{onStashChanges && (
|
||||||
|
<TooltipWrapper
|
||||||
|
showTooltip={!isGitOpsAvailable}
|
||||||
|
tooltipContent={gitOpsDisabledReason}
|
||||||
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isGitOpsAvailable) return;
|
if (!isGitOpsAvailable) return;
|
||||||
onStashChanges(worktree);
|
onStashChanges(worktree);
|
||||||
}}
|
}}
|
||||||
disabled={!isGitOpsAvailable}
|
disabled={!isGitOpsAvailable}
|
||||||
className={cn(
|
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||||
'text-xs flex-1 pr-0 rounded-r-none',
|
|
||||||
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
<Archive className="w-3.5 h-3.5 mr-2" />
|
||||||
Stash Changes
|
Create Stash
|
||||||
{!isGitOpsAvailable && (
|
{!isGitOpsAvailable && (
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{/* Chevron trigger for submenu with stash options */}
|
</TooltipWrapper>
|
||||||
<DropdownMenuSubTrigger
|
)}
|
||||||
className={cn(
|
{onViewStashes && (
|
||||||
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
|
||||||
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||||
)}
|
View Stashes
|
||||||
disabled={!isGitOpsAvailable}
|
</DropdownMenuItem>
|
||||||
/>
|
)}
|
||||||
</div>
|
</DropdownMenuSubContent>
|
||||||
<DropdownMenuSubContent>
|
</DropdownMenuSub>
|
||||||
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
|
) : worktree.hasChanges ? (
|
||||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
<DropdownMenuItem onClick={() => onViewChanges(worktree)} className="text-xs">
|
||||||
View Stashes
|
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||||
</DropdownMenuItem>
|
View Changes
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuSub>
|
) : onViewStashes ? (
|
||||||
) : (
|
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
|
||||||
// Only one action is meaningful - render a simple menu item without submenu
|
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||||
<DropdownMenuItem
|
View Stashes
|
||||||
onClick={() => {
|
</DropdownMenuItem>
|
||||||
if (!isGitOpsAvailable) return;
|
) : null}
|
||||||
if (worktree.hasChanges && onStashChanges) {
|
|
||||||
onStashChanges(worktree);
|
|
||||||
} else if (onViewStashes) {
|
|
||||||
onViewStashes(worktree);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!isGitOpsAvailable}
|
|
||||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
|
||||||
>
|
|
||||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
|
||||||
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
|
|
||||||
{!isGitOpsAvailable && (
|
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</TooltipWrapper>
|
|
||||||
)}
|
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
showTooltip={!!gitOpsDisabledReason}
|
showTooltip={!!gitOpsDisabledReason}
|
||||||
@@ -961,43 +1080,52 @@ export function WorktreeActionsDropdown({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)}
|
)}
|
||||||
{/* Show PR info and Address Comments button if PR exists */}
|
{/* Show PR info with Address Comments in sub-menu if PR exists */}
|
||||||
{showPRInfo && worktree.pr && (
|
{prInfo && worktree.pr && (
|
||||||
<>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuItem
|
<div className="flex items-center">
|
||||||
onClick={() => {
|
{/* Main clickable area - opens PR in browser */}
|
||||||
window.open(worktree.pr!.url, '_blank', 'noopener,noreferrer');
|
<DropdownMenuItem
|
||||||
}}
|
onClick={() => {
|
||||||
className="text-xs"
|
window.open(worktree.pr!.url, '_blank', 'noopener,noreferrer');
|
||||||
>
|
}}
|
||||||
<GitPullRequest className="w-3 h-3 mr-2" />
|
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||||
PR #{worktree.pr.number}
|
>
|
||||||
<span className="ml-auto text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded uppercase">
|
<GitPullRequest className="w-3 h-3 mr-2" />
|
||||||
{worktree.pr.state}
|
PR #{worktree.pr.number}
|
||||||
</span>
|
<span
|
||||||
</DropdownMenuItem>
|
className={cn(
|
||||||
<DropdownMenuItem
|
'ml-auto mr-1 text-[10px] px-1.5 py-0.5 rounded uppercase',
|
||||||
onClick={() => {
|
worktree.pr.state === 'MERGED'
|
||||||
// Convert stored PR info to the full PRInfo format for the handler
|
? 'bg-purple-500/20 text-purple-600'
|
||||||
// The handler will fetch full comments from GitHub
|
: worktree.pr.state === 'CLOSED'
|
||||||
const prInfo: PRInfo = {
|
? 'bg-gray-500/20 text-gray-500'
|
||||||
number: worktree.pr!.number,
|
: 'bg-green-500/20 text-green-600'
|
||||||
title: worktree.pr!.title,
|
)}
|
||||||
url: worktree.pr!.url,
|
>
|
||||||
state: worktree.pr!.state,
|
{worktree.pr.state}
|
||||||
author: '', // Will be fetched
|
</span>
|
||||||
body: '', // Will be fetched
|
</DropdownMenuItem>
|
||||||
comments: [],
|
{/* Chevron trigger for submenu with PR actions */}
|
||||||
reviewComments: [],
|
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||||
};
|
</div>
|
||||||
onAddressPRComments(worktree, prInfo);
|
<DropdownMenuSubContent>
|
||||||
}}
|
<DropdownMenuItem
|
||||||
className="text-xs text-blue-500 focus:text-blue-600"
|
onClick={() => onAddressPRComments(worktree, prInfo)}
|
||||||
>
|
className="text-xs text-blue-500 focus:text-blue-600"
|
||||||
<MessageSquare className="w-3.5 h-3.5 mr-2" />
|
>
|
||||||
Address PR Comments
|
<MessageSquare className="w-3.5 h-3.5 mr-2" />
|
||||||
</DropdownMenuItem>
|
Manage PR Comments
|
||||||
</>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onAutoAddressPRComments(worktree, prInfo)}
|
||||||
|
className="text-xs text-blue-500 focus:text-blue-600"
|
||||||
|
>
|
||||||
|
<Zap className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Address PR Comments
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
)}
|
)}
|
||||||
{hasChangesSectionContent && hasDestructiveSectionContent && <DropdownMenuSeparator />}
|
{hasChangesSectionContent && hasDestructiveSectionContent && <DropdownMenuSeparator />}
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
|
|||||||
@@ -144,8 +144,8 @@ export function WorktreeDropdownItem({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dev server indicator */}
|
{/* Dev server indicator - hidden when URL detection explicitly failed */}
|
||||||
{devServerRunning && (
|
{devServerRunning && devServerInfo?.urlDetected !== false && (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center justify-center h-4 w-4 text-green-500"
|
className="inline-flex items-center justify-center h-4 w-4 text-green-500"
|
||||||
title={`Dev server running on port ${devServerInfo?.port}`}
|
title={`Dev server running on port ${devServerInfo?.port}`}
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export interface WorktreeDropdownProps {
|
|||||||
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;
|
||||||
|
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
onMerge: (worktree: WorktreeInfo) => void;
|
onMerge: (worktree: WorktreeInfo) => void;
|
||||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||||
@@ -131,6 +132,12 @@ export interface WorktreeDropdownProps {
|
|||||||
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
/** Push to a specific remote, bypassing the remote selection dialog */
|
/** Push to a specific remote, bypassing the remote selection dialog */
|
||||||
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
|
/** Terminal quick scripts configured for the project */
|
||||||
|
terminalScripts?: import('@/components/views/project-settings-view/terminal-scripts-constants').TerminalScript[];
|
||||||
|
/** Callback to run a terminal quick script in a new terminal session */
|
||||||
|
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
|
||||||
|
/** Callback to open the script editor UI */
|
||||||
|
onEditScripts?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,6 +206,7 @@ export function WorktreeDropdown({
|
|||||||
onCommit,
|
onCommit,
|
||||||
onCreatePR,
|
onCreatePR,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
|
onAutoAddressPRComments,
|
||||||
onResolveConflicts,
|
onResolveConflicts,
|
||||||
onMerge,
|
onMerge,
|
||||||
onDeleteWorktree,
|
onDeleteWorktree,
|
||||||
@@ -219,6 +227,9 @@ export function WorktreeDropdown({
|
|||||||
remotesCache,
|
remotesCache,
|
||||||
onPullWithRemote,
|
onPullWithRemote,
|
||||||
onPushWithRemote,
|
onPushWithRemote,
|
||||||
|
terminalScripts,
|
||||||
|
onRunTerminalScript,
|
||||||
|
onEditScripts,
|
||||||
}: WorktreeDropdownProps) {
|
}: WorktreeDropdownProps) {
|
||||||
// Find the currently selected worktree to display in the trigger
|
// Find the currently selected worktree to display in the trigger
|
||||||
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
|
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
|
||||||
@@ -304,15 +315,11 @@ export function WorktreeDropdown({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dev server indicator */}
|
{/* Dev server indicator - only shown when port is confirmed detected */}
|
||||||
{selectedStatus.devServerRunning && (
|
{selectedStatus.devServerRunning && selectedStatus.devServerInfo?.urlDetected !== false && (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0"
|
className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0"
|
||||||
title={
|
title={`Dev server running on port ${selectedStatus.devServerInfo?.port}`}
|
||||||
selectedStatus.devServerInfo?.urlDetected === false
|
|
||||||
? 'Dev server starting...'
|
|
||||||
: `Dev server running on port ${selectedStatus.devServerInfo?.port}`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Globe className="w-3 h-3" />
|
<Globe className="w-3 h-3" />
|
||||||
</span>
|
</span>
|
||||||
@@ -520,6 +527,7 @@ export function WorktreeDropdown({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={onMerge}
|
onMerge={onMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
@@ -538,6 +546,9 @@ export function WorktreeDropdown({
|
|||||||
onAbortOperation={onAbortOperation}
|
onAbortOperation={onAbortOperation}
|
||||||
onContinueOperation={onContinueOperation}
|
onContinueOperation={onContinueOperation}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
|
terminalScripts={terminalScripts}
|
||||||
|
onRunTerminalScript={onRunTerminalScript}
|
||||||
|
onEditScripts={onEditScripts}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ interface WorktreeTabProps {
|
|||||||
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;
|
||||||
|
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
onMerge: (worktree: WorktreeInfo) => void;
|
onMerge: (worktree: WorktreeInfo) => void;
|
||||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||||
@@ -101,6 +102,12 @@ interface WorktreeTabProps {
|
|||||||
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
/** Push to a specific remote, bypassing the remote selection dialog */
|
/** Push to a specific remote, bypassing the remote selection dialog */
|
||||||
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
|
/** Terminal quick scripts configured for the project */
|
||||||
|
terminalScripts?: import('@/components/views/project-settings-view/terminal-scripts-constants').TerminalScript[];
|
||||||
|
/** Callback to run a terminal quick script in a new terminal session */
|
||||||
|
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
|
||||||
|
/** Callback to open the script editor UI */
|
||||||
|
onEditScripts?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorktreeTab({
|
export function WorktreeTab({
|
||||||
@@ -148,6 +155,7 @@ export function WorktreeTab({
|
|||||||
onCommit,
|
onCommit,
|
||||||
onCreatePR,
|
onCreatePR,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
|
onAutoAddressPRComments,
|
||||||
onResolveConflicts,
|
onResolveConflicts,
|
||||||
onMerge,
|
onMerge,
|
||||||
onDeleteWorktree,
|
onDeleteWorktree,
|
||||||
@@ -170,6 +178,9 @@ export function WorktreeTab({
|
|||||||
remotes,
|
remotes,
|
||||||
onPullWithRemote,
|
onPullWithRemote,
|
||||||
onPushWithRemote,
|
onPushWithRemote,
|
||||||
|
terminalScripts,
|
||||||
|
onRunTerminalScript,
|
||||||
|
onEditScripts,
|
||||||
}: WorktreeTabProps) {
|
}: WorktreeTabProps) {
|
||||||
// Make the worktree tab a drop target for feature cards
|
// Make the worktree tab a drop target for feature cards
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
@@ -440,7 +451,7 @@ export function WorktreeTab({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isDevServerRunning && (
|
{isDevServerRunning && devServerInfo?.urlDetected !== false && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -517,6 +528,7 @@ export function WorktreeTab({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={onMerge}
|
onMerge={onMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
@@ -535,6 +547,9 @@ export function WorktreeTab({
|
|||||||
onAbortOperation={onAbortOperation}
|
onAbortOperation={onAbortOperation}
|
||||||
onContinueOperation={onContinueOperation}
|
onContinueOperation={onContinueOperation}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
|
terminalScripts={terminalScripts}
|
||||||
|
onRunTerminalScript={onRunTerminalScript}
|
||||||
|
onEditScripts={onEditScripts}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { normalizePath } from '@/lib/utils';
|
import { normalizePath } from '@/lib/utils';
|
||||||
@@ -7,14 +7,175 @@ import type { DevServerInfo, WorktreeInfo } from '../types';
|
|||||||
|
|
||||||
const logger = createLogger('DevServers');
|
const logger = createLogger('DevServers');
|
||||||
|
|
||||||
|
// Timeout (ms) for port detection before showing a warning to the user
|
||||||
|
const PORT_DETECTION_TIMEOUT_MS = 30_000;
|
||||||
|
// Interval (ms) for periodic state reconciliation with the backend
|
||||||
|
const STATE_RECONCILE_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
interface UseDevServersOptions {
|
interface UseDevServersOptions {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to build the browser-accessible dev server URL by rewriting the hostname
|
||||||
|
* to match the current window's hostname (supports remote access).
|
||||||
|
* Returns null if the URL is invalid or uses an unsupported protocol.
|
||||||
|
*/
|
||||||
|
function buildDevServerBrowserUrl(serverUrl: string): string | null {
|
||||||
|
try {
|
||||||
|
const devServerUrl = new URL(serverUrl);
|
||||||
|
// Security: Only allow http/https protocols
|
||||||
|
if (devServerUrl.protocol !== 'http:' && devServerUrl.protocol !== 'https:') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
devServerUrl.hostname = window.location.hostname;
|
||||||
|
return devServerUrl.toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a toast notification for a detected dev server URL.
|
||||||
|
* Extracted to avoid duplication between event handler and reconciliation paths.
|
||||||
|
*/
|
||||||
|
function showUrlDetectedToast(url: string, port: number): void {
|
||||||
|
const browserUrl = buildDevServerBrowserUrl(url);
|
||||||
|
toast.success(`Dev server running on port ${port}`, {
|
||||||
|
description: browserUrl ? browserUrl : url,
|
||||||
|
action: browserUrl
|
||||||
|
? {
|
||||||
|
label: 'Open in Browser',
|
||||||
|
onClick: () => {
|
||||||
|
window.open(browserUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
duration: 8000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useDevServers({ projectPath }: UseDevServersOptions) {
|
export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||||
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
|
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
|
||||||
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(new Map());
|
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(new Map());
|
||||||
|
|
||||||
|
// Track which worktrees have had their url-detected toast shown to prevent re-triggering
|
||||||
|
const toastShownForRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Track port detection timeouts per worktree key
|
||||||
|
const portDetectionTimers = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||||
|
|
||||||
|
// Track whether initial fetch has completed to avoid reconciliation race
|
||||||
|
const initialFetchDone = useRef(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear a port detection timeout for a given key
|
||||||
|
*/
|
||||||
|
const clearPortDetectionTimer = useCallback((key: string) => {
|
||||||
|
const timer = portDetectionTimers.current.get(key);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
portDetectionTimers.current.delete(key);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a port detection timeout for a server that hasn't detected its URL yet.
|
||||||
|
* After PORT_DETECTION_TIMEOUT_MS, if still undetected, show a warning toast
|
||||||
|
* and attempt to reconcile state with the backend.
|
||||||
|
*/
|
||||||
|
const startPortDetectionTimer = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
// Clear any existing timer for this key
|
||||||
|
clearPortDetectionTimer(key);
|
||||||
|
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
portDetectionTimers.current.delete(key);
|
||||||
|
|
||||||
|
// Check if the server is still in undetected state.
|
||||||
|
// Use a setState-updater-as-reader to access the latest state snapshot,
|
||||||
|
// but keep the updater pure (no side effects, just reads).
|
||||||
|
let needsReconciliation = false;
|
||||||
|
setRunningDevServers((prev) => {
|
||||||
|
const server = prev.get(key);
|
||||||
|
needsReconciliation = !!server && !server.urlDetected;
|
||||||
|
return prev; // no state change
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!needsReconciliation) return;
|
||||||
|
|
||||||
|
logger.warn(`Port detection timeout for ${key} after ${PORT_DETECTION_TIMEOUT_MS}ms`);
|
||||||
|
|
||||||
|
// Try to reconcile with backend - the server may have detected the URL
|
||||||
|
// but the WebSocket event was missed
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.listDevServers) return;
|
||||||
|
const result = await api.worktree.listDevServers();
|
||||||
|
if (result.success && result.result?.servers) {
|
||||||
|
const backendServer = result.result.servers.find(
|
||||||
|
(s) => normalizePath(s.worktreePath) === key
|
||||||
|
);
|
||||||
|
if (backendServer && backendServer.urlDetected) {
|
||||||
|
// Backend has detected the URL - update our state
|
||||||
|
logger.info(`Port detection reconciled from backend for ${key}`);
|
||||||
|
setRunningDevServers((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(key, {
|
||||||
|
...backendServer,
|
||||||
|
urlDetected: true,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (!toastShownForRef.current.has(key)) {
|
||||||
|
toastShownForRef.current.add(key);
|
||||||
|
showUrlDetectedToast(backendServer.url, backendServer.port);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!backendServer) {
|
||||||
|
// Server is no longer running on the backend - remove from state
|
||||||
|
logger.info(`Server ${key} no longer running on backend, removing from state`);
|
||||||
|
setRunningDevServers((prev) => {
|
||||||
|
if (!prev.has(key)) return prev;
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
toastShownForRef.current.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to reconcile port detection:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, the backend also hasn't detected the URL - show warning
|
||||||
|
toast.warning('Port detection is taking longer than expected', {
|
||||||
|
description:
|
||||||
|
'The dev server may be slow to start, or the port output format is not recognized.',
|
||||||
|
action: {
|
||||||
|
label: 'Retry',
|
||||||
|
onClick: () => {
|
||||||
|
// Use ref to get the latest startPortDetectionTimer, avoiding stale closure
|
||||||
|
startPortDetectionTimerRef.current(key);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
}, PORT_DETECTION_TIMEOUT_MS);
|
||||||
|
|
||||||
|
portDetectionTimers.current.set(key, timer);
|
||||||
|
},
|
||||||
|
[clearPortDetectionTimer]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ref to hold the latest startPortDetectionTimer callback, avoiding stale closures
|
||||||
|
// in long-lived callbacks like toast action handlers
|
||||||
|
const startPortDetectionTimerRef = useRef(startPortDetectionTimer);
|
||||||
|
startPortDetectionTimerRef.current = startPortDetectionTimer;
|
||||||
|
|
||||||
const fetchDevServers = useCallback(async () => {
|
const fetchDevServers = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
@@ -25,23 +186,142 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
if (result.success && result.result?.servers) {
|
if (result.success && result.result?.servers) {
|
||||||
const serversMap = new Map<string, DevServerInfo>();
|
const serversMap = new Map<string, DevServerInfo>();
|
||||||
for (const server of result.result.servers) {
|
for (const server of result.result.servers) {
|
||||||
serversMap.set(normalizePath(server.worktreePath), {
|
const key = normalizePath(server.worktreePath);
|
||||||
|
serversMap.set(key, {
|
||||||
...server,
|
...server,
|
||||||
urlDetected: server.urlDetected ?? true,
|
urlDetected: server.urlDetected ?? true,
|
||||||
});
|
});
|
||||||
|
// Mark already-detected servers as having shown the toast
|
||||||
|
// so we don't re-trigger on initial load
|
||||||
|
if (server.urlDetected !== false) {
|
||||||
|
toastShownForRef.current.add(key);
|
||||||
|
// Clear any pending detection timer since URL is already detected
|
||||||
|
clearPortDetectionTimer(key);
|
||||||
|
} else {
|
||||||
|
// Server running but URL not yet detected - start timeout
|
||||||
|
startPortDetectionTimer(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setRunningDevServers(serversMap);
|
setRunningDevServers(serversMap);
|
||||||
}
|
}
|
||||||
|
initialFetchDone.current = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to fetch dev servers:', error);
|
logger.error('Failed to fetch dev servers:', error);
|
||||||
|
initialFetchDone.current = true;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [clearPortDetectionTimer, startPortDetectionTimer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDevServers();
|
fetchDevServers();
|
||||||
}, [fetchDevServers]);
|
}, [fetchDevServers]);
|
||||||
|
|
||||||
// Subscribe to url-detected events to update port/url when the actual dev server port is detected
|
// Periodic state reconciliation: poll backend to catch missed WebSocket events
|
||||||
|
// This handles edge cases like PWA restart, WebSocket reconnection gaps, etc.
|
||||||
|
useEffect(() => {
|
||||||
|
const reconcile = async () => {
|
||||||
|
if (!initialFetchDone.current) return;
|
||||||
|
// Skip reconciliation when the tab/panel is not visible to avoid
|
||||||
|
// unnecessary API calls while the user isn't looking at the panel.
|
||||||
|
if (document.hidden) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.listDevServers) return;
|
||||||
|
|
||||||
|
const result = await api.worktree.listDevServers();
|
||||||
|
if (!result.success || !result.result?.servers) return;
|
||||||
|
|
||||||
|
const backendServers = new Map<string, (typeof result.result.servers)[number]>();
|
||||||
|
for (const server of result.result.servers) {
|
||||||
|
backendServers.set(normalizePath(server.worktreePath), server);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect side-effect actions in a local array so the setState updater
|
||||||
|
// remains pure. Side effects are executed after the state update.
|
||||||
|
const sideEffects: Array<() => void> = [];
|
||||||
|
|
||||||
|
setRunningDevServers((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = new Map(prev);
|
||||||
|
|
||||||
|
// Add or update servers from backend
|
||||||
|
for (const [key, server] of backendServers) {
|
||||||
|
const existing = next.get(key);
|
||||||
|
if (!existing) {
|
||||||
|
// Server running on backend but not in our state - add it
|
||||||
|
sideEffects.push(() => logger.info(`Reconciliation: adding missing server ${key}`));
|
||||||
|
next.set(key, {
|
||||||
|
...server,
|
||||||
|
urlDetected: server.urlDetected ?? true,
|
||||||
|
});
|
||||||
|
if (server.urlDetected !== false) {
|
||||||
|
sideEffects.push(() => {
|
||||||
|
toastShownForRef.current.add(key);
|
||||||
|
clearPortDetectionTimer(key);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
sideEffects.push(() => startPortDetectionTimer(key));
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
} else if (!existing.urlDetected && server.urlDetected) {
|
||||||
|
// URL was detected on backend but we missed the event - update
|
||||||
|
sideEffects.push(() => {
|
||||||
|
logger.info(`Reconciliation: URL detected for ${key}`);
|
||||||
|
clearPortDetectionTimer(key);
|
||||||
|
if (!toastShownForRef.current.has(key)) {
|
||||||
|
toastShownForRef.current.add(key);
|
||||||
|
showUrlDetectedToast(server.url, server.port);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
next.set(key, {
|
||||||
|
...server,
|
||||||
|
urlDetected: true,
|
||||||
|
});
|
||||||
|
changed = true;
|
||||||
|
} else if (
|
||||||
|
existing.urlDetected &&
|
||||||
|
server.urlDetected &&
|
||||||
|
(existing.port !== server.port || existing.url !== server.url)
|
||||||
|
) {
|
||||||
|
// Port or URL changed between sessions - update
|
||||||
|
sideEffects.push(() => logger.info(`Reconciliation: port/URL changed for ${key}`));
|
||||||
|
next.set(key, {
|
||||||
|
...server,
|
||||||
|
urlDetected: true,
|
||||||
|
});
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove servers from our state that are no longer on the backend
|
||||||
|
for (const [key] of next) {
|
||||||
|
if (!backendServers.has(key)) {
|
||||||
|
sideEffects.push(() => {
|
||||||
|
logger.info(`Reconciliation: removing stale server ${key}`);
|
||||||
|
toastShownForRef.current.delete(key);
|
||||||
|
clearPortDetectionTimer(key);
|
||||||
|
});
|
||||||
|
next.delete(key);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute side effects outside the updater
|
||||||
|
for (const fn of sideEffects) fn();
|
||||||
|
} catch (error) {
|
||||||
|
// Reconciliation failures are non-critical - just log and continue
|
||||||
|
logger.debug('State reconciliation failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const intervalId = setInterval(reconcile, STATE_RECONCILE_INTERVAL_MS);
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [clearPortDetectionTimer, startPortDetectionTimer]);
|
||||||
|
|
||||||
|
// Subscribe to all dev server lifecycle events for reactive state updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.worktree?.onDevServerLogEvent) return;
|
if (!api?.worktree?.onDevServerLogEvent) return;
|
||||||
@@ -50,10 +330,26 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
if (event.type === 'dev-server:url-detected') {
|
if (event.type === 'dev-server:url-detected') {
|
||||||
const { worktreePath, url, port } = event.payload;
|
const { worktreePath, url, port } = event.payload;
|
||||||
const key = normalizePath(worktreePath);
|
const key = normalizePath(worktreePath);
|
||||||
|
// Clear the port detection timeout since URL was successfully detected
|
||||||
|
clearPortDetectionTimer(key);
|
||||||
let didUpdate = false;
|
let didUpdate = false;
|
||||||
setRunningDevServers((prev) => {
|
setRunningDevServers((prev) => {
|
||||||
const existing = prev.get(key);
|
const existing = prev.get(key);
|
||||||
if (!existing) return prev;
|
// If the server isn't in our state yet (e.g., race condition on first load
|
||||||
|
// where url-detected arrives before fetchDevServers completes), create the entry
|
||||||
|
if (!existing) {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(key, {
|
||||||
|
worktreePath,
|
||||||
|
url,
|
||||||
|
port,
|
||||||
|
urlDetected: true,
|
||||||
|
});
|
||||||
|
didUpdate = true;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
// Avoid updating if already detected with same url/port
|
||||||
|
if (existing.urlDetected && existing.url === url && existing.port === port) return prev;
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(key, {
|
next.set(key, {
|
||||||
...existing,
|
...existing,
|
||||||
@@ -66,12 +362,59 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
});
|
});
|
||||||
if (didUpdate) {
|
if (didUpdate) {
|
||||||
logger.info(`Dev server URL detected for ${worktreePath}: ${url} (port ${port})`);
|
logger.info(`Dev server URL detected for ${worktreePath}: ${url} (port ${port})`);
|
||||||
toast.success(`Dev server running on port ${port}`);
|
// Only show toast on the transition from undetected → detected (not on re-renders/polls)
|
||||||
|
if (!toastShownForRef.current.has(key)) {
|
||||||
|
toastShownForRef.current.add(key);
|
||||||
|
showUrlDetectedToast(url, port);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else if (event.type === 'dev-server:stopped') {
|
||||||
|
// Reactively remove the server from state when it stops
|
||||||
|
const { worktreePath } = event.payload;
|
||||||
|
const key = normalizePath(worktreePath);
|
||||||
|
// Clear any pending port detection timeout
|
||||||
|
clearPortDetectionTimer(key);
|
||||||
|
setRunningDevServers((prev) => {
|
||||||
|
if (!prev.has(key)) return prev;
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// Clear the toast tracking so a fresh detection will show a new toast
|
||||||
|
toastShownForRef.current.delete(key);
|
||||||
|
logger.info(`Dev server stopped for ${worktreePath} (reactive update)`);
|
||||||
|
} else if (event.type === 'dev-server:started') {
|
||||||
|
// Reactively add/update the server when it starts
|
||||||
|
const { worktreePath, port, url } = event.payload;
|
||||||
|
const key = normalizePath(worktreePath);
|
||||||
|
// Clear previous toast tracking for this key so a new detection triggers a fresh toast
|
||||||
|
toastShownForRef.current.delete(key);
|
||||||
|
setRunningDevServers((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(key, {
|
||||||
|
worktreePath,
|
||||||
|
port,
|
||||||
|
url,
|
||||||
|
urlDetected: false,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// Start port detection timeout for the new server
|
||||||
|
startPortDetectionTimer(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
|
}, [clearPortDetectionTimer, startPortDetectionTimer]);
|
||||||
|
|
||||||
|
// Cleanup all port detection timers on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
for (const timer of portDetectionTimers.current.values()) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
portDetectionTimers.current.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getWorktreeKey = useCallback(
|
const getWorktreeKey = useCallback(
|
||||||
@@ -98,9 +441,12 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
const result = await api.worktree.startDevServer(projectPath, targetPath);
|
const result = await api.worktree.startDevServer(projectPath, targetPath);
|
||||||
|
|
||||||
if (result.success && result.result) {
|
if (result.success && result.result) {
|
||||||
|
const key = normalizePath(targetPath);
|
||||||
|
// Clear toast tracking so the new port detection shows a fresh toast
|
||||||
|
toastShownForRef.current.delete(key);
|
||||||
setRunningDevServers((prev) => {
|
setRunningDevServers((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(normalizePath(targetPath), {
|
next.set(key, {
|
||||||
worktreePath: result.result!.worktreePath,
|
worktreePath: result.result!.worktreePath,
|
||||||
port: result.result!.port,
|
port: result.result!.port,
|
||||||
url: result.result!.url,
|
url: result.result!.url,
|
||||||
@@ -108,18 +454,26 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
toast.success('Dev server started, detecting port...');
|
// Start port detection timeout
|
||||||
|
startPortDetectionTimer(key);
|
||||||
|
toast.success('Dev server started, detecting port...', {
|
||||||
|
description: 'Logs are now visible in the dev server panel.',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || 'Failed to start dev server');
|
toast.error(result.error || 'Failed to start dev server', {
|
||||||
|
description: 'Check the dev server logs panel for details.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Start dev server failed:', error);
|
logger.error('Start dev server failed:', error);
|
||||||
toast.error('Failed to start dev server');
|
toast.error('Failed to start dev server', {
|
||||||
|
description: error instanceof Error ? error.message : undefined,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsStartingDevServer(false);
|
setIsStartingDevServer(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isStartingDevServer, projectPath]
|
[isStartingDevServer, projectPath, startPortDetectionTimer]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStopDevServer = useCallback(
|
const handleStopDevServer = useCallback(
|
||||||
@@ -135,11 +489,16 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
const result = await api.worktree.stopDevServer(targetPath);
|
const result = await api.worktree.stopDevServer(targetPath);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
const key = normalizePath(targetPath);
|
||||||
|
// Clear port detection timeout
|
||||||
|
clearPortDetectionTimer(key);
|
||||||
setRunningDevServers((prev) => {
|
setRunningDevServers((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.delete(normalizePath(targetPath));
|
next.delete(key);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
// Clear toast tracking so future restarts get a fresh toast
|
||||||
|
toastShownForRef.current.delete(key);
|
||||||
toast.success(result.result?.message || 'Dev server stopped');
|
toast.success(result.result?.message || 'Dev server stopped');
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || 'Failed to stop dev server');
|
toast.error(result.error || 'Failed to stop dev server');
|
||||||
@@ -149,7 +508,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
toast.error('Failed to stop dev server');
|
toast.error('Failed to stop dev server');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectPath]
|
[projectPath, clearPortDetectionTimer]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenDevServerUrl = useCallback(
|
const handleOpenDevServerUrl = useCallback(
|
||||||
@@ -163,30 +522,16 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const browserUrl = buildDevServerBrowserUrl(serverInfo.url);
|
||||||
// Rewrite URL hostname to match the current browser's hostname.
|
if (!browserUrl) {
|
||||||
// This ensures dev server URLs work when accessing Automaker from
|
logger.error('Invalid dev server URL:', serverInfo.url);
|
||||||
// remote machines (e.g., 192.168.x.x or hostname.local instead of localhost).
|
toast.error('Invalid dev server URL', {
|
||||||
const devServerUrl = new URL(serverInfo.url);
|
description: 'The server returned an unsupported URL protocol.',
|
||||||
|
|
||||||
// Security: Only allow http/https protocols to prevent potential attacks
|
|
||||||
// via data:, javascript:, file:, or other dangerous URL schemes
|
|
||||||
if (devServerUrl.protocol !== 'http:' && devServerUrl.protocol !== 'https:') {
|
|
||||||
logger.error('Invalid dev server URL protocol:', devServerUrl.protocol);
|
|
||||||
toast.error('Invalid dev server URL', {
|
|
||||||
description: 'The server returned an unsupported URL protocol.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
devServerUrl.hostname = window.location.hostname;
|
|
||||||
window.open(devServerUrl.toString(), '_blank', 'noopener,noreferrer');
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to parse dev server URL:', error);
|
|
||||||
toast.error('Failed to open dev server', {
|
|
||||||
description: 'The server URL could not be processed. Please try again.',
|
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.open(browserUrl, '_blank', 'noopener,noreferrer');
|
||||||
},
|
},
|
||||||
[runningDevServers, getWorktreeKey]
|
[runningDevServers, getWorktreeKey]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -163,6 +163,24 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
|
|||||||
[navigate]
|
[navigate]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleRunTerminalScript = useCallback(
|
||||||
|
(worktree: WorktreeInfo, command: string) => {
|
||||||
|
// Navigate to the terminal view with the worktree path, branch, and command to run
|
||||||
|
// The terminal view will create a new terminal and automatically execute the command
|
||||||
|
navigate({
|
||||||
|
to: '/terminal',
|
||||||
|
search: {
|
||||||
|
cwd: worktree.path,
|
||||||
|
branch: worktree.branch,
|
||||||
|
mode: 'tab' as const,
|
||||||
|
nonce: Date.now(),
|
||||||
|
command,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpenInEditor = useCallback(
|
const handleOpenInEditor = useCallback(
|
||||||
async (worktree: WorktreeInfo, editorCommand?: string) => {
|
async (worktree: WorktreeInfo, editorCommand?: string) => {
|
||||||
openInEditorMutation.mutate({
|
openInEditorMutation.mutate({
|
||||||
@@ -204,6 +222,7 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
|
|||||||
handlePull,
|
handlePull,
|
||||||
handlePush,
|
handlePush,
|
||||||
handleOpenInIntegratedTerminal,
|
handleOpenInIntegratedTerminal,
|
||||||
|
handleRunTerminalScript,
|
||||||
handleOpenInEditor,
|
handleOpenInEditor,
|
||||||
handleOpenInExternalTerminal,
|
handleOpenInExternalTerminal,
|
||||||
// Stash confirmation state for branch switching
|
// Stash confirmation state for branch switching
|
||||||
|
|||||||
@@ -28,10 +28,21 @@ export function useWorktrees({
|
|||||||
const { data, isLoading, refetch } = useWorktreesQuery(projectPath);
|
const { data, isLoading, refetch } = useWorktreesQuery(projectPath);
|
||||||
const worktrees = (data?.worktrees ?? []) as WorktreeInfo[];
|
const worktrees = (data?.worktrees ?? []) as WorktreeInfo[];
|
||||||
|
|
||||||
// Sync worktrees to Zustand store when they change
|
// Sync worktrees to Zustand store when they change.
|
||||||
|
// Use a ref to track the previous worktrees and skip the store update when the
|
||||||
|
// data hasn't structurally changed. Without this check, every React Query refetch
|
||||||
|
// (triggered by WebSocket event invalidations) would update the store even when
|
||||||
|
// the worktree list is identical, causing a cascade of re-renders in BoardView →
|
||||||
|
// selectedWorktree → useAutoMode → refreshStatus that can trigger React error #185.
|
||||||
|
const prevWorktreesJsonRef = useRef<string>('');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (worktrees.length > 0) {
|
if (worktrees.length > 0) {
|
||||||
setWorktreesInStore(projectPath, worktrees);
|
// Compare serialized worktrees to skip no-op store updates
|
||||||
|
const json = JSON.stringify(worktrees);
|
||||||
|
if (json !== prevWorktreesJsonRef.current) {
|
||||||
|
prevWorktreesJsonRef.current = json;
|
||||||
|
setWorktreesInStore(projectPath, worktrees);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [worktrees, projectPath, setWorktreesInStore]);
|
}, [worktrees, projectPath, setWorktreesInStore]);
|
||||||
|
|
||||||
@@ -87,11 +98,28 @@ export function useWorktrees({
|
|||||||
}
|
}
|
||||||
}, [worktrees, projectPath, setCurrentWorktree]);
|
}, [worktrees, projectPath, setCurrentWorktree]);
|
||||||
|
|
||||||
|
const currentWorktreePath = currentWorktree?.path ?? null;
|
||||||
|
|
||||||
const handleSelectWorktree = useCallback(
|
const handleSelectWorktree = useCallback(
|
||||||
(worktree: WorktreeInfo) => {
|
(worktree: WorktreeInfo) => {
|
||||||
|
// Skip invalidation when re-selecting the already-active worktree
|
||||||
|
const isSameWorktree = worktree.isMain
|
||||||
|
? currentWorktreePath === null
|
||||||
|
: pathsEqual(worktree.path, currentWorktreePath ?? '');
|
||||||
|
|
||||||
|
if (isSameWorktree) return;
|
||||||
|
|
||||||
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
|
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
|
||||||
|
|
||||||
|
// Invalidate feature queries when switching worktrees to ensure fresh data.
|
||||||
|
// Without this, feature cards that remount after the worktree switch may have stale
|
||||||
|
// or missing planSpec/task data, causing todo lists to appear empty until the next
|
||||||
|
// polling cycle or user interaction triggers a re-render.
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(projectPath),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[projectPath, setCurrentWorktree]
|
[projectPath, setCurrentWorktree, queryClient, currentWorktreePath]
|
||||||
);
|
);
|
||||||
|
|
||||||
// fetchWorktrees for backward compatibility - now just triggers a refetch
|
// fetchWorktrees for backward compatibility - now just triggers a refetch
|
||||||
@@ -110,7 +138,6 @@ export function useWorktrees({
|
|||||||
[projectPath, queryClient, refetch]
|
[projectPath, queryClient, refetch]
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentWorktreePath = currentWorktree?.path ?? null;
|
|
||||||
const selectedWorktree = currentWorktreePath
|
const selectedWorktree = currentWorktreePath
|
||||||
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
|
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
|
||||||
: worktrees.find((w) => w.isMain);
|
: worktrees.find((w) => w.isMain);
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export interface WorktreePanelProps {
|
|||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
|
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||||
/** Called when branch switch stash reapply results in merge conflicts */
|
/** Called when branch switch stash reapply results in merge conflicts */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
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 { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
@@ -9,6 +10,7 @@ import { useIsMobile } from '@/hooks/use-media-query';
|
|||||||
import { useWorktreeInitScript, useProjectSettings } from '@/hooks/queries';
|
import { useWorktreeInitScript, useProjectSettings } from '@/hooks/queries';
|
||||||
import { useTestRunnerEvents } from '@/hooks/use-test-runners';
|
import { useTestRunnerEvents } from '@/hooks/use-test-runners';
|
||||||
import { useTestRunnersStore } from '@/store/test-runners-store';
|
import { useTestRunnersStore } from '@/store/test-runners-store';
|
||||||
|
import { DEFAULT_TERMINAL_SCRIPTS } from '@/components/views/project-settings-view/terminal-scripts-constants';
|
||||||
import type {
|
import type {
|
||||||
TestRunnerStartedEvent,
|
TestRunnerStartedEvent,
|
||||||
TestRunnerOutputEvent,
|
TestRunnerOutputEvent,
|
||||||
@@ -59,6 +61,7 @@ export function WorktreePanel({
|
|||||||
onCreatePR,
|
onCreatePR,
|
||||||
onCreateBranch,
|
onCreateBranch,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
|
onAutoAddressPRComments,
|
||||||
onResolveConflicts,
|
onResolveConflicts,
|
||||||
onCreateMergeConflictResolutionFeature,
|
onCreateMergeConflictResolutionFeature,
|
||||||
onBranchSwitchConflict,
|
onBranchSwitchConflict,
|
||||||
@@ -116,6 +119,7 @@ export function WorktreePanel({
|
|||||||
handlePull: _handlePull,
|
handlePull: _handlePull,
|
||||||
handlePush,
|
handlePush,
|
||||||
handleOpenInIntegratedTerminal,
|
handleOpenInIntegratedTerminal,
|
||||||
|
handleRunTerminalScript,
|
||||||
handleOpenInEditor,
|
handleOpenInEditor,
|
||||||
handleOpenInExternalTerminal,
|
handleOpenInExternalTerminal,
|
||||||
pendingSwitch,
|
pendingSwitch,
|
||||||
@@ -209,6 +213,21 @@ export function WorktreePanel({
|
|||||||
const { data: projectSettings } = useProjectSettings(projectPath);
|
const { data: projectSettings } = useProjectSettings(projectPath);
|
||||||
const hasTestCommand = !!projectSettings?.testCommand;
|
const hasTestCommand = !!projectSettings?.testCommand;
|
||||||
|
|
||||||
|
// Get terminal quick scripts from project settings (or fall back to defaults)
|
||||||
|
const terminalScripts = useMemo(() => {
|
||||||
|
const configured = projectSettings?.terminalScripts;
|
||||||
|
if (configured && configured.length > 0) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
return DEFAULT_TERMINAL_SCRIPTS;
|
||||||
|
}, [projectSettings?.terminalScripts]);
|
||||||
|
|
||||||
|
// Navigate to project settings to edit scripts
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const handleEditScripts = useCallback(() => {
|
||||||
|
navigate({ to: '/project-settings', search: { section: 'commands-scripts' } });
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
// Test runner state management
|
// Test runner state management
|
||||||
// Use the test runners store to get global state for all worktrees
|
// Use the test runners store to get global state for all worktrees
|
||||||
const testRunnersStore = useTestRunnersStore();
|
const testRunnersStore = useTestRunnersStore();
|
||||||
@@ -640,6 +659,18 @@ export function WorktreePanel({
|
|||||||
// Keep logPanelWorktree set for smooth close animation
|
// Keep logPanelWorktree set for smooth close animation
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Wrap handleStartDevServer to auto-open the logs panel so the user
|
||||||
|
// can see output immediately (including failure reasons)
|
||||||
|
const handleStartDevServerAndShowLogs = useCallback(
|
||||||
|
async (worktree: WorktreeInfo) => {
|
||||||
|
// Open logs panel immediately so output is visible from the start
|
||||||
|
setLogPanelWorktree(worktree);
|
||||||
|
setLogPanelOpen(true);
|
||||||
|
await handleStartDevServer(worktree);
|
||||||
|
},
|
||||||
|
[handleStartDevServer]
|
||||||
|
);
|
||||||
|
|
||||||
// Handle opening the push to remote dialog
|
// Handle opening the push to remote dialog
|
||||||
const handlePushNewBranch = useCallback((worktree: WorktreeInfo) => {
|
const handlePushNewBranch = useCallback((worktree: WorktreeInfo) => {
|
||||||
setPushToRemoteWorktree(worktree);
|
setPushToRemoteWorktree(worktree);
|
||||||
@@ -914,10 +945,11 @@ export function WorktreePanel({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={handleMerge}
|
onMerge={handleMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServerAndShowLogs}
|
||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
@@ -932,6 +964,9 @@ export function WorktreePanel({
|
|||||||
onAbortOperation={handleAbortOperation}
|
onAbortOperation={handleAbortOperation}
|
||||||
onContinueOperation={handleContinueOperation}
|
onContinueOperation={handleContinueOperation}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
|
terminalScripts={terminalScripts}
|
||||||
|
onRunTerminalScript={handleRunTerminalScript}
|
||||||
|
onEditScripts={handleEditScripts}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1154,10 +1189,11 @@ export function WorktreePanel({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={handleMerge}
|
onMerge={handleMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServerAndShowLogs}
|
||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
@@ -1171,6 +1207,9 @@ export function WorktreePanel({
|
|||||||
onCherryPick={handleCherryPick}
|
onCherryPick={handleCherryPick}
|
||||||
onAbortOperation={handleAbortOperation}
|
onAbortOperation={handleAbortOperation}
|
||||||
onContinueOperation={handleContinueOperation}
|
onContinueOperation={handleContinueOperation}
|
||||||
|
terminalScripts={terminalScripts}
|
||||||
|
onRunTerminalScript={handleRunTerminalScript}
|
||||||
|
onEditScripts={handleEditScripts}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{useWorktreesEnabled && (
|
{useWorktreesEnabled && (
|
||||||
@@ -1257,10 +1296,11 @@ export function WorktreePanel({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={handleMerge}
|
onMerge={handleMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServerAndShowLogs}
|
||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
@@ -1276,6 +1316,9 @@ export function WorktreePanel({
|
|||||||
onContinueOperation={handleContinueOperation}
|
onContinueOperation={handleContinueOperation}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
hasTestCommand={hasTestCommand}
|
hasTestCommand={hasTestCommand}
|
||||||
|
terminalScripts={terminalScripts}
|
||||||
|
onRunTerminalScript={handleRunTerminalScript}
|
||||||
|
onEditScripts={handleEditScripts}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1340,10 +1383,11 @@ export function WorktreePanel({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={handleMerge}
|
onMerge={handleMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServerAndShowLogs}
|
||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
@@ -1359,6 +1403,9 @@ export function WorktreePanel({
|
|||||||
onContinueOperation={handleContinueOperation}
|
onContinueOperation={handleContinueOperation}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
hasTestCommand={hasTestCommand}
|
hasTestCommand={hasTestCommand}
|
||||||
|
terminalScripts={terminalScripts}
|
||||||
|
onRunTerminalScript={handleRunTerminalScript}
|
||||||
|
onEditScripts={handleEditScripts}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { X, Circle, MoreHorizontal } from 'lucide-react';
|
import { X, Circle, MoreHorizontal, Save } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { EditorTab } from '../use-file-editor-store';
|
import type { EditorTab } from '../use-file-editor-store';
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +14,12 @@ interface EditorTabsProps {
|
|||||||
onTabSelect: (tabId: string) => void;
|
onTabSelect: (tabId: string) => void;
|
||||||
onTabClose: (tabId: string) => void;
|
onTabClose: (tabId: string) => void;
|
||||||
onCloseAll: () => void;
|
onCloseAll: () => void;
|
||||||
|
/** Called when the save button is clicked (mobile only) */
|
||||||
|
onSave?: () => void;
|
||||||
|
/** Whether there are unsaved changes (controls enabled state of save button) */
|
||||||
|
isDirty?: boolean;
|
||||||
|
/** Whether to show the save button in the tab bar (intended for mobile) */
|
||||||
|
showSaveButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a file icon color based on extension */
|
/** Get a file icon color based on extension */
|
||||||
@@ -74,6 +80,9 @@ export function EditorTabs({
|
|||||||
onTabSelect,
|
onTabSelect,
|
||||||
onTabClose,
|
onTabClose,
|
||||||
onCloseAll,
|
onCloseAll,
|
||||||
|
onSave,
|
||||||
|
isDirty,
|
||||||
|
showSaveButton,
|
||||||
}: EditorTabsProps) {
|
}: EditorTabsProps) {
|
||||||
if (tabs.length === 0) return null;
|
if (tabs.length === 0) return null;
|
||||||
|
|
||||||
@@ -128,8 +137,26 @@ export function EditorTabs({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Tab actions dropdown (close all, etc.) */}
|
{/* Tab actions: save button (mobile) + close-all dropdown */}
|
||||||
<div className="ml-auto shrink-0 flex items-center px-1">
|
<div className="ml-auto shrink-0 flex items-center px-1 gap-0.5">
|
||||||
|
{/* Save button — shown in the tab bar on mobile */}
|
||||||
|
{showSaveButton && onSave && (
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!isDirty}
|
||||||
|
className={cn(
|
||||||
|
'p-1 rounded transition-colors',
|
||||||
|
isDirty
|
||||||
|
? 'text-primary hover:text-primary hover:bg-muted/50'
|
||||||
|
: 'text-muted-foreground/40 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
title="Save file (Ctrl+S)"
|
||||||
|
aria-label="Save file"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useFileEditorStore, type FileTreeNode } from '../use-file-editor-store';
|
import { useFileEditorStore, type FileTreeNode } from '../use-file-editor-store';
|
||||||
|
import { useFileBrowser } from '@/contexts/file-browser-context';
|
||||||
|
|
||||||
interface FileTreeProps {
|
interface FileTreeProps {
|
||||||
onFileSelect: (path: string) => void;
|
onFileSelect: (path: string) => void;
|
||||||
@@ -104,6 +105,21 @@ function getGitStatusLabel(status: string | undefined): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a file/folder name for safety.
|
||||||
|
* Rejects names containing path separators, relative path components,
|
||||||
|
* or names that are just dots (which resolve to parent/current directory).
|
||||||
|
*/
|
||||||
|
function isValidFileName(name: string): boolean {
|
||||||
|
// Reject names containing path separators
|
||||||
|
if (name.includes('/') || name.includes('\\')) return false;
|
||||||
|
// Reject current/parent directory references
|
||||||
|
if (name === '.' || name === '..') return false;
|
||||||
|
// Reject empty or whitespace-only names
|
||||||
|
if (!name.trim()) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Inline input for creating/renaming items */
|
/** Inline input for creating/renaming items */
|
||||||
function InlineInput({
|
function InlineInput({
|
||||||
defaultValue,
|
defaultValue,
|
||||||
@@ -117,6 +133,7 @@ function InlineInput({
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}) {
|
}) {
|
||||||
const [value, setValue] = useState(defaultValue || '');
|
const [value, setValue] = useState(defaultValue || '');
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
// Guard against double-submission: pressing Enter triggers onKeyDown AND may
|
// Guard against double-submission: pressing Enter triggers onKeyDown AND may
|
||||||
// immediately trigger onBlur (e.g. when the component unmounts after submit).
|
// immediately trigger onBlur (e.g. when the component unmounts after submit).
|
||||||
@@ -125,7 +142,9 @@ function InlineInput({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
if (defaultValue) {
|
if (defaultValue) {
|
||||||
// Select name without extension for rename
|
// Select name without extension for rename.
|
||||||
|
// For dotfiles (e.g. ".gitignore"), lastIndexOf('.') returns 0,
|
||||||
|
// so we fall through to select() which selects the entire name.
|
||||||
const dotIndex = defaultValue.lastIndexOf('.');
|
const dotIndex = defaultValue.lastIndexOf('.');
|
||||||
if (dotIndex > 0) {
|
if (dotIndex > 0) {
|
||||||
inputRef.current?.setSelectionRange(0, dotIndex);
|
inputRef.current?.setSelectionRange(0, dotIndex);
|
||||||
@@ -135,97 +154,62 @@ function InlineInput({
|
|||||||
}
|
}
|
||||||
}, [defaultValue]);
|
}, [defaultValue]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (submittedRef.current) return;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isValidFileName(trimmed)) {
|
||||||
|
// Invalid name — surface error, keep editing so the user can fix it
|
||||||
|
setErrorMessage('Invalid name: avoid /, \\, ".", or ".."');
|
||||||
|
inputRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErrorMessage(null);
|
||||||
|
submittedRef.current = true;
|
||||||
|
onSubmit(trimmed);
|
||||||
|
}, [value, onSubmit, onCancel]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<div className="flex flex-col gap-0.5">
|
||||||
ref={inputRef}
|
<input
|
||||||
value={value}
|
ref={inputRef}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
value={value}
|
||||||
onKeyDown={(e) => {
|
onChange={(e) => {
|
||||||
if (e.key === 'Enter' && value.trim()) {
|
setValue(e.target.value);
|
||||||
|
if (errorMessage) setErrorMessage(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSubmit();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
// Prevent duplicate submission if onKeyDown already triggered onSubmit
|
||||||
if (submittedRef.current) return;
|
if (submittedRef.current) return;
|
||||||
submittedRef.current = true;
|
const trimmed = value.trim();
|
||||||
onSubmit(value.trim());
|
if (trimmed && isValidFileName(trimmed)) {
|
||||||
} else if (e.key === 'Escape') {
|
submittedRef.current = true;
|
||||||
onCancel();
|
onSubmit(trimmed);
|
||||||
}
|
}
|
||||||
}}
|
// If the name is empty or invalid, do NOT call onCancel — keep the
|
||||||
onBlur={() => {
|
// input open so the user can correct the value (mirrors handleSubmit).
|
||||||
// Prevent duplicate submission if onKeyDown already triggered onSubmit
|
// Optionally re-focus so the user can continue editing.
|
||||||
if (submittedRef.current) return;
|
else {
|
||||||
if (value.trim()) {
|
inputRef.current?.focus();
|
||||||
submittedRef.current = true;
|
}
|
||||||
onSubmit(value.trim());
|
}}
|
||||||
} else {
|
placeholder={placeholder}
|
||||||
onCancel();
|
className={cn(
|
||||||
}
|
'text-sm bg-muted border rounded px-1 py-0.5 w-full outline-none focus:border-primary',
|
||||||
}}
|
errorMessage ? 'border-red-500' : 'border-border'
|
||||||
placeholder={placeholder}
|
)}
|
||||||
className="text-sm bg-muted border border-border rounded px-1 py-0.5 w-full outline-none focus:border-primary"
|
/>
|
||||||
/>
|
{errorMessage && <span className="text-[10px] text-red-500 px-0.5">{errorMessage}</span>}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Destination path picker dialog for copy/move operations */
|
|
||||||
function DestinationPicker({
|
|
||||||
onSubmit,
|
|
||||||
onCancel,
|
|
||||||
defaultPath,
|
|
||||||
action,
|
|
||||||
}: {
|
|
||||||
onSubmit: (path: string) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
defaultPath: string;
|
|
||||||
action: 'Copy' | 'Move';
|
|
||||||
}) {
|
|
||||||
const [path, setPath] = useState(defaultPath);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
inputRef.current?.select();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
|
||||||
<div className="bg-background border border-border rounded-lg shadow-lg w-full max-w-md">
|
|
||||||
<div className="px-4 py-3 border-b border-border">
|
|
||||||
<h3 className="text-sm font-medium">{action} To...</h3>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
Enter the destination path for the {action.toLowerCase()} operation
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="px-4 py-3">
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
value={path}
|
|
||||||
onChange={(e) => setPath(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && path.trim()) {
|
|
||||||
onSubmit(path.trim());
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
onCancel();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Enter destination path..."
|
|
||||||
className="w-full text-sm bg-muted border border-border rounded px-3 py-2 outline-none focus:border-primary font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border">
|
|
||||||
<button
|
|
||||||
onClick={onCancel}
|
|
||||||
className="px-3 py-1.5 text-sm rounded hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => path.trim() && onSubmit(path.trim())}
|
|
||||||
disabled={!path.trim()}
|
|
||||||
className="px-3 py-1.5 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{action}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -276,12 +260,11 @@ function TreeNode({
|
|||||||
selectedPaths,
|
selectedPaths,
|
||||||
toggleSelectedPath,
|
toggleSelectedPath,
|
||||||
} = useFileEditorStore();
|
} = useFileEditorStore();
|
||||||
|
const { openFileBrowser } = useFileBrowser();
|
||||||
const [isCreatingFile, setIsCreatingFile] = useState(false);
|
const [isCreatingFile, setIsCreatingFile] = useState(false);
|
||||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
||||||
const [isRenaming, setIsRenaming] = useState(false);
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [showCopyPicker, setShowCopyPicker] = useState(false);
|
|
||||||
const [showMovePicker, setShowMovePicker] = useState(false);
|
|
||||||
|
|
||||||
const isExpanded = expandedFolders.has(node.path);
|
const isExpanded = expandedFolders.has(node.path);
|
||||||
const isActive = activeFilePath === node.path;
|
const isActive = activeFilePath === node.path;
|
||||||
@@ -409,30 +392,6 @@ function TreeNode({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={node.path}>
|
<div key={node.path}>
|
||||||
{/* Destination picker dialogs */}
|
|
||||||
{showCopyPicker && onCopyItem && (
|
|
||||||
<DestinationPicker
|
|
||||||
action="Copy"
|
|
||||||
defaultPath={node.path}
|
|
||||||
onSubmit={async (destPath) => {
|
|
||||||
setShowCopyPicker(false);
|
|
||||||
await onCopyItem(node.path, destPath);
|
|
||||||
}}
|
|
||||||
onCancel={() => setShowCopyPicker(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showMovePicker && onMoveItem && (
|
|
||||||
<DestinationPicker
|
|
||||||
action="Move"
|
|
||||||
defaultPath={node.path}
|
|
||||||
onSubmit={async (destPath) => {
|
|
||||||
setShowMovePicker(false);
|
|
||||||
await onMoveItem(node.path, destPath);
|
|
||||||
}}
|
|
||||||
onCancel={() => setShowMovePicker(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isRenaming ? (
|
{isRenaming ? (
|
||||||
<div style={{ paddingLeft: `${depth * 16 + 8}px` }} className="py-0.5 px-2">
|
<div style={{ paddingLeft: `${depth * 16 + 8}px` }} className="py-0.5 px-2">
|
||||||
<InlineInput
|
<InlineInput
|
||||||
@@ -630,9 +589,21 @@ function TreeNode({
|
|||||||
{/* Copy To... */}
|
{/* Copy To... */}
|
||||||
{onCopyItem && (
|
{onCopyItem && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setShowCopyPicker(true);
|
try {
|
||||||
|
const parentPath = node.path.substring(0, node.path.lastIndexOf('/')) || '/';
|
||||||
|
const destPath = await openFileBrowser({
|
||||||
|
title: `Copy "${node.name}" To...`,
|
||||||
|
description: 'Select the destination folder for the copy operation',
|
||||||
|
initialPath: parentPath,
|
||||||
|
});
|
||||||
|
if (destPath) {
|
||||||
|
await onCopyItem(node.path, destPath);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Copy operation failed:', err);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
@@ -644,9 +615,21 @@ function TreeNode({
|
|||||||
{/* Move To... */}
|
{/* Move To... */}
|
||||||
{onMoveItem && (
|
{onMoveItem && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setShowMovePicker(true);
|
try {
|
||||||
|
const parentPath = node.path.substring(0, node.path.lastIndexOf('/')) || '/';
|
||||||
|
const destPath = await openFileBrowser({
|
||||||
|
title: `Move "${node.name}" To...`,
|
||||||
|
description: 'Select the destination folder for the move operation',
|
||||||
|
initialPath: parentPath,
|
||||||
|
});
|
||||||
|
if (destPath) {
|
||||||
|
await onMoveItem(node.path, destPath);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Move operation failed:', err);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
@@ -775,8 +758,15 @@ export function FileTree({
|
|||||||
onDragDropMove,
|
onDragDropMove,
|
||||||
effectivePath,
|
effectivePath,
|
||||||
}: FileTreeProps) {
|
}: FileTreeProps) {
|
||||||
const { fileTree, showHiddenFiles, setShowHiddenFiles, gitStatusMap, setDragState, gitBranch } =
|
const {
|
||||||
useFileEditorStore();
|
fileTree,
|
||||||
|
showHiddenFiles,
|
||||||
|
setShowHiddenFiles,
|
||||||
|
gitStatusMap,
|
||||||
|
dragState,
|
||||||
|
setDragState,
|
||||||
|
gitBranch,
|
||||||
|
} = useFileEditorStore();
|
||||||
const [isCreatingFile, setIsCreatingFile] = useState(false);
|
const [isCreatingFile, setIsCreatingFile] = useState(false);
|
||||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
||||||
|
|
||||||
@@ -791,10 +781,13 @@ export function FileTree({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (effectivePath) {
|
if (effectivePath) {
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = 'move';
|
||||||
setDragState({ draggedPaths: [], dropTargetPath: effectivePath });
|
// Skip redundant state update if already targeting the same path
|
||||||
|
if (dragState.dropTargetPath !== effectivePath) {
|
||||||
|
setDragState({ ...dragState, dropTargetPath: effectivePath });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[effectivePath, setDragState]
|
[effectivePath, dragState, setDragState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRootDrop = useCallback(
|
const handleRootDrop = useCallback(
|
||||||
@@ -818,47 +811,54 @@ export function FileTree({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full" data-testid="file-tree">
|
<div className="flex flex-col h-full" data-testid="file-tree">
|
||||||
{/* Tree toolbar */}
|
{/* Tree toolbar */}
|
||||||
<div className="flex items-center justify-between px-2 py-1.5 border-b border-border">
|
<div className="px-2 py-1.5 border-b border-border">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
Explorer
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
</span>
|
Explorer
|
||||||
{gitBranch && (
|
</span>
|
||||||
<span className="text-[10px] text-primary font-medium px-1 py-0.5 bg-primary/10 rounded">
|
</div>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreatingFile(true)}
|
||||||
|
className="p-1 hover:bg-accent rounded"
|
||||||
|
title="New file"
|
||||||
|
>
|
||||||
|
<FilePlus className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreatingFolder(true)}
|
||||||
|
className="p-1 hover:bg-accent rounded"
|
||||||
|
title="New folder"
|
||||||
|
>
|
||||||
|
<FolderPlus className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHiddenFiles(!showHiddenFiles)}
|
||||||
|
className="p-1 hover:bg-accent rounded"
|
||||||
|
title={showHiddenFiles ? 'Hide dotfiles' : 'Show dotfiles'}
|
||||||
|
>
|
||||||
|
{showHiddenFiles ? (
|
||||||
|
<Eye className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button onClick={onRefresh} className="p-1 hover:bg-accent rounded" title="Refresh">
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{gitBranch && (
|
||||||
|
<div className="mt-1 min-w-0">
|
||||||
|
<span
|
||||||
|
className="inline-block max-w-full truncate whitespace-nowrap text-[10px] text-primary font-medium px-1 py-0.5 bg-primary/10 rounded"
|
||||||
|
title={gitBranch}
|
||||||
|
>
|
||||||
{gitBranch}
|
{gitBranch}
|
||||||
</span>
|
</span>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsCreatingFile(true)}
|
|
||||||
className="p-1 hover:bg-accent rounded"
|
|
||||||
title="New file"
|
|
||||||
>
|
|
||||||
<FilePlus className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsCreatingFolder(true)}
|
|
||||||
className="p-1 hover:bg-accent rounded"
|
|
||||||
title="New folder"
|
|
||||||
>
|
|
||||||
<FolderPlus className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowHiddenFiles(!showHiddenFiles)}
|
|
||||||
className="p-1 hover:bg-accent rounded"
|
|
||||||
title={showHiddenFiles ? 'Hide dotfiles' : 'Show dotfiles'}
|
|
||||||
>
|
|
||||||
{showHiddenFiles ? (
|
|
||||||
<Eye className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<EyeOff className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button onClick={onRefresh} className="p-1 hover:bg-accent rounded" title="Refresh">
|
|
||||||
<RefreshCw className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tree content */}
|
{/* Tree content */}
|
||||||
|
|||||||
@@ -650,6 +650,12 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
|||||||
|
|
||||||
const handleRenameItem = useCallback(
|
const handleRenameItem = useCallback(
|
||||||
async (oldPath: string, newName: string) => {
|
async (oldPath: string, newName: string) => {
|
||||||
|
// Extract the current file/folder name from the old path
|
||||||
|
const oldName = oldPath.split('/').pop() || '';
|
||||||
|
|
||||||
|
// If the name hasn't changed, skip the rename entirely (no-op)
|
||||||
|
if (newName === oldName) return;
|
||||||
|
|
||||||
const parentPath = oldPath.substring(0, oldPath.lastIndexOf('/'));
|
const parentPath = oldPath.substring(0, oldPath.lastIndexOf('/'));
|
||||||
const newPath = `${parentPath}/${newName}`;
|
const newPath = `${parentPath}/${newName}`;
|
||||||
|
|
||||||
@@ -1028,6 +1034,9 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
|||||||
onTabSelect={setActiveTab}
|
onTabSelect={setActiveTab}
|
||||||
onTabClose={handleTabClose}
|
onTabClose={handleTabClose}
|
||||||
onCloseAll={handleCloseAll}
|
onCloseAll={handleCloseAll}
|
||||||
|
onSave={handleSave}
|
||||||
|
isDirty={activeTab?.isDirty && !activeTab?.isBinary && !activeTab?.isTooLarge}
|
||||||
|
showSaveButton={isMobile && !!activeTab && !activeTab.isBinary && !activeTab.isTooLarge}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Editor content */}
|
{/* Editor content */}
|
||||||
@@ -1320,24 +1329,6 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
{/* Mobile: Save button in main toolbar */}
|
|
||||||
{activeTab &&
|
|
||||||
!activeTab.isBinary &&
|
|
||||||
!activeTab.isTooLarge &&
|
|
||||||
isMobile &&
|
|
||||||
!mobileBrowserVisible && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!activeTab.isDirty}
|
|
||||||
className="lg:hidden"
|
|
||||||
title={editorAutoSave ? 'Auto-save enabled (Ctrl+S)' : 'Save file (Ctrl+S)'}
|
|
||||||
>
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tablet/Mobile: actions panel trigger */}
|
{/* Tablet/Mobile: actions panel trigger */}
|
||||||
<HeaderActionsPanelTrigger
|
<HeaderActionsPanelTrigger
|
||||||
isOpen={showActionsPanel}
|
isOpen={showActionsPanel}
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
|||||||
import { LoadingState } from '@/components/ui/loading-state';
|
import { LoadingState } from '@/components/ui/loading-state';
|
||||||
import { ErrorState } from '@/components/ui/error-state';
|
import { ErrorState } from '@/components/ui/error-state';
|
||||||
import { cn, pathsEqual, generateUUID } from '@/lib/utils';
|
import { cn, pathsEqual, generateUUID } from '@/lib/utils';
|
||||||
|
import { useIsMobile } from '@/hooks/use-media-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { queryKeys } from '@/lib/query-keys';
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
|
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
|
||||||
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
|
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
|
||||||
import { ValidationDialog } from './github-issues-view/dialogs';
|
import { ValidationDialog } from './github-issues-view/dialogs';
|
||||||
|
import { AddFeatureDialog } from './board-view/dialogs';
|
||||||
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
|
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
|
||||||
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
import { useModelOverride } from '@/components/shared';
|
import { useModelOverride } from '@/components/shared';
|
||||||
import type {
|
import type {
|
||||||
ValidateIssueOptions,
|
ValidateIssueOptions,
|
||||||
@@ -34,15 +37,22 @@ export function GitHubIssuesView() {
|
|||||||
const [pendingRevalidateOptions, setPendingRevalidateOptions] =
|
const [pendingRevalidateOptions, setPendingRevalidateOptions] =
|
||||||
useState<ValidateIssueOptions | null>(null);
|
useState<ValidateIssueOptions | null>(null);
|
||||||
|
|
||||||
|
// Add Feature dialog state
|
||||||
|
const [showAddFeatureDialog, setShowAddFeatureDialog] = useState(false);
|
||||||
|
const [createFeatureIssue, setCreateFeatureIssue] = useState<GitHubIssue | null>(null);
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
|
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
|
||||||
|
|
||||||
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
|
const { currentProject, getCurrentWorktree, worktreesByProject, defaultSkipTests } =
|
||||||
|
useAppStore();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Model override for validation
|
// Model override for validation
|
||||||
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
|
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues();
|
const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues();
|
||||||
|
|
||||||
const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } =
|
const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } =
|
||||||
@@ -108,6 +118,132 @@ export function GitHubIssuesView() {
|
|||||||
api.openExternalLink(url);
|
api.openExternalLink(url);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Build a prefilled description from a GitHub issue for the feature dialog
|
||||||
|
const buildIssueDescription = useCallback(
|
||||||
|
(issue: GitHubIssue) => {
|
||||||
|
const parts = [
|
||||||
|
`**From GitHub Issue #${issue.number}**`,
|
||||||
|
'',
|
||||||
|
issue.body || 'No description provided.',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Include labels if present
|
||||||
|
if (issue.labels.length > 0) {
|
||||||
|
parts.push('', `**Labels:** ${issue.labels.map((l) => l.name).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include linked PRs info if present
|
||||||
|
if (issue.linkedPRs && issue.linkedPRs.length > 0) {
|
||||||
|
parts.push(
|
||||||
|
'',
|
||||||
|
'**Linked Pull Requests:**',
|
||||||
|
...issue.linkedPRs.map((pr) => `- #${pr.number}: ${pr.title} (${pr.state})`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include cached validation analysis if available
|
||||||
|
const cached = cachedValidations.get(issue.number);
|
||||||
|
if (cached?.result) {
|
||||||
|
const validation = cached.result;
|
||||||
|
parts.push('', '---', '', '**AI Validation Analysis:**', validation.reasoning);
|
||||||
|
if (validation.suggestedFix) {
|
||||||
|
parts.push('', `**Suggested Approach:**`, validation.suggestedFix);
|
||||||
|
}
|
||||||
|
if (validation.relatedFiles?.length) {
|
||||||
|
parts.push('', '**Related Files:**', ...validation.relatedFiles.map((f) => `- \`${f}\``));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('\n');
|
||||||
|
},
|
||||||
|
[cachedValidations]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize the prefilled description to avoid recomputing on every render
|
||||||
|
const prefilledDescription = useMemo(
|
||||||
|
() => (createFeatureIssue ? buildIssueDescription(createFeatureIssue) : undefined),
|
||||||
|
[createFeatureIssue, buildIssueDescription]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open the Add Feature dialog with pre-filled data from a GitHub issue
|
||||||
|
const handleCreateFeature = useCallback((issue: GitHubIssue) => {
|
||||||
|
setCreateFeatureIssue(issue);
|
||||||
|
setShowAddFeatureDialog(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle feature creation from the AddFeatureDialog
|
||||||
|
const handleAddFeatureFromIssue = useCallback(
|
||||||
|
async (featureData: {
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
priority: number;
|
||||||
|
model: string;
|
||||||
|
thinkingLevel: string;
|
||||||
|
reasoningEffort: string;
|
||||||
|
skipTests: boolean;
|
||||||
|
branchName: string;
|
||||||
|
planningMode: string;
|
||||||
|
requirePlanApproval: boolean;
|
||||||
|
excludedPipelineSteps?: string[];
|
||||||
|
workMode: string;
|
||||||
|
imagePaths?: Array<{ id: string; path: string; description?: string }>;
|
||||||
|
textFilePaths?: Array<{ id: string; path: string; description?: string }>;
|
||||||
|
}) => {
|
||||||
|
if (!currentProject?.path) {
|
||||||
|
toast.error('No project selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (api.features?.create) {
|
||||||
|
const feature = {
|
||||||
|
id: `issue-${createFeatureIssue?.number || 'new'}-${generateUUID()}`,
|
||||||
|
title: featureData.title,
|
||||||
|
description: featureData.description,
|
||||||
|
category: featureData.category,
|
||||||
|
status: 'backlog' as const,
|
||||||
|
passes: false,
|
||||||
|
priority: featureData.priority,
|
||||||
|
model: featureData.model,
|
||||||
|
thinkingLevel: featureData.thinkingLevel,
|
||||||
|
reasoningEffort: featureData.reasoningEffort,
|
||||||
|
skipTests: featureData.skipTests,
|
||||||
|
branchName: featureData.workMode === 'current' ? currentBranch : featureData.branchName,
|
||||||
|
planningMode: featureData.planningMode,
|
||||||
|
requirePlanApproval: featureData.requirePlanApproval,
|
||||||
|
excludedPipelineSteps: featureData.excludedPipelineSteps,
|
||||||
|
...(featureData.imagePaths?.length ? { imagePaths: featureData.imagePaths } : {}),
|
||||||
|
...(featureData.textFilePaths?.length
|
||||||
|
? { textFilePaths: featureData.textFilePaths }
|
||||||
|
: {}),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await api.features.create(currentProject.path, feature);
|
||||||
|
if (result.success) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
`Created feature: ${featureData.title || featureData.description.slice(0, 50)}`
|
||||||
|
);
|
||||||
|
setShowAddFeatureDialog(false);
|
||||||
|
setCreateFeatureIssue(null);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || 'Failed to create feature');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Create feature from issue error:', err);
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to create feature');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject?.path, currentBranch, queryClient, createFeatureIssue]
|
||||||
|
);
|
||||||
|
|
||||||
const handleConvertToTask = useCallback(
|
const handleConvertToTask = useCallback(
|
||||||
async (issue: GitHubIssue, validation: IssueValidationResult) => {
|
async (issue: GitHubIssue, validation: IssueValidationResult) => {
|
||||||
if (!currentProject?.path) {
|
if (!currentProject?.path) {
|
||||||
@@ -119,7 +255,7 @@ export function GitHubIssuesView() {
|
|||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (api.features?.create) {
|
if (api.features?.create) {
|
||||||
// Build description from issue body + validation info
|
// Build description from issue body + validation info
|
||||||
const description = [
|
const parts = [
|
||||||
`**From GitHub Issue #${issue.number}**`,
|
`**From GitHub Issue #${issue.number}**`,
|
||||||
'',
|
'',
|
||||||
issue.body || 'No description provided.',
|
issue.body || 'No description provided.',
|
||||||
@@ -128,13 +264,18 @@ export function GitHubIssuesView() {
|
|||||||
'',
|
'',
|
||||||
'**AI Validation Analysis:**',
|
'**AI Validation Analysis:**',
|
||||||
validation.reasoning,
|
validation.reasoning,
|
||||||
validation.suggestedFix ? `\n**Suggested Approach:**\n${validation.suggestedFix}` : '',
|
];
|
||||||
validation.relatedFiles?.length
|
if (validation.suggestedFix) {
|
||||||
? `\n**Related Files:**\n${validation.relatedFiles.map((f) => `- \`${f}\``).join('\n')}`
|
parts.push('', `**Suggested Approach:**`, validation.suggestedFix);
|
||||||
: '',
|
}
|
||||||
]
|
if (validation.relatedFiles?.length) {
|
||||||
.filter(Boolean)
|
parts.push(
|
||||||
.join('\n');
|
'',
|
||||||
|
'**Related Files:**',
|
||||||
|
...validation.relatedFiles.map((f) => `- \`${f}\``)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const description = parts.join('\n');
|
||||||
|
|
||||||
const feature = {
|
const feature = {
|
||||||
id: `issue-${issue.number}-${generateUUID()}`,
|
id: `issue-${issue.number}-${generateUUID()}`,
|
||||||
@@ -144,7 +285,7 @@ export function GitHubIssuesView() {
|
|||||||
status: 'backlog' as const,
|
status: 'backlog' as const,
|
||||||
passes: false,
|
passes: false,
|
||||||
priority: getFeaturePriority(validation.estimatedComplexity),
|
priority: getFeaturePriority(validation.estimatedComplexity),
|
||||||
model: 'opus',
|
model: resolveModelString('opus'),
|
||||||
thinkingLevel: 'none' as const,
|
thinkingLevel: 'none' as const,
|
||||||
branchName: currentBranch,
|
branchName: currentBranch,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
@@ -185,11 +326,12 @@ export function GitHubIssuesView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* Issues List */}
|
{/* Issues List - hidden on mobile when an issue is selected */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col overflow-hidden border-r border-border',
|
'flex flex-col overflow-hidden border-r border-border',
|
||||||
selectedIssue ? 'w-80' : 'flex-1'
|
selectedIssue ? 'w-80' : 'flex-1',
|
||||||
|
isMobile && selectedIssue && 'hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -296,8 +438,10 @@ export function GitHubIssuesView() {
|
|||||||
setPendingRevalidateOptions(options);
|
setPendingRevalidateOptions(options);
|
||||||
setShowRevalidateConfirm(true);
|
setShowRevalidateConfirm(true);
|
||||||
}}
|
}}
|
||||||
|
onCreateFeature={handleCreateFeature}
|
||||||
formatDate={formatDate}
|
formatDate={formatDate}
|
||||||
modelOverride={validationModelOverride}
|
modelOverride={validationModelOverride}
|
||||||
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -310,6 +454,28 @@ export function GitHubIssuesView() {
|
|||||||
onConvertToTask={handleConvertToTask}
|
onConvertToTask={handleConvertToTask}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Add Feature Dialog - opened from issue detail panel */}
|
||||||
|
<AddFeatureDialog
|
||||||
|
open={showAddFeatureDialog}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setShowAddFeatureDialog(open);
|
||||||
|
if (!open) {
|
||||||
|
setCreateFeatureIssue(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onAdd={handleAddFeatureFromIssue}
|
||||||
|
categorySuggestions={['From GitHub']}
|
||||||
|
branchSuggestions={[]}
|
||||||
|
defaultSkipTests={defaultSkipTests}
|
||||||
|
defaultBranch={currentBranch}
|
||||||
|
currentBranch={currentBranch || undefined}
|
||||||
|
isMaximized={false}
|
||||||
|
projectPath={currentProject?.path}
|
||||||
|
prefilledTitle={createFeatureIssue?.title}
|
||||||
|
prefilledDescription={prefilledDescription}
|
||||||
|
prefilledCategory="From GitHub"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Revalidate Confirmation Dialog */}
|
{/* Revalidate Confirmation Dialog */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={showRevalidateConfirm}
|
open={showRevalidateConfirm}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
|
Plus,
|
||||||
|
ArrowLeft,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -34,8 +36,10 @@ export function IssueDetailPanel({
|
|||||||
onOpenInGitHub,
|
onOpenInGitHub,
|
||||||
onClose,
|
onClose,
|
||||||
onShowRevalidateConfirm,
|
onShowRevalidateConfirm,
|
||||||
|
onCreateFeature,
|
||||||
formatDate,
|
formatDate,
|
||||||
modelOverride,
|
modelOverride,
|
||||||
|
isMobile = false,
|
||||||
}: IssueDetailPanelProps) {
|
}: IssueDetailPanelProps) {
|
||||||
const isValidating = validatingIssues.has(issue.number);
|
const isValidating = validatingIssues.has(issue.number);
|
||||||
const cached = cachedValidations.get(issue.number);
|
const cached = cachedValidations.get(issue.number);
|
||||||
@@ -71,8 +75,20 @@ export function IssueDetailPanel({
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Detail Header */}
|
{/* Detail Header */}
|
||||||
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
|
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30 gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{isMobile && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClose}
|
||||||
|
className="shrink-0 -ml-1"
|
||||||
|
aria-label="Back"
|
||||||
|
title="Back"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{issue.state === 'OPEN' ? (
|
{issue.state === 'OPEN' ? (
|
||||||
<Circle className="h-4 w-4 text-green-500 shrink-0" />
|
<Circle className="h-4 w-4 text-green-500 shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
@@ -82,12 +98,12 @@ export function IssueDetailPanel({
|
|||||||
#{issue.number} {issue.title}
|
#{issue.number} {issue.title}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className={cn('flex items-center gap-2 shrink-0', isMobile && 'gap-1')}>
|
||||||
{(() => {
|
{(() => {
|
||||||
if (isValidating) {
|
if (isValidating) {
|
||||||
return (
|
return (
|
||||||
<Button variant="default" size="sm" loading>
|
<Button variant="default" size="sm" loading>
|
||||||
Validating...
|
{isMobile ? '...' : 'Validating...'}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -95,9 +111,15 @@ export function IssueDetailPanel({
|
|||||||
if (cached && !isStale) {
|
if (cached && !isStale) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onViewCachedValidation(issue)}
|
||||||
|
aria-label="View Result"
|
||||||
|
title="View Result"
|
||||||
|
>
|
||||||
<CheckCircle className="h-4 w-4 mr-1 text-green-500" />
|
<CheckCircle className="h-4 w-4 mr-1 text-green-500" />
|
||||||
View Result
|
{!isMobile && 'View Result'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -114,9 +136,15 @@ export function IssueDetailPanel({
|
|||||||
if (cached && isStale) {
|
if (cached && isStale) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onViewCachedValidation(issue)}
|
||||||
|
aria-label="View (stale)"
|
||||||
|
title="View (stale)"
|
||||||
|
>
|
||||||
<Clock className="h-4 w-4 mr-1 text-yellow-500" />
|
<Clock className="h-4 w-4 mr-1 text-yellow-500" />
|
||||||
View (stale)
|
{!isMobile && 'View (stale)'}
|
||||||
</Button>
|
</Button>
|
||||||
<ModelOverrideTrigger
|
<ModelOverrideTrigger
|
||||||
currentModelEntry={modelOverride.effectiveModelEntry}
|
currentModelEntry={modelOverride.effectiveModelEntry}
|
||||||
@@ -131,9 +159,11 @@ export function IssueDetailPanel({
|
|||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onValidateIssue(issue, getValidationOptions(true))}
|
onClick={() => onValidateIssue(issue, getValidationOptions(true))}
|
||||||
|
aria-label="Re-validate"
|
||||||
|
title="Re-validate"
|
||||||
>
|
>
|
||||||
<Wand2 className="h-4 w-4 mr-1" />
|
<Wand2 className="h-4 w-4 mr-1" />
|
||||||
Re-validate
|
{!isMobile && 'Re-validate'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -154,25 +184,46 @@ export function IssueDetailPanel({
|
|||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onValidateIssue(issue, getValidationOptions())}
|
onClick={() => onValidateIssue(issue, getValidationOptions())}
|
||||||
|
aria-label="Validate with AI"
|
||||||
|
title="Validate with AI"
|
||||||
>
|
>
|
||||||
<Wand2 className="h-4 w-4 mr-1" />
|
<Wand2 className="h-4 w-4 mr-1" />
|
||||||
Validate with AI
|
{!isMobile && 'Validate with AI'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
<Button variant="outline" size="sm" onClick={() => onOpenInGitHub(issue.url)}>
|
{!isMobile && (
|
||||||
<ExternalLink className="h-4 w-4 mr-1" />
|
<Button
|
||||||
Open in GitHub
|
variant="secondary"
|
||||||
</Button>
|
size="sm"
|
||||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
onClick={() => onCreateFeature(issue)}
|
||||||
<X className="h-4 w-4" />
|
title="Create a new feature to address this issue"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Create Feature
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onOpenInGitHub(issue.url)}
|
||||||
|
aria-label="Open in GitHub"
|
||||||
|
title="Open in GitHub"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
{!isMobile && <span className="ml-1">Open in GitHub</span>}
|
||||||
</Button>
|
</Button>
|
||||||
|
{!isMobile && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Issue Detail Content */}
|
{/* Issue Detail Content */}
|
||||||
<div className="flex-1 overflow-auto p-6">
|
<div className={cn('flex-1 overflow-auto', isMobile ? 'p-4' : 'p-6')}>
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h1 className="text-xl font-bold mb-2">{issue.title}</h1>
|
<h1 className="text-xl font-bold mb-2">{issue.title}</h1>
|
||||||
|
|
||||||
@@ -344,8 +395,25 @@ export function IssueDetailPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Create Feature CTA - shown on mobile since it's hidden from the header */}
|
||||||
|
{isMobile && (
|
||||||
|
<div className="mt-6 p-4 rounded-lg bg-primary/5 border border-primary/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Plus className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-sm font-medium">Create Feature</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
Create a new feature task to address this issue.
|
||||||
|
</p>
|
||||||
|
<Button variant="secondary" onClick={() => onCreateFeature(issue)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Create Feature
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Open in GitHub CTA */}
|
{/* Open in GitHub CTA */}
|
||||||
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
|
<div className="mt-4 p-4 rounded-lg bg-muted/50 border border-border">
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
View comments, add reactions, and more on GitHub.
|
View comments, add reactions, and more on GitHub.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ export interface IssueDetailPanelProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Called when user wants to revalidate - receives the validation options including comments/linkedPRs */
|
/** Called when user wants to revalidate - receives the validation options including comments/linkedPRs */
|
||||||
onShowRevalidateConfirm: (options: ValidateIssueOptions) => void;
|
onShowRevalidateConfirm: (options: ValidateIssueOptions) => void;
|
||||||
|
/** Called when user wants to create a feature to address this issue */
|
||||||
|
onCreateFeature: (issue: GitHubIssue) => void;
|
||||||
formatDate: (date: string) => string;
|
formatDate: (date: string) => string;
|
||||||
/** Model override state */
|
/** Model override state */
|
||||||
modelOverride: {
|
modelOverride: {
|
||||||
@@ -146,4 +148,6 @@ export interface IssueDetailPanelProps {
|
|||||||
isOverridden: boolean;
|
isOverridden: boolean;
|
||||||
setOverride: (entry: PhaseModelEntry | null) => void;
|
setOverride: (entry: PhaseModelEntry | null) => void;
|
||||||
};
|
};
|
||||||
|
/** Whether the view is in mobile mode - shows back button and full-screen detail */
|
||||||
|
isMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,42 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { GitPullRequest, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react';
|
import {
|
||||||
|
GitPullRequest,
|
||||||
|
RefreshCw,
|
||||||
|
ExternalLink,
|
||||||
|
GitMerge,
|
||||||
|
X,
|
||||||
|
MessageSquare,
|
||||||
|
MoreHorizontal,
|
||||||
|
Zap,
|
||||||
|
ArrowLeft,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { getElectronAPI, type GitHubPR } from '@/lib/electron';
|
import { getElectronAPI, type GitHubPR } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore, type Feature } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Markdown } from '@/components/ui/markdown';
|
import { Markdown } from '@/components/ui/markdown';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn, generateUUID } from '@/lib/utils';
|
||||||
|
import { useIsMobile } from '@/hooks/use-media-query';
|
||||||
import { useGitHubPRs } from '@/hooks/queries';
|
import { useGitHubPRs } from '@/hooks/queries';
|
||||||
|
import { useCreateFeature } from '@/hooks/mutations/use-feature-mutations';
|
||||||
|
import { PRCommentResolutionDialog } from '@/components/dialogs';
|
||||||
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
export function GitHubPRsView() {
|
export function GitHubPRsView() {
|
||||||
const [selectedPR, setSelectedPR] = useState<GitHubPR | null>(null);
|
const [selectedPR, setSelectedPR] = useState<GitHubPR | null>(null);
|
||||||
|
const [commentDialogPR, setCommentDialogPR] = useState<GitHubPR | null>(null);
|
||||||
const { currentProject } = useAppStore();
|
const { currentProject } = useAppStore();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -38,6 +62,65 @@ export function GitHubPRsView() {
|
|||||||
api.openExternalLink(url);
|
api.openExternalLink(url);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const createFeature = useCreateFeature(currentProject?.path ?? '');
|
||||||
|
|
||||||
|
const handleAutoAddressComments = useCallback(
|
||||||
|
async (pr: GitHubPR) => {
|
||||||
|
if (!pr.number || !currentProject?.path) {
|
||||||
|
toast.error('Cannot address PR comments', {
|
||||||
|
description: 'No PR number or project available.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const featureId = `pr-${pr.number}-${generateUUID()}`;
|
||||||
|
const feature: Feature = {
|
||||||
|
id: featureId,
|
||||||
|
title: `Address PR #${pr.number} Review Comments`,
|
||||||
|
category: 'bug-fix',
|
||||||
|
description: `Read the review requests on PR #${pr.number} and address any feedback the best you can.`,
|
||||||
|
steps: [],
|
||||||
|
status: 'backlog',
|
||||||
|
model: resolveModelString('opus'),
|
||||||
|
thinkingLevel: 'none',
|
||||||
|
planningMode: 'skip',
|
||||||
|
...(pr.headRefName ? { branchName: pr.headRefName } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createFeature.mutateAsync(feature);
|
||||||
|
|
||||||
|
// Start the feature immediately after creation
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (api.autoMode?.runFeature) {
|
||||||
|
try {
|
||||||
|
await api.autoMode.runFeature(currentProject.path, featureId);
|
||||||
|
toast.success('Feature created and started', {
|
||||||
|
description: `Addressing review comments on PR #${pr.number}`,
|
||||||
|
});
|
||||||
|
} catch (runError) {
|
||||||
|
toast.error('Feature created but failed to start', {
|
||||||
|
description:
|
||||||
|
runError instanceof Error
|
||||||
|
? runError.message
|
||||||
|
: 'An error occurred while starting the feature',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error('Cannot start feature', {
|
||||||
|
description:
|
||||||
|
'Feature API is not available. The feature was created but could not be started.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to create feature', {
|
||||||
|
description: error instanceof Error ? error.message : 'An error occurred',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject, createFeature]
|
||||||
|
);
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString('en-US', {
|
return date.toLocaleDateString('en-US', {
|
||||||
@@ -91,11 +174,12 @@ export function GitHubPRsView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* PR List */}
|
{/* PR List - hidden on mobile when a PR is selected */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col overflow-hidden border-r border-border',
|
'flex flex-col overflow-hidden border-r border-border',
|
||||||
selectedPR ? 'w-80' : 'flex-1'
|
selectedPR ? 'w-80' : 'flex-1',
|
||||||
|
isMobile && selectedPR && 'hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -140,6 +224,8 @@ export function GitHubPRsView() {
|
|||||||
isSelected={selectedPR?.number === pr.number}
|
isSelected={selectedPR?.number === pr.number}
|
||||||
onClick={() => setSelectedPR(pr)}
|
onClick={() => setSelectedPR(pr)}
|
||||||
onOpenExternal={() => handleOpenInGitHub(pr.url)}
|
onOpenExternal={() => handleOpenInGitHub(pr.url)}
|
||||||
|
onManageComments={() => setCommentDialogPR(pr)}
|
||||||
|
onAutoAddressComments={() => handleAutoAddressComments(pr)}
|
||||||
formatDate={formatDate}
|
formatDate={formatDate}
|
||||||
getReviewStatus={getReviewStatus}
|
getReviewStatus={getReviewStatus}
|
||||||
/>
|
/>
|
||||||
@@ -158,6 +244,8 @@ export function GitHubPRsView() {
|
|||||||
isSelected={selectedPR?.number === pr.number}
|
isSelected={selectedPR?.number === pr.number}
|
||||||
onClick={() => setSelectedPR(pr)}
|
onClick={() => setSelectedPR(pr)}
|
||||||
onOpenExternal={() => handleOpenInGitHub(pr.url)}
|
onOpenExternal={() => handleOpenInGitHub(pr.url)}
|
||||||
|
onManageComments={() => setCommentDialogPR(pr)}
|
||||||
|
onAutoAddressComments={() => handleAutoAddressComments(pr)}
|
||||||
formatDate={formatDate}
|
formatDate={formatDate}
|
||||||
getReviewStatus={getReviewStatus}
|
getReviewStatus={getReviewStatus}
|
||||||
/>
|
/>
|
||||||
@@ -170,124 +258,187 @@ export function GitHubPRsView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PR Detail Panel */}
|
{/* PR Detail Panel */}
|
||||||
{selectedPR && (
|
{selectedPR &&
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
(() => {
|
||||||
{/* Detail Header */}
|
const reviewStatus = getReviewStatus(selectedPR);
|
||||||
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
|
return (
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{selectedPR.state === 'MERGED' ? (
|
{/* Detail Header */}
|
||||||
<GitMerge className="h-4 w-4 text-purple-500 shrink-0" />
|
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30 gap-2">
|
||||||
) : (
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<GitPullRequest className="h-4 w-4 text-green-500 shrink-0" />
|
{isMobile && (
|
||||||
)}
|
<Button
|
||||||
<span className="text-sm font-medium truncate">
|
variant="ghost"
|
||||||
#{selectedPR.number} {selectedPR.title}
|
size="sm"
|
||||||
</span>
|
onClick={() => setSelectedPR(null)}
|
||||||
{selectedPR.isDraft && (
|
className="shrink-0 -ml-1"
|
||||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground">
|
>
|
||||||
Draft
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</span>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleOpenInGitHub(selectedPR.url)}
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-4 w-4 mr-1" />
|
|
||||||
Open in GitHub
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setSelectedPR(null)}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PR Detail Content */}
|
|
||||||
<div className="flex-1 overflow-auto p-6">
|
|
||||||
{/* Title */}
|
|
||||||
<h1 className="text-xl font-bold mb-2">{selectedPR.title}</h1>
|
|
||||||
|
|
||||||
{/* Meta info */}
|
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4 flex-wrap">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'px-2 py-0.5 rounded-full text-xs font-medium',
|
|
||||||
selectedPR.state === 'MERGED'
|
|
||||||
? 'bg-purple-500/10 text-purple-500'
|
|
||||||
: selectedPR.isDraft
|
|
||||||
? 'bg-muted text-muted-foreground'
|
|
||||||
: 'bg-green-500/10 text-green-500'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{selectedPR.state === 'MERGED' ? 'Merged' : selectedPR.isDraft ? 'Draft' : 'Open'}
|
|
||||||
</span>
|
|
||||||
{getReviewStatus(selectedPR) && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'px-2 py-0.5 rounded-full text-xs font-medium',
|
|
||||||
getReviewStatus(selectedPR)!.bg,
|
|
||||||
getReviewStatus(selectedPR)!.color
|
|
||||||
)}
|
)}
|
||||||
>
|
{selectedPR.state === 'MERGED' ? (
|
||||||
{getReviewStatus(selectedPR)!.label}
|
<GitMerge className="h-4 w-4 text-purple-500 shrink-0" />
|
||||||
</span>
|
) : (
|
||||||
)}
|
<GitPullRequest className="h-4 w-4 text-green-500 shrink-0" />
|
||||||
<span>
|
)}
|
||||||
#{selectedPR.number} opened {formatDate(selectedPR.createdAt)} by{' '}
|
<span className="text-sm font-medium truncate">
|
||||||
<span className="font-medium text-foreground">{selectedPR.author.login}</span>
|
#{selectedPR.number} {selectedPR.title}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Branch info */}
|
|
||||||
{selectedPR.headRefName && (
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<span className="text-xs text-muted-foreground">Branch:</span>
|
|
||||||
<span className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
|
|
||||||
{selectedPR.headRefName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Labels */}
|
|
||||||
{selectedPR.labels.length > 0 && (
|
|
||||||
<div className="flex items-center gap-2 mb-6 flex-wrap">
|
|
||||||
{selectedPR.labels.map((label) => (
|
|
||||||
<span
|
|
||||||
key={label.name}
|
|
||||||
className="px-2 py-0.5 text-xs font-medium rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: `#${label.color}20`,
|
|
||||||
color: `#${label.color}`,
|
|
||||||
border: `1px solid #${label.color}40`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label.name}
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
{selectedPR.isDraft && (
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground">
|
||||||
|
Draft
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={cn('flex items-center gap-2 shrink-0', isMobile && 'gap-1')}>
|
||||||
|
{!isMobile && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCommentDialogPR(selectedPR)}
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4 mr-1" />
|
||||||
|
Manage Comments
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleOpenInGitHub(selectedPR.url)}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
{!isMobile && <span className="ml-1">Open in GitHub</span>}
|
||||||
|
</Button>
|
||||||
|
{!isMobile && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSelectedPR(null)}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Body */}
|
{/* PR Detail Content */}
|
||||||
{selectedPR.body ? (
|
<div className={cn('flex-1 overflow-auto', isMobile ? 'p-4' : 'p-6')}>
|
||||||
<Markdown className="text-sm">{selectedPR.body}</Markdown>
|
{/* Title */}
|
||||||
) : (
|
<h1 className="text-xl font-bold mb-2">{selectedPR.title}</h1>
|
||||||
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Open in GitHub CTA */}
|
{/* Meta info */}
|
||||||
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
|
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4 flex-wrap">
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
<span
|
||||||
View code changes, comments, and reviews on GitHub.
|
className={cn(
|
||||||
</p>
|
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||||
<Button onClick={() => handleOpenInGitHub(selectedPR.url)}>
|
selectedPR.state === 'MERGED'
|
||||||
<ExternalLink className="h-4 w-4 mr-2" />
|
? 'bg-purple-500/10 text-purple-500'
|
||||||
View Full PR on GitHub
|
: selectedPR.isDraft
|
||||||
</Button>
|
? 'bg-muted text-muted-foreground'
|
||||||
|
: 'bg-green-500/10 text-green-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedPR.state === 'MERGED'
|
||||||
|
? 'Merged'
|
||||||
|
: selectedPR.isDraft
|
||||||
|
? 'Draft'
|
||||||
|
: 'Open'}
|
||||||
|
</span>
|
||||||
|
{reviewStatus && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
reviewStatus.bg,
|
||||||
|
reviewStatus.color
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{reviewStatus.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
#{selectedPR.number} opened {formatDate(selectedPR.createdAt)} by{' '}
|
||||||
|
<span className="font-medium text-foreground">{selectedPR.author.login}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Branch info */}
|
||||||
|
{selectedPR.headRefName && (
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<span className="text-xs text-muted-foreground">Branch:</span>
|
||||||
|
<span className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
|
||||||
|
{selectedPR.headRefName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
{selectedPR.labels.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 mb-6 flex-wrap">
|
||||||
|
{selectedPR.labels.map((label) => (
|
||||||
|
<span
|
||||||
|
key={label.name}
|
||||||
|
className="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `#${label.color}20`,
|
||||||
|
color: `#${label.color}`,
|
||||||
|
border: `1px solid #${label.color}40`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
{selectedPR.body ? (
|
||||||
|
<Markdown className="text-sm">{selectedPR.body}</Markdown>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Review Comments CTA */}
|
||||||
|
<div className="mt-8 p-4 rounded-lg bg-blue-500/5 border border-blue-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<MessageSquare className="h-4 w-4 text-blue-500" />
|
||||||
|
<span className="text-sm font-medium">Review Comments</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
Manage review comments individually or let AI address all feedback
|
||||||
|
automatically.
|
||||||
|
</p>
|
||||||
|
<div className={cn('flex gap-2', isMobile ? 'flex-col' : 'items-center')}>
|
||||||
|
<Button variant="outline" onClick={() => setCommentDialogPR(selectedPR)}>
|
||||||
|
<MessageSquare className="h-4 w-4 mr-2" />
|
||||||
|
Manage Review Comments
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => handleAutoAddressComments(selectedPR)}>
|
||||||
|
<Zap className="h-4 w-4 mr-2" />
|
||||||
|
Address Review Comments
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Open in GitHub CTA */}
|
||||||
|
<div className="mt-4 p-4 rounded-lg bg-muted/50 border border-border">
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
View code changes, comments, and reviews on GitHub.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => handleOpenInGitHub(selectedPR.url)}>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
View Full PR on GitHub
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
})()}
|
||||||
|
|
||||||
|
{/* PR Comment Resolution Dialog */}
|
||||||
|
{commentDialogPR && (
|
||||||
|
<PRCommentResolutionDialog
|
||||||
|
open={!!commentDialogPR}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setCommentDialogPR(null);
|
||||||
|
}}
|
||||||
|
pr={commentDialogPR}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -298,6 +449,8 @@ interface PRRowProps {
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
onOpenExternal: () => void;
|
onOpenExternal: () => void;
|
||||||
|
onManageComments: () => void;
|
||||||
|
onAutoAddressComments: () => void;
|
||||||
formatDate: (date: string) => string;
|
formatDate: (date: string) => string;
|
||||||
getReviewStatus: (pr: GitHubPR) => { label: string; color: string; bg: string } | null;
|
getReviewStatus: (pr: GitHubPR) => { label: string; color: string; bg: string } | null;
|
||||||
}
|
}
|
||||||
@@ -307,6 +460,8 @@ function PRRow({
|
|||||||
isSelected,
|
isSelected,
|
||||||
onClick,
|
onClick,
|
||||||
onOpenExternal,
|
onOpenExternal,
|
||||||
|
onManageComments,
|
||||||
|
onAutoAddressComments,
|
||||||
formatDate,
|
formatDate,
|
||||||
getReviewStatus,
|
getReviewStatus,
|
||||||
}: PRRowProps) {
|
}: PRRowProps) {
|
||||||
@@ -378,17 +533,52 @@ function PRRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
{/* Actions dropdown menu */}
|
||||||
variant="ghost"
|
<DropdownMenu>
|
||||||
size="sm"
|
<DropdownMenuTrigger asChild>
|
||||||
className="shrink-0 opacity-0 group-hover:opacity-100"
|
<Button
|
||||||
onClick={(e) => {
|
variant="ghost"
|
||||||
e.stopPropagation();
|
size="sm"
|
||||||
onOpenExternal();
|
className="shrink-0 h-7 w-7 p-0"
|
||||||
}}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onManageComments();
|
||||||
|
}}
|
||||||
|
className="text-xs text-blue-500 focus:text-blue-600"
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-3.5 w-3.5 mr-2" />
|
||||||
|
Manage PR Comments
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAutoAddressComments();
|
||||||
|
}}
|
||||||
|
className="text-xs text-blue-500 focus:text-blue-600"
|
||||||
|
>
|
||||||
|
<Zap className="h-3.5 w-3.5 mr-2" />
|
||||||
|
Address PR Comments
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenExternal();
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 mr-2" />
|
||||||
|
Open in GitHub
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,657 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo, useRef, type KeyboardEvent } from 'react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Terminal,
|
||||||
|
Save,
|
||||||
|
RotateCcw,
|
||||||
|
Info,
|
||||||
|
X,
|
||||||
|
Play,
|
||||||
|
FlaskConical,
|
||||||
|
ScrollText,
|
||||||
|
Plus,
|
||||||
|
GripVertical,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useProjectSettings } from '@/hooks/queries';
|
||||||
|
import { useUpdateProjectSettings } from '@/hooks/mutations';
|
||||||
|
import type { Project } from '@/lib/electron';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { DEFAULT_TERMINAL_SCRIPTS } from './terminal-scripts-constants';
|
||||||
|
|
||||||
|
/** Preset dev server commands for quick selection */
|
||||||
|
const DEV_SERVER_PRESETS = [
|
||||||
|
{ label: 'npm run dev', command: 'npm run dev' },
|
||||||
|
{ label: 'yarn dev', command: 'yarn dev' },
|
||||||
|
{ label: 'pnpm dev', command: 'pnpm dev' },
|
||||||
|
{ label: 'bun dev', command: 'bun dev' },
|
||||||
|
{ label: 'npm start', command: 'npm start' },
|
||||||
|
{ label: 'cargo watch', command: 'cargo watch -x run' },
|
||||||
|
{ label: 'go run', command: 'go run .' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Preset test commands for quick selection */
|
||||||
|
const TEST_PRESETS = [
|
||||||
|
{ label: 'npm test', command: 'npm test' },
|
||||||
|
{ label: 'yarn test', command: 'yarn test' },
|
||||||
|
{ label: 'pnpm test', command: 'pnpm test' },
|
||||||
|
{ label: 'bun test', command: 'bun test' },
|
||||||
|
{ label: 'pytest', command: 'pytest' },
|
||||||
|
{ label: 'cargo test', command: 'cargo test' },
|
||||||
|
{ label: 'go test', command: 'go test ./...' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Preset scripts for quick addition */
|
||||||
|
const SCRIPT_PRESETS = [
|
||||||
|
{ name: 'Dev Server', command: 'npm run dev' },
|
||||||
|
{ name: 'Build', command: 'npm run build' },
|
||||||
|
{ name: 'Test', command: 'npm run test' },
|
||||||
|
{ name: 'Lint', command: 'npm run lint' },
|
||||||
|
{ name: 'Format', command: 'npm run format' },
|
||||||
|
{ name: 'Type Check', command: 'npm run typecheck' },
|
||||||
|
{ name: 'Start', command: 'npm start' },
|
||||||
|
{ name: 'Clean', command: 'npm run clean' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface ScriptEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandsAndScriptsSectionProps {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a unique ID for a new script */
|
||||||
|
function generateId(): string {
|
||||||
|
return `script-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSectionProps) {
|
||||||
|
// Fetch project settings using TanStack Query
|
||||||
|
const { data: projectSettings, isLoading, isError } = useProjectSettings(project.path);
|
||||||
|
|
||||||
|
// Mutation hook for updating project settings
|
||||||
|
const updateSettingsMutation = useUpdateProjectSettings(project.path);
|
||||||
|
|
||||||
|
// ── Commands state ──
|
||||||
|
const [devCommand, setDevCommand] = useState('');
|
||||||
|
const [originalDevCommand, setOriginalDevCommand] = useState('');
|
||||||
|
const [testCommand, setTestCommand] = useState('');
|
||||||
|
const [originalTestCommand, setOriginalTestCommand] = useState('');
|
||||||
|
|
||||||
|
// ── Scripts state ──
|
||||||
|
const [scripts, setScripts] = useState<ScriptEntry[]>([]);
|
||||||
|
const [originalScripts, setOriginalScripts] = useState<ScriptEntry[]>([]);
|
||||||
|
|
||||||
|
// Dragging state for scripts
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Track previous project path to detect project switches
|
||||||
|
const prevProjectPathRef = useRef(project.path);
|
||||||
|
// Track whether we've done the initial sync for the current project
|
||||||
|
const isInitializedRef = useRef(false);
|
||||||
|
|
||||||
|
// Sync commands and scripts state when project settings load or project changes
|
||||||
|
useEffect(() => {
|
||||||
|
const projectChanged = prevProjectPathRef.current !== project.path;
|
||||||
|
prevProjectPathRef.current = project.path;
|
||||||
|
|
||||||
|
// Always clear local state on project change to avoid flashing stale data
|
||||||
|
if (projectChanged) {
|
||||||
|
isInitializedRef.current = false;
|
||||||
|
setDevCommand('');
|
||||||
|
setOriginalDevCommand('');
|
||||||
|
setTestCommand('');
|
||||||
|
setOriginalTestCommand('');
|
||||||
|
setScripts([]);
|
||||||
|
setOriginalScripts([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply project settings only when they are available
|
||||||
|
if (projectSettings) {
|
||||||
|
// Only sync from server if this is the initial load or if there are no unsaved edits.
|
||||||
|
// This prevents background refetches from overwriting in-progress local edits.
|
||||||
|
const isDirty =
|
||||||
|
isInitializedRef.current &&
|
||||||
|
(devCommand !== originalDevCommand ||
|
||||||
|
testCommand !== originalTestCommand ||
|
||||||
|
JSON.stringify(scripts) !== JSON.stringify(originalScripts));
|
||||||
|
|
||||||
|
if (!isInitializedRef.current || !isDirty) {
|
||||||
|
// Commands
|
||||||
|
const dev = projectSettings.devCommand || '';
|
||||||
|
const test = projectSettings.testCommand || '';
|
||||||
|
setDevCommand(dev);
|
||||||
|
setOriginalDevCommand(dev);
|
||||||
|
setTestCommand(test);
|
||||||
|
setOriginalTestCommand(test);
|
||||||
|
|
||||||
|
// Scripts
|
||||||
|
const configured = projectSettings.terminalScripts;
|
||||||
|
const scriptList =
|
||||||
|
configured && configured.length > 0
|
||||||
|
? configured.map((s) => ({ id: s.id, name: s.name, command: s.command }))
|
||||||
|
: DEFAULT_TERMINAL_SCRIPTS.map((s) => ({ ...s }));
|
||||||
|
setScripts(scriptList);
|
||||||
|
setOriginalScripts(structuredClone(scriptList));
|
||||||
|
|
||||||
|
isInitializedRef.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [projectSettings, project.path]);
|
||||||
|
|
||||||
|
// ── Change detection ──
|
||||||
|
const hasDevChanges = devCommand !== originalDevCommand;
|
||||||
|
const hasTestChanges = testCommand !== originalTestCommand;
|
||||||
|
const hasCommandChanges = hasDevChanges || hasTestChanges;
|
||||||
|
const hasScriptChanges = useMemo(
|
||||||
|
() => JSON.stringify(scripts) !== JSON.stringify(originalScripts),
|
||||||
|
[scripts, originalScripts]
|
||||||
|
);
|
||||||
|
const hasChanges = hasCommandChanges || hasScriptChanges;
|
||||||
|
const isSaving = updateSettingsMutation.isPending;
|
||||||
|
|
||||||
|
// ── Save all (commands + scripts) ──
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
const normalizedDevCommand = devCommand.trim();
|
||||||
|
const normalizedTestCommand = testCommand.trim();
|
||||||
|
const validScripts = scripts.filter((s) => s.name.trim() && s.command.trim());
|
||||||
|
const normalizedScripts = validScripts.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name.trim(),
|
||||||
|
command: s.command.trim(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
updateSettingsMutation.mutate(
|
||||||
|
{
|
||||||
|
devCommand: normalizedDevCommand || null,
|
||||||
|
testCommand: normalizedTestCommand || null,
|
||||||
|
terminalScripts: normalizedScripts,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setDevCommand(normalizedDevCommand);
|
||||||
|
setOriginalDevCommand(normalizedDevCommand);
|
||||||
|
setTestCommand(normalizedTestCommand);
|
||||||
|
setOriginalTestCommand(normalizedTestCommand);
|
||||||
|
setScripts(normalizedScripts);
|
||||||
|
setOriginalScripts(structuredClone(normalizedScripts));
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error('Failed to save settings', {
|
||||||
|
description: error instanceof Error ? error.message : 'An unexpected error occurred',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [devCommand, testCommand, scripts, updateSettingsMutation]);
|
||||||
|
|
||||||
|
// ── Reset all ──
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setDevCommand(originalDevCommand);
|
||||||
|
setTestCommand(originalTestCommand);
|
||||||
|
setScripts(structuredClone(originalScripts));
|
||||||
|
}, [originalDevCommand, originalTestCommand, originalScripts]);
|
||||||
|
|
||||||
|
// ── Command handlers ──
|
||||||
|
const handleUseDevPreset = useCallback((command: string) => {
|
||||||
|
setDevCommand(command);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUseTestPreset = useCallback((command: string) => {
|
||||||
|
setTestCommand(command);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClearDev = useCallback(() => {
|
||||||
|
setDevCommand('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClearTest = useCallback(() => {
|
||||||
|
setTestCommand('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Script handlers ──
|
||||||
|
const handleAddScript = useCallback(() => {
|
||||||
|
setScripts((prev) => [...prev, { id: generateId(), name: '', command: '' }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddPreset = useCallback((preset: { name: string; command: string }) => {
|
||||||
|
setScripts((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: generateId(), name: preset.name, command: preset.command },
|
||||||
|
]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRemoveScript = useCallback((index: number) => {
|
||||||
|
setScripts((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpdateScript = useCallback(
|
||||||
|
(index: number, field: 'name' | 'command', value: string) => {
|
||||||
|
setScripts((prev) => prev.map((s, i) => (i === index ? { ...s, [field]: value } : s)));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle keyboard shortcuts (Enter to save)
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter' && hasChanges && !isSaving) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hasChanges, isSaving, handleSave]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Drag and drop handlers for script reordering ──
|
||||||
|
const handleDragStart = useCallback((index: number) => {
|
||||||
|
setDraggedIndex(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback(
|
||||||
|
(e: React.DragEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedIndex === null || draggedIndex === index) return;
|
||||||
|
setDragOverIndex(index);
|
||||||
|
},
|
||||||
|
[draggedIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedIndex !== null && dragOverIndex !== null && draggedIndex !== dragOverIndex) {
|
||||||
|
setScripts((prev) => {
|
||||||
|
const newScripts = [...prev];
|
||||||
|
const [removed] = newScripts.splice(draggedIndex, 1);
|
||||||
|
newScripts.splice(dragOverIndex, 0, removed);
|
||||||
|
return newScripts;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
},
|
||||||
|
[draggedIndex, dragOverIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback((_e: React.DragEvent) => {
|
||||||
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Keyboard reorder helpers for accessibility ──
|
||||||
|
const moveScript = useCallback((fromIndex: number, toIndex: number) => {
|
||||||
|
setScripts((prev) => {
|
||||||
|
if (toIndex < 0 || toIndex >= prev.length) return prev;
|
||||||
|
const newScripts = [...prev];
|
||||||
|
const [removed] = newScripts.splice(fromIndex, 1);
|
||||||
|
newScripts.splice(toIndex, 0, removed);
|
||||||
|
return newScripts;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragHandleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent, index: number) => {
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
moveScript(index, index - 1);
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
moveScript(index, index + 1);
|
||||||
|
} else if (e.key === 'Home') {
|
||||||
|
e.preventDefault();
|
||||||
|
moveScript(index, 0);
|
||||||
|
} else if (e.key === 'End') {
|
||||||
|
e.preventDefault();
|
||||||
|
moveScript(index, scripts.length - 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[moveScript, scripts.length]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* ── Commands Card ── */}
|
||||||
|
<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'
|
||||||
|
)}
|
||||||
|
data-testid="commands-section"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<Terminal className="w-5 h-5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||||
|
Project Commands
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
|
Configure custom commands for development and testing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-8">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Spinner size="md" />
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-destructive">
|
||||||
|
Failed to load project settings. Please try again.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Dev Server Command Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Play className="w-4 h-4 text-brand-500" />
|
||||||
|
<h3 className="text-base font-medium text-foreground">Dev Server</h3>
|
||||||
|
{hasDevChanges && (
|
||||||
|
<span className="text-xs text-amber-500 font-medium">(unsaved)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 pl-6">
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="dev-command"
|
||||||
|
value={devCommand}
|
||||||
|
onChange={(e) => setDevCommand(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="e.g., npm run dev, yarn dev, cargo watch"
|
||||||
|
className="font-mono text-sm pr-8"
|
||||||
|
data-testid="dev-command-input"
|
||||||
|
/>
|
||||||
|
{devCommand && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClearDev}
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Clear dev command"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
Leave empty to auto-detect based on your package manager.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Dev Presets */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{DEV_SERVER_PRESETS.map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.command}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleUseDevPreset(preset.command)}
|
||||||
|
className="text-xs font-mono h-7 px-2"
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-border/30" />
|
||||||
|
|
||||||
|
{/* Test Command Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FlaskConical className="w-4 h-4 text-brand-500" />
|
||||||
|
<h3 className="text-base font-medium text-foreground">Test Runner</h3>
|
||||||
|
{hasTestChanges && (
|
||||||
|
<span className="text-xs text-amber-500 font-medium">(unsaved)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 pl-6">
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="test-command"
|
||||||
|
value={testCommand}
|
||||||
|
onChange={(e) => setTestCommand(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="e.g., npm test, pytest, cargo test"
|
||||||
|
className="font-mono text-sm pr-8"
|
||||||
|
data-testid="test-command-input"
|
||||||
|
/>
|
||||||
|
{testCommand && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClearTest}
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Clear test command"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
Leave empty to auto-detect based on your project structure.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Test Presets */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{TEST_PRESETS.map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.command}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleUseTestPreset(preset.command)}
|
||||||
|
className="text-xs font-mono h-7 px-2"
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-detection Info */}
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
|
||||||
|
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<p className="font-medium text-foreground mb-1">Auto-detection</p>
|
||||||
|
<p>
|
||||||
|
When no custom command is set, the system automatically detects your package
|
||||||
|
manager and test framework based on project files (package.json, Cargo.toml,
|
||||||
|
go.mod, etc.).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Terminal Quick Scripts Card ── */}
|
||||||
|
<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'
|
||||||
|
)}
|
||||||
|
data-testid="scripts-section"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<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">
|
||||||
|
<ScrollText className="w-5 h-5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||||
|
Terminal Quick Scripts
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
|
Configure quick-access scripts that appear in the terminal header dropdown. Click any
|
||||||
|
script to run it instantly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Spinner size="md" />
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-destructive">
|
||||||
|
Failed to load project settings. Please try again.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Scripts List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{scripts.map((script, index) => (
|
||||||
|
<div
|
||||||
|
key={script.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 p-2 rounded-lg border border-border/30 bg-accent/10 transition-all',
|
||||||
|
draggedIndex === index && 'opacity-50',
|
||||||
|
dragOverIndex === index && 'border-brand-500/50 bg-brand-500/5'
|
||||||
|
)}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => handleDragStart(index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDrop={(e) => handleDrop(e)}
|
||||||
|
onDragEnd={(e) => handleDragEnd(e)}
|
||||||
|
>
|
||||||
|
{/* Drag handle - keyboard accessible */}
|
||||||
|
<div
|
||||||
|
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground focus:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded shrink-0 p-0.5"
|
||||||
|
title="Drag to reorder (or use Arrow keys)"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
aria-label={`Reorder ${script.name || 'script'}. Use arrow keys to move.`}
|
||||||
|
onKeyDown={(e) => handleDragHandleKeyDown(e, index)}
|
||||||
|
>
|
||||||
|
<GripVertical className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Script name */}
|
||||||
|
<Input
|
||||||
|
value={script.name}
|
||||||
|
onChange={(e) => handleUpdateScript(index, 'name', e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Script name"
|
||||||
|
className="h-8 text-sm flex-[0.4] min-w-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Script command */}
|
||||||
|
<Input
|
||||||
|
value={script.command}
|
||||||
|
onChange={(e) => handleUpdateScript(index, 'command', e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Command to run"
|
||||||
|
className="h-8 text-sm font-mono flex-[0.6] min-w-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Remove button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveScript(index)}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive shrink-0"
|
||||||
|
aria-label={`Remove ${script.name || 'script'}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{scripts.length === 0 && (
|
||||||
|
<div className="text-center py-6 text-sm text-muted-foreground">
|
||||||
|
No scripts configured. Add some below or use a preset.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Script Button */}
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAddScript} className="gap-1.5">
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
Add Script
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-border/30" />
|
||||||
|
|
||||||
|
{/* Presets */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Quick Add Presets</h3>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{SCRIPT_PRESETS.map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.command}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAddPreset(preset)}
|
||||||
|
className="text-xs font-mono h-7 px-2"
|
||||||
|
>
|
||||||
|
{preset.command}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
|
||||||
|
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<p className="font-medium text-foreground mb-1">Terminal Quick Scripts</p>
|
||||||
|
<p>
|
||||||
|
These scripts appear in the terminal header as a dropdown menu (the{' '}
|
||||||
|
<ScrollText className="inline-block w-3 h-3 mx-0.5 align-middle" /> icon).
|
||||||
|
Clicking a script will type the command into the active terminal and press
|
||||||
|
Enter. Drag to reorder scripts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Shared Action Buttons ── */}
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={!hasChanges || isSaving}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges || isSaving}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{isSaving ? <Spinner size="xs" /> : <Save className="w-3.5 h-3.5" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
Workflow,
|
Workflow,
|
||||||
Database,
|
Database,
|
||||||
Terminal,
|
Terminal,
|
||||||
ScrollText,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||||
|
|
||||||
@@ -20,8 +19,7 @@ export interface ProjectNavigationItem {
|
|||||||
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
||||||
{ id: 'identity', label: 'Identity', icon: User },
|
{ id: 'identity', label: 'Identity', icon: User },
|
||||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||||
{ id: 'commands', label: 'Commands', icon: Terminal },
|
{ id: 'commands-scripts', label: 'Commands & Scripts', icon: Terminal },
|
||||||
{ id: 'scripts', label: 'Terminal Scripts', icon: ScrollText },
|
|
||||||
{ id: 'theme', label: 'Theme', icon: Palette },
|
{ id: 'theme', label: 'Theme', icon: Palette },
|
||||||
{ id: 'claude', label: 'Models', icon: Workflow },
|
{ id: 'claude', label: 'Models', icon: Workflow },
|
||||||
{ id: 'data', label: 'Data', icon: Database },
|
{ id: 'data', label: 'Data', icon: Database },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type ProjectSettingsViewId =
|
|||||||
| 'worktrees'
|
| 'worktrees'
|
||||||
| 'commands'
|
| 'commands'
|
||||||
| 'scripts'
|
| 'scripts'
|
||||||
|
| 'commands-scripts'
|
||||||
| 'claude'
|
| 'claude'
|
||||||
| 'data'
|
| 'data'
|
||||||
| 'danger';
|
| 'danger';
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export { ProjectSettingsView } from './project-settings-view';
|
|||||||
export { ProjectIdentitySection } from './project-identity-section';
|
export { ProjectIdentitySection } from './project-identity-section';
|
||||||
export { ProjectThemeSection } from './project-theme-section';
|
export { ProjectThemeSection } from './project-theme-section';
|
||||||
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||||
|
export { CommandsAndScriptsSection } from './commands-and-scripts-section';
|
||||||
|
// Legacy exports kept for backward compatibility
|
||||||
export { CommandsSection } from './commands-section';
|
export { CommandsSection } from './commands-section';
|
||||||
export { TerminalScriptsSection } from './terminal-scripts-section';
|
export { TerminalScriptsSection } from './terminal-scripts-section';
|
||||||
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { ProjectIdentitySection } from './project-identity-section';
|
import { ProjectIdentitySection } from './project-identity-section';
|
||||||
import { ProjectThemeSection } from './project-theme-section';
|
import { ProjectThemeSection } from './project-theme-section';
|
||||||
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||||
import { CommandsSection } from './commands-section';
|
import { CommandsAndScriptsSection } from './commands-and-scripts-section';
|
||||||
import { TerminalScriptsSection } from './terminal-scripts-section';
|
|
||||||
import { ProjectModelsSection } from './project-models-section';
|
import { ProjectModelsSection } from './project-models-section';
|
||||||
import { DataManagementSection } from './data-management-section';
|
import { DataManagementSection } from './data-management-section';
|
||||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||||
@@ -15,6 +14,8 @@ import { RemoveFromAutomakerDialog } from '../settings-view/components/remove-fr
|
|||||||
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||||
import { useProjectSettingsView } from './hooks/use-project-settings-view';
|
import { useProjectSettingsView } from './hooks/use-project-settings-view';
|
||||||
import type { Project as ElectronProject } from '@/lib/electron';
|
import type { Project as ElectronProject } from '@/lib/electron';
|
||||||
|
import { useSearch } from '@tanstack/react-router';
|
||||||
|
import type { ProjectSettingsViewId } from './hooks/use-project-settings-view';
|
||||||
|
|
||||||
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
|
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
|
||||||
const LG_BREAKPOINT = 1024;
|
const LG_BREAKPOINT = 1024;
|
||||||
@@ -34,8 +35,18 @@ export function ProjectSettingsView() {
|
|||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [showRemoveFromAutomakerDialog, setShowRemoveFromAutomakerDialog] = useState(false);
|
const [showRemoveFromAutomakerDialog, setShowRemoveFromAutomakerDialog] = useState(false);
|
||||||
|
|
||||||
|
// Read the optional section search param to support deep-linking to a specific section
|
||||||
|
const search = useSearch({ strict: false }) as { section?: ProjectSettingsViewId };
|
||||||
|
// Map legacy 'commands' and 'scripts' IDs to the combined 'commands-scripts' section
|
||||||
|
const resolvedSection: ProjectSettingsViewId | undefined =
|
||||||
|
search.section === 'commands' || search.section === 'scripts'
|
||||||
|
? 'commands-scripts'
|
||||||
|
: search.section;
|
||||||
|
|
||||||
// Use project settings view navigation hook
|
// Use project settings view navigation hook
|
||||||
const { activeView, navigateTo } = useProjectSettingsView();
|
const { activeView, navigateTo } = useProjectSettingsView({
|
||||||
|
initialView: resolvedSection ?? 'identity',
|
||||||
|
});
|
||||||
|
|
||||||
// Mobile navigation state - default to showing on desktop, hidden on mobile
|
// Mobile navigation state - default to showing on desktop, hidden on mobile
|
||||||
const [showNavigation, setShowNavigation] = useState(() => {
|
const [showNavigation, setShowNavigation] = useState(() => {
|
||||||
@@ -91,9 +102,9 @@ export function ProjectSettingsView() {
|
|||||||
case 'worktrees':
|
case 'worktrees':
|
||||||
return <WorktreePreferencesSection project={currentProject} />;
|
return <WorktreePreferencesSection project={currentProject} />;
|
||||||
case 'commands':
|
case 'commands':
|
||||||
return <CommandsSection project={currentProject} />;
|
|
||||||
case 'scripts':
|
case 'scripts':
|
||||||
return <TerminalScriptsSection project={currentProject} />;
|
case 'commands-scripts':
|
||||||
|
return <CommandsAndScriptsSection project={currentProject} />;
|
||||||
case 'claude':
|
case 'claude':
|
||||||
return <ProjectModelsSection project={currentProject} />;
|
return <ProjectModelsSection project={currentProject} />;
|
||||||
case 'data':
|
case 'data':
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export function SettingsView() {
|
|||||||
setPromptCustomization,
|
setPromptCustomization,
|
||||||
skipSandboxWarning,
|
skipSandboxWarning,
|
||||||
setSkipSandboxWarning,
|
setSkipSandboxWarning,
|
||||||
|
defaultMaxTurns,
|
||||||
|
setDefaultMaxTurns,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
// Global theme (project-specific themes are managed in Project Settings)
|
// Global theme (project-specific themes are managed in Project Settings)
|
||||||
@@ -173,6 +175,7 @@ export function SettingsView() {
|
|||||||
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
||||||
enableAiCommitMessages={enableAiCommitMessages}
|
enableAiCommitMessages={enableAiCommitMessages}
|
||||||
defaultFeatureModel={defaultFeatureModel}
|
defaultFeatureModel={defaultFeatureModel}
|
||||||
|
defaultMaxTurns={defaultMaxTurns}
|
||||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||||
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||||
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
|
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
|
||||||
@@ -180,6 +183,7 @@ export function SettingsView() {
|
|||||||
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
||||||
onEnableAiCommitMessagesChange={setEnableAiCommitMessages}
|
onEnableAiCommitMessagesChange={setEnableAiCommitMessages}
|
||||||
onDefaultFeatureModelChange={setDefaultFeatureModel}
|
onDefaultFeatureModelChange={setDefaultFeatureModel}
|
||||||
|
onDefaultMaxTurnsChange={setDefaultMaxTurns}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'worktrees':
|
case 'worktrees':
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
TestTube,
|
TestTube,
|
||||||
@@ -12,6 +14,7 @@ import {
|
|||||||
FastForward,
|
FastForward,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Cpu,
|
Cpu,
|
||||||
|
RotateCcw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +37,7 @@ interface FeatureDefaultsSectionProps {
|
|||||||
defaultRequirePlanApproval: boolean;
|
defaultRequirePlanApproval: boolean;
|
||||||
enableAiCommitMessages: boolean;
|
enableAiCommitMessages: boolean;
|
||||||
defaultFeatureModel: PhaseModelEntry;
|
defaultFeatureModel: PhaseModelEntry;
|
||||||
|
defaultMaxTurns: number;
|
||||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||||
onEnableDependencyBlockingChange: (value: boolean) => void;
|
onEnableDependencyBlockingChange: (value: boolean) => void;
|
||||||
onSkipVerificationInAutoModeChange: (value: boolean) => void;
|
onSkipVerificationInAutoModeChange: (value: boolean) => void;
|
||||||
@@ -41,6 +45,7 @@ interface FeatureDefaultsSectionProps {
|
|||||||
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
||||||
onEnableAiCommitMessagesChange: (value: boolean) => void;
|
onEnableAiCommitMessagesChange: (value: boolean) => void;
|
||||||
onDefaultFeatureModelChange: (value: PhaseModelEntry) => void;
|
onDefaultFeatureModelChange: (value: PhaseModelEntry) => void;
|
||||||
|
onDefaultMaxTurnsChange: (value: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeatureDefaultsSection({
|
export function FeatureDefaultsSection({
|
||||||
@@ -51,6 +56,7 @@ export function FeatureDefaultsSection({
|
|||||||
defaultRequirePlanApproval,
|
defaultRequirePlanApproval,
|
||||||
enableAiCommitMessages,
|
enableAiCommitMessages,
|
||||||
defaultFeatureModel,
|
defaultFeatureModel,
|
||||||
|
defaultMaxTurns,
|
||||||
onDefaultSkipTestsChange,
|
onDefaultSkipTestsChange,
|
||||||
onEnableDependencyBlockingChange,
|
onEnableDependencyBlockingChange,
|
||||||
onSkipVerificationInAutoModeChange,
|
onSkipVerificationInAutoModeChange,
|
||||||
@@ -58,7 +64,16 @@ export function FeatureDefaultsSection({
|
|||||||
onDefaultRequirePlanApprovalChange,
|
onDefaultRequirePlanApprovalChange,
|
||||||
onEnableAiCommitMessagesChange,
|
onEnableAiCommitMessagesChange,
|
||||||
onDefaultFeatureModelChange,
|
onDefaultFeatureModelChange,
|
||||||
|
onDefaultMaxTurnsChange,
|
||||||
}: FeatureDefaultsSectionProps) {
|
}: FeatureDefaultsSectionProps) {
|
||||||
|
const [maxTurnsInput, setMaxTurnsInput] = useState(String(defaultMaxTurns));
|
||||||
|
|
||||||
|
// Keep the displayed input in sync if the prop changes after mount
|
||||||
|
// (e.g. when settings are loaded asynchronously or reset from parent)
|
||||||
|
useEffect(() => {
|
||||||
|
setMaxTurnsInput(String(defaultMaxTurns));
|
||||||
|
}, [defaultMaxTurns]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -104,6 +119,55 @@ export function FeatureDefaultsSection({
|
|||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
<div className="border-t border-border/30" />
|
<div className="border-t border-border/30" />
|
||||||
|
|
||||||
|
{/* Max Turns Setting */}
|
||||||
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||||
|
<div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-orange-500/10">
|
||||||
|
<RotateCcw className="w-5 h-5 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="default-max-turns" className="text-foreground font-medium">
|
||||||
|
Max Agent Turns
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="default-max-turns"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={2000}
|
||||||
|
step={1}
|
||||||
|
value={maxTurnsInput}
|
||||||
|
onChange={(e) => {
|
||||||
|
setMaxTurnsInput(e.target.value);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
const value = Number(maxTurnsInput);
|
||||||
|
if (Number.isInteger(value) && value >= 1 && value <= 2000) {
|
||||||
|
onDefaultMaxTurnsChange(value);
|
||||||
|
} else {
|
||||||
|
// Reset to current valid value if invalid (including decimals like "1.5")
|
||||||
|
setMaxTurnsInput(String(defaultMaxTurns));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
(e.target as HTMLInputElement).blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-[100px] h-8 text-right"
|
||||||
|
data-testid="default-max-turns-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||||
|
Maximum number of tool-call round-trips the AI agent can perform per feature. Higher
|
||||||
|
values allow more complex tasks but use more API credits. Default: 1000, Range:
|
||||||
|
1-2000. Supported by Claude and Codex providers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="border-t border-border/30" />
|
||||||
|
|
||||||
{/* Planning Mode Default */}
|
{/* Planning Mode Default */}
|
||||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
SquarePlus,
|
SquarePlus,
|
||||||
SplitSquareHorizontal,
|
SplitSquareHorizontal,
|
||||||
|
Palette,
|
||||||
|
Type,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
@@ -38,6 +41,8 @@ export function TerminalSection() {
|
|||||||
defaultTerminalId,
|
defaultTerminalId,
|
||||||
setDefaultTerminalId,
|
setDefaultTerminalId,
|
||||||
setOpenTerminalMode,
|
setOpenTerminalMode,
|
||||||
|
setTerminalBackgroundColor,
|
||||||
|
setTerminalForegroundColor,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -48,6 +53,8 @@ export function TerminalSection() {
|
|||||||
lineHeight,
|
lineHeight,
|
||||||
defaultFontSize,
|
defaultFontSize,
|
||||||
openTerminalMode,
|
openTerminalMode,
|
||||||
|
customBackgroundColor,
|
||||||
|
customForegroundColor,
|
||||||
} = terminalState;
|
} = terminalState;
|
||||||
|
|
||||||
// Get available external terminals
|
// Get available external terminals
|
||||||
@@ -205,6 +212,138 @@ export function TerminalSection() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Background Color */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-foreground font-medium">Background Color</Label>
|
||||||
|
{customBackgroundColor && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
setTerminalBackgroundColor(null);
|
||||||
|
toast.success('Background color reset to theme default');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3 mr-1" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Override the terminal background color. Leave empty to use the theme default.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-lg border border-border/50 shadow-sm flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: customBackgroundColor || 'var(--card)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Palette
|
||||||
|
className={cn(
|
||||||
|
'w-5 h-5',
|
||||||
|
customBackgroundColor ? 'text-white/80' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
value={customBackgroundColor || '#000000'}
|
||||||
|
onChange={(e) => {
|
||||||
|
const color = e.target.value;
|
||||||
|
setTerminalBackgroundColor(color);
|
||||||
|
}}
|
||||||
|
className="w-14 h-10 p-1 cursor-pointer bg-transparent border-border/50"
|
||||||
|
title="Pick a color"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={customBackgroundColor || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
// Validate hex color format
|
||||||
|
if (value === '' || /^#[0-9A-Fa-f]{0,6}$/.test(value)) {
|
||||||
|
if (value === '' || /^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||||
|
setTerminalBackgroundColor(value || null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="e.g., #1a1a1a"
|
||||||
|
className="flex-1 bg-accent/30 border-border/50 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Foreground Color */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-foreground font-medium">Foreground Color</Label>
|
||||||
|
{customForegroundColor && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
setTerminalForegroundColor(null);
|
||||||
|
toast.success('Foreground color reset to theme default');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3 mr-1" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Override the terminal text/foreground color. Leave empty to use the theme default.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-lg border border-border/50 shadow-sm flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: customForegroundColor || 'var(--foreground)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Type
|
||||||
|
className={cn(
|
||||||
|
'w-5 h-5',
|
||||||
|
customForegroundColor ? 'text-black/80' : 'text-background'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
value={customForegroundColor || '#ffffff'}
|
||||||
|
onChange={(e) => {
|
||||||
|
const color = e.target.value;
|
||||||
|
setTerminalForegroundColor(color);
|
||||||
|
}}
|
||||||
|
className="w-14 h-10 p-1 cursor-pointer bg-transparent border-border/50"
|
||||||
|
title="Pick a color"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={customForegroundColor || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
// Validate hex color format
|
||||||
|
if (value === '' || /^#[0-9A-Fa-f]{0,6}$/.test(value)) {
|
||||||
|
if (value === '' || /^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||||
|
setTerminalForegroundColor(value || null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="e.g., #ffffff"
|
||||||
|
className="flex-1 bg-accent/30 border-border/50 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Default Font Size */}
|
{/* Default Font Size */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import {
|
|||||||
GitBranch,
|
GitBranch,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
FolderGit,
|
FolderGit,
|
||||||
|
Palette,
|
||||||
|
RotateCcw,
|
||||||
|
Type,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { getServerUrlSync } from '@/lib/http-api-client';
|
import { getServerUrlSync } from '@/lib/http-api-client';
|
||||||
@@ -240,9 +243,17 @@ interface TerminalViewProps {
|
|||||||
initialMode?: 'tab' | 'split';
|
initialMode?: 'tab' | 'split';
|
||||||
/** Unique nonce to allow opening the same worktree multiple times */
|
/** Unique nonce to allow opening the same worktree multiple times */
|
||||||
nonce?: number;
|
nonce?: number;
|
||||||
|
/** Command to run automatically when the terminal is created (e.g., from scripts submenu) */
|
||||||
|
initialCommand?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: TerminalViewProps) {
|
export function TerminalView({
|
||||||
|
initialCwd,
|
||||||
|
initialBranch,
|
||||||
|
initialMode,
|
||||||
|
nonce,
|
||||||
|
initialCommand,
|
||||||
|
}: TerminalViewProps) {
|
||||||
const {
|
const {
|
||||||
terminalState,
|
terminalState,
|
||||||
setTerminalUnlocked,
|
setTerminalUnlocked,
|
||||||
@@ -268,6 +279,8 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
setTerminalLineHeight,
|
setTerminalLineHeight,
|
||||||
setTerminalScrollbackLines,
|
setTerminalScrollbackLines,
|
||||||
setTerminalScreenReaderMode,
|
setTerminalScreenReaderMode,
|
||||||
|
setTerminalBackgroundColor,
|
||||||
|
setTerminalForegroundColor,
|
||||||
updateTerminalPanelSizes,
|
updateTerminalPanelSizes,
|
||||||
currentWorktreeByProject,
|
currentWorktreeByProject,
|
||||||
worktreesByProject,
|
worktreesByProject,
|
||||||
@@ -288,6 +301,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
const isCreatingRef = useRef<boolean>(false);
|
const isCreatingRef = useRef<boolean>(false);
|
||||||
const restoringProjectPathRef = useRef<string | null>(null);
|
const restoringProjectPathRef = useRef<string | null>(null);
|
||||||
const [newSessionIds, setNewSessionIds] = useState<Set<string>>(new Set());
|
const [newSessionIds, setNewSessionIds] = useState<Set<string>>(new Set());
|
||||||
|
// Per-session command overrides (e.g., from scripts submenu), takes priority over defaultRunScript
|
||||||
|
const [sessionCommandOverrides, setSessionCommandOverrides] = useState<Map<string, string>>(
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
const [serverSessionInfo, setServerSessionInfo] = useState<{
|
const [serverSessionInfo, setServerSessionInfo] = useState<{
|
||||||
current: number;
|
current: number;
|
||||||
max: number;
|
max: number;
|
||||||
@@ -576,7 +593,7 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
|
|
||||||
// Skip if we've already handled this exact request (prevents duplicate terminals)
|
// Skip if we've already handled this exact request (prevents duplicate terminals)
|
||||||
// Include mode and nonce in the key to allow opening same cwd multiple times
|
// Include mode and nonce in the key to allow opening same cwd multiple times
|
||||||
const cwdKey = `${initialCwd}:${initialMode || 'default'}:${nonce || 0}`;
|
const cwdKey = `${initialCwd}:${initialMode || 'default'}:${nonce || 0}:${initialCommand || ''}`;
|
||||||
if (initialCwdHandledRef.current === cwdKey) return;
|
if (initialCwdHandledRef.current === cwdKey) return;
|
||||||
|
|
||||||
// Skip if terminal is not enabled or not unlocked
|
// Skip if terminal is not enabled or not unlocked
|
||||||
@@ -618,8 +635,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mark this session as new for running initial command
|
// Mark this session as new for running initial command
|
||||||
if (defaultRunScript) {
|
if (initialCommand || defaultRunScript) {
|
||||||
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
|
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
|
||||||
|
// Store per-session command override if an explicit command was provided
|
||||||
|
if (initialCommand) {
|
||||||
|
setSessionCommandOverrides((prev) => new Map(prev).set(data.data.id, initialCommand));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show success toast with branch name if provided
|
// Show success toast with branch name if provided
|
||||||
@@ -654,6 +675,7 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
initialCwd,
|
initialCwd,
|
||||||
initialBranch,
|
initialBranch,
|
||||||
initialMode,
|
initialMode,
|
||||||
|
initialCommand,
|
||||||
nonce,
|
nonce,
|
||||||
status?.enabled,
|
status?.enabled,
|
||||||
status?.passwordRequired,
|
status?.passwordRequired,
|
||||||
@@ -1059,7 +1081,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
// Create terminal in new tab
|
// Create terminal in new tab
|
||||||
// customCwd: optional working directory (e.g., a specific worktree path)
|
// customCwd: optional working directory (e.g., a specific worktree path)
|
||||||
// branchName: optional branch name to display in the terminal panel header
|
// branchName: optional branch name to display in the terminal panel header
|
||||||
const createTerminalInNewTab = async (customCwd?: string, branchName?: string) => {
|
// command: optional command to run when the terminal connects (e.g., from scripts menu)
|
||||||
|
const createTerminalInNewTab = async (
|
||||||
|
customCwd?: string,
|
||||||
|
branchName?: string,
|
||||||
|
command?: string
|
||||||
|
) => {
|
||||||
if (!canCreateTerminal('[Terminal] Debounced terminal tab creation')) {
|
if (!canCreateTerminal('[Terminal] Debounced terminal tab creation')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1087,8 +1114,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
const { addTerminalToTab } = useAppStore.getState();
|
const { addTerminalToTab } = useAppStore.getState();
|
||||||
addTerminalToTab(data.data.id, tabId, undefined, worktreeBranch);
|
addTerminalToTab(data.data.id, tabId, undefined, worktreeBranch);
|
||||||
// Mark this session as new for running initial command
|
// Mark this session as new for running initial command
|
||||||
if (defaultRunScript) {
|
if (command || defaultRunScript) {
|
||||||
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
|
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
|
||||||
|
// Store per-session command override if an explicit command was provided
|
||||||
|
if (command) {
|
||||||
|
setSessionCommandOverrides((prev) => new Map(prev).set(data.data.id, command));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Refresh session count
|
// Refresh session count
|
||||||
fetchServerSettings();
|
fetchServerSettings();
|
||||||
@@ -1136,6 +1167,18 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
// Always remove from UI - even if server says 404 (session may have already exited)
|
// Always remove from UI - even if server says 404 (session may have already exited)
|
||||||
removeTerminalFromLayout(sessionId);
|
removeTerminalFromLayout(sessionId);
|
||||||
|
|
||||||
|
// Clean up stale entries for killed sessions
|
||||||
|
setSessionCommandOverrides((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(sessionId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setNewSessionIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(sessionId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok && response.status !== 404) {
|
if (!response.ok && response.status !== 404) {
|
||||||
// Log non-404 errors but still proceed with UI cleanup
|
// Log non-404 errors but still proceed with UI cleanup
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
@@ -1148,6 +1191,17 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
logger.error('Kill session error:', err);
|
logger.error('Kill session error:', err);
|
||||||
// Still remove from UI on network error - better UX than leaving broken terminal
|
// Still remove from UI on network error - better UX than leaving broken terminal
|
||||||
removeTerminalFromLayout(sessionId);
|
removeTerminalFromLayout(sessionId);
|
||||||
|
// Clean up stale entries for killed sessions (same cleanup as try block)
|
||||||
|
setSessionCommandOverrides((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(sessionId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setNewSessionIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(sessionId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1182,6 +1236,22 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Clean up stale entries for all killed sessions in this tab
|
||||||
|
setSessionCommandOverrides((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
next.delete(sessionId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setNewSessionIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
next.delete(sessionId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
// Now remove the tab from state
|
// Now remove the tab from state
|
||||||
removeTerminalTab(tabId);
|
removeTerminalTab(tabId);
|
||||||
// Refresh session count
|
// Refresh session count
|
||||||
@@ -1255,6 +1325,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
next.delete(sessionId);
|
next.delete(sessionId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
// Clean up any per-session command override
|
||||||
|
setSessionCommandOverrides((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(sessionId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Navigate between terminal panes with directional awareness
|
// Navigate between terminal panes with directional awareness
|
||||||
@@ -1387,6 +1463,9 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
const terminalFontSize = content.fontSize ?? terminalState.defaultFontSize;
|
const terminalFontSize = content.fontSize ?? terminalState.defaultFontSize;
|
||||||
// Only run command on new sessions (not restored ones)
|
// Only run command on new sessions (not restored ones)
|
||||||
const isNewSession = newSessionIds.has(content.sessionId);
|
const isNewSession = newSessionIds.has(content.sessionId);
|
||||||
|
// Per-session command override takes priority over defaultRunScript
|
||||||
|
const sessionCommand = sessionCommandOverrides.get(content.sessionId);
|
||||||
|
const runCommand = isNewSession ? sessionCommand || defaultRunScript : undefined;
|
||||||
return (
|
return (
|
||||||
<TerminalErrorBoundary
|
<TerminalErrorBoundary
|
||||||
key={`boundary-${content.sessionId}`}
|
key={`boundary-${content.sessionId}`}
|
||||||
@@ -1413,6 +1492,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
createTerminal('vertical', content.sessionId, cwd, branchName);
|
createTerminal('vertical', content.sessionId, cwd, branchName);
|
||||||
}}
|
}}
|
||||||
onNewTab={createTerminalInNewTab}
|
onNewTab={createTerminalInNewTab}
|
||||||
|
onRunCommandInNewTab={(command: string) => {
|
||||||
|
const { cwd, branchName: branch } = getActiveSessionWorktreeInfo();
|
||||||
|
createTerminalInNewTab(cwd, branch, command);
|
||||||
|
}}
|
||||||
onNavigateUp={() => navigateToTerminal('up')}
|
onNavigateUp={() => navigateToTerminal('up')}
|
||||||
onNavigateDown={() => navigateToTerminal('down')}
|
onNavigateDown={() => navigateToTerminal('down')}
|
||||||
onNavigateLeft={() => navigateToTerminal('left')}
|
onNavigateLeft={() => navigateToTerminal('left')}
|
||||||
@@ -1427,7 +1510,7 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
isDropTarget={activeDragId !== null && activeDragId !== content.sessionId}
|
isDropTarget={activeDragId !== null && activeDragId !== content.sessionId}
|
||||||
fontSize={terminalFontSize}
|
fontSize={terminalFontSize}
|
||||||
onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)}
|
onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)}
|
||||||
runCommandOnConnect={isNewSession ? defaultRunScript : undefined}
|
runCommandOnConnect={runCommand}
|
||||||
onCommandRan={() => handleCommandRan(content.sessionId)}
|
onCommandRan={() => handleCommandRan(content.sessionId)}
|
||||||
isMaximized={terminalState.maximizedSessionId === content.sessionId}
|
isMaximized={terminalState.maximizedSessionId === content.sessionId}
|
||||||
onToggleMaximize={() => toggleTerminalMaximized(content.sessionId)}
|
onToggleMaximize={() => toggleTerminalMaximized(content.sessionId)}
|
||||||
@@ -1919,6 +2002,119 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Background Color */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-medium">Background Color</Label>
|
||||||
|
{terminalState.customBackgroundColor && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5"
|
||||||
|
onClick={() => setTerminalBackgroundColor(null)}
|
||||||
|
title="Reset to theme default"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-7 h-7 rounded border border-border/50 shrink-0 flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: terminalState.customBackgroundColor || 'var(--card)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Palette
|
||||||
|
className={cn(
|
||||||
|
'h-3 w-3',
|
||||||
|
terminalState.customBackgroundColor
|
||||||
|
? 'text-white/80'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
value={terminalState.customBackgroundColor || '#000000'}
|
||||||
|
onChange={(e) => setTerminalBackgroundColor(e.target.value)}
|
||||||
|
className="w-10 h-7 p-0.5 cursor-pointer bg-transparent border-border/50 shrink-0"
|
||||||
|
title="Pick a background color"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={terminalState.customBackgroundColor || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (value === '' || /^#[0-9A-Fa-f]{0,6}$/.test(value)) {
|
||||||
|
if (value === '' || /^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||||
|
setTerminalBackgroundColor(value || null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="#1a1a1a"
|
||||||
|
className="flex-1 h-7 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Foreground Color */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-medium">Foreground Color</Label>
|
||||||
|
{terminalState.customForegroundColor && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5"
|
||||||
|
onClick={() => setTerminalForegroundColor(null)}
|
||||||
|
title="Reset to theme default"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-7 h-7 rounded border border-border/50 shrink-0 flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
terminalState.customForegroundColor || 'var(--foreground)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Type
|
||||||
|
className={cn(
|
||||||
|
'h-3 w-3',
|
||||||
|
terminalState.customForegroundColor
|
||||||
|
? 'text-black/80'
|
||||||
|
: 'text-background'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
value={terminalState.customForegroundColor || '#ffffff'}
|
||||||
|
onChange={(e) => setTerminalForegroundColor(e.target.value)}
|
||||||
|
className="w-10 h-7 p-0.5 cursor-pointer bg-transparent border-border/50 shrink-0"
|
||||||
|
title="Pick a foreground color"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={terminalState.customForegroundColor || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (value === '' || /^#[0-9A-Fa-f]{0,6}$/.test(value)) {
|
||||||
|
if (value === '' || /^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||||
|
setTerminalForegroundColor(value || null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="#ffffff"
|
||||||
|
className="flex-1 h-7 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Screen Reader */}
|
{/* Screen Reader */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
@@ -1971,6 +2167,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
createTerminal('vertical', terminalState.maximizedSessionId!, cwd, branchName);
|
createTerminal('vertical', terminalState.maximizedSessionId!, cwd, branchName);
|
||||||
}}
|
}}
|
||||||
onNewTab={createTerminalInNewTab}
|
onNewTab={createTerminalInNewTab}
|
||||||
|
onRunCommandInNewTab={(command: string) => {
|
||||||
|
const { cwd, branchName: branch } = getActiveSessionWorktreeInfo();
|
||||||
|
createTerminalInNewTab(cwd, branch, command);
|
||||||
|
}}
|
||||||
onSessionInvalid={() => {
|
onSessionInvalid={() => {
|
||||||
const sessionId = terminalState.maximizedSessionId!;
|
const sessionId = terminalState.maximizedSessionId!;
|
||||||
logger.info(`Maximized session ${sessionId} is invalid, removing from layout`);
|
logger.info(`Maximized session ${sessionId} is invalid, removing from layout`);
|
||||||
@@ -1982,6 +2182,13 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
onFontSizeChange={(size) =>
|
onFontSizeChange={(size) =>
|
||||||
setTerminalPanelFontSize(terminalState.maximizedSessionId!, size)
|
setTerminalPanelFontSize(terminalState.maximizedSessionId!, size)
|
||||||
}
|
}
|
||||||
|
runCommandOnConnect={
|
||||||
|
newSessionIds.has(terminalState.maximizedSessionId)
|
||||||
|
? sessionCommandOverrides.get(terminalState.maximizedSessionId) ||
|
||||||
|
defaultRunScript
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onCommandRan={() => handleCommandRan(terminalState.maximizedSessionId!)}
|
||||||
isMaximized={true}
|
isMaximized={true}
|
||||||
onToggleMaximize={() => toggleTerminalMaximized(terminalState.maximizedSessionId!)}
|
onToggleMaximize={() => toggleTerminalMaximized(terminalState.maximizedSessionId!)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
@@ -90,6 +91,7 @@ interface TerminalPanelProps {
|
|||||||
onSplitHorizontal: () => void;
|
onSplitHorizontal: () => void;
|
||||||
onSplitVertical: () => void;
|
onSplitVertical: () => void;
|
||||||
onNewTab?: () => void;
|
onNewTab?: () => void;
|
||||||
|
onRunCommandInNewTab?: (command: string) => void; // Run a script command in a new terminal tab
|
||||||
onNavigateUp?: () => void; // Navigate to terminal pane above
|
onNavigateUp?: () => void; // Navigate to terminal pane above
|
||||||
onNavigateDown?: () => void; // Navigate to terminal pane below
|
onNavigateDown?: () => void; // Navigate to terminal pane below
|
||||||
onNavigateLeft?: () => void; // Navigate to terminal pane on the left
|
onNavigateLeft?: () => void; // Navigate to terminal pane on the left
|
||||||
@@ -120,6 +122,7 @@ export function TerminalPanel({
|
|||||||
onSplitHorizontal,
|
onSplitHorizontal,
|
||||||
onSplitVertical,
|
onSplitVertical,
|
||||||
onNewTab,
|
onNewTab,
|
||||||
|
onRunCommandInNewTab,
|
||||||
onNavigateUp,
|
onNavigateUp,
|
||||||
onNavigateDown,
|
onNavigateDown,
|
||||||
onNavigateLeft,
|
onNavigateLeft,
|
||||||
@@ -135,6 +138,7 @@ export function TerminalPanel({
|
|||||||
onToggleMaximize,
|
onToggleMaximize,
|
||||||
branchName,
|
branchName,
|
||||||
}: TerminalPanelProps) {
|
}: TerminalPanelProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const terminalRef = useRef<HTMLDivElement>(null);
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const xtermRef = useRef<XTerminal | null>(null);
|
const xtermRef = useRef<XTerminal | null>(null);
|
||||||
@@ -198,16 +202,25 @@ export function TerminalPanel({
|
|||||||
const currentProject = useAppStore((state) => state.currentProject);
|
const currentProject = useAppStore((state) => state.currentProject);
|
||||||
|
|
||||||
// Get terminal settings from store - grouped with shallow comparison to reduce re-renders
|
// Get terminal settings from store - grouped with shallow comparison to reduce re-renders
|
||||||
const { defaultRunScript, screenReaderMode, fontFamily, scrollbackLines, lineHeight } =
|
const {
|
||||||
useAppStore(
|
defaultRunScript,
|
||||||
useShallow((state) => ({
|
screenReaderMode,
|
||||||
defaultRunScript: state.terminalState.defaultRunScript,
|
fontFamily,
|
||||||
screenReaderMode: state.terminalState.screenReaderMode,
|
scrollbackLines,
|
||||||
fontFamily: state.terminalState.fontFamily,
|
lineHeight,
|
||||||
scrollbackLines: state.terminalState.scrollbackLines,
|
customBackgroundColor,
|
||||||
lineHeight: state.terminalState.lineHeight,
|
customForegroundColor,
|
||||||
}))
|
} = useAppStore(
|
||||||
);
|
useShallow((state) => ({
|
||||||
|
defaultRunScript: state.terminalState.defaultRunScript,
|
||||||
|
screenReaderMode: state.terminalState.screenReaderMode,
|
||||||
|
fontFamily: state.terminalState.fontFamily,
|
||||||
|
scrollbackLines: state.terminalState.scrollbackLines,
|
||||||
|
lineHeight: state.terminalState.lineHeight,
|
||||||
|
customBackgroundColor: state.terminalState.customBackgroundColor,
|
||||||
|
customForegroundColor: state.terminalState.customForegroundColor,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
// Action setters are stable references, can use individual selectors
|
// Action setters are stable references, can use individual selectors
|
||||||
const setTerminalDefaultRunScript = useAppStore((state) => state.setTerminalDefaultRunScript);
|
const setTerminalDefaultRunScript = useAppStore((state) => state.setTerminalDefaultRunScript);
|
||||||
@@ -675,7 +688,7 @@ export function TerminalPanel({
|
|||||||
if (!mounted || !terminalRef.current) return;
|
if (!mounted || !terminalRef.current) return;
|
||||||
|
|
||||||
// Get terminal theme matching the app theme
|
// Get terminal theme matching the app theme
|
||||||
const terminalTheme = getTerminalTheme(themeRef.current);
|
const baseTheme = getTerminalTheme(themeRef.current);
|
||||||
|
|
||||||
// Get settings from store (read at initialization time)
|
// Get settings from store (read at initialization time)
|
||||||
const terminalSettings = useAppStore.getState().terminalState;
|
const terminalSettings = useAppStore.getState().terminalState;
|
||||||
@@ -683,6 +696,18 @@ export function TerminalPanel({
|
|||||||
const terminalFontFamily = getTerminalFontFamily(terminalSettings.fontFamily);
|
const terminalFontFamily = getTerminalFontFamily(terminalSettings.fontFamily);
|
||||||
const terminalScrollback = terminalSettings.scrollbackLines || 5000;
|
const terminalScrollback = terminalSettings.scrollbackLines || 5000;
|
||||||
const terminalLineHeight = terminalSettings.lineHeight || 1.0;
|
const terminalLineHeight = terminalSettings.lineHeight || 1.0;
|
||||||
|
const customBgColor = terminalSettings.customBackgroundColor;
|
||||||
|
const customFgColor = terminalSettings.customForegroundColor;
|
||||||
|
|
||||||
|
// Apply custom colors if set
|
||||||
|
const terminalTheme =
|
||||||
|
customBgColor || customFgColor
|
||||||
|
? {
|
||||||
|
...baseTheme,
|
||||||
|
...(customBgColor && { background: customBgColor }),
|
||||||
|
...(customFgColor && { foreground: customFgColor }),
|
||||||
|
}
|
||||||
|
: baseTheme;
|
||||||
|
|
||||||
// Create terminal instance with the current global font size and theme
|
// Create terminal instance with the current global font size and theme
|
||||||
const terminal = new Terminal({
|
const terminal = new Terminal({
|
||||||
@@ -1480,15 +1505,23 @@ export function TerminalPanel({
|
|||||||
}
|
}
|
||||||
}, [fontSize, isTerminalReady]);
|
}, [fontSize, isTerminalReady]);
|
||||||
|
|
||||||
// Update terminal theme when app theme changes (including system preference)
|
// Update terminal theme when app theme or custom colors change (including system preference)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (xtermRef.current && isTerminalReady) {
|
if (xtermRef.current && isTerminalReady) {
|
||||||
// Clear any search decorations first to prevent stale color artifacts
|
// Clear any search decorations first to prevent stale color artifacts
|
||||||
searchAddonRef.current?.clearDecorations();
|
searchAddonRef.current?.clearDecorations();
|
||||||
const terminalTheme = getTerminalTheme(resolvedTheme);
|
const baseTheme = getTerminalTheme(resolvedTheme);
|
||||||
|
const terminalTheme =
|
||||||
|
customBackgroundColor || customForegroundColor
|
||||||
|
? {
|
||||||
|
...baseTheme,
|
||||||
|
...(customBackgroundColor && { background: customBackgroundColor }),
|
||||||
|
...(customForegroundColor && { foreground: customForegroundColor }),
|
||||||
|
}
|
||||||
|
: baseTheme;
|
||||||
xtermRef.current.options.theme = terminalTheme;
|
xtermRef.current.options.theme = terminalTheme;
|
||||||
}
|
}
|
||||||
}, [resolvedTheme, isTerminalReady]);
|
}, [resolvedTheme, customBackgroundColor, customForegroundColor, isTerminalReady]);
|
||||||
|
|
||||||
// Handle keyboard shortcuts for zoom (Ctrl+Plus, Ctrl+Minus, Ctrl+0)
|
// Handle keyboard shortcuts for zoom (Ctrl+Plus, Ctrl+Minus, Ctrl+0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1921,6 +1954,10 @@ export function TerminalPanel({
|
|||||||
// Get current terminal theme for xterm styling (resolved for system preference)
|
// Get current terminal theme for xterm styling (resolved for system preference)
|
||||||
const currentTerminalTheme = getTerminalTheme(resolvedTheme);
|
const currentTerminalTheme = getTerminalTheme(resolvedTheme);
|
||||||
|
|
||||||
|
// Apply custom background/foreground colors if set, otherwise use theme defaults
|
||||||
|
const terminalBackgroundColor = customBackgroundColor ?? currentTerminalTheme.background;
|
||||||
|
const terminalForegroundColor = customForegroundColor ?? currentTerminalTheme.foreground;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setRefs}
|
ref={setRefs}
|
||||||
@@ -2071,7 +2108,11 @@ export function TerminalPanel({
|
|||||||
{/* Quick scripts dropdown */}
|
{/* Quick scripts dropdown */}
|
||||||
<TerminalScriptsDropdown
|
<TerminalScriptsDropdown
|
||||||
onRunCommand={sendCommand}
|
onRunCommand={sendCommand}
|
||||||
|
onRunCommandInNewTab={onRunCommandInNewTab}
|
||||||
isConnected={connectionStatus === 'connected'}
|
isConnected={connectionStatus === 'connected'}
|
||||||
|
onOpenSettings={() =>
|
||||||
|
navigate({ to: '/project-settings', search: { section: 'commands-scripts' } })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Settings popover */}
|
{/* Settings popover */}
|
||||||
@@ -2387,7 +2428,7 @@ export function TerminalPanel({
|
|||||||
<div
|
<div
|
||||||
ref={terminalRef}
|
ref={terminalRef}
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
style={{ backgroundColor: currentTerminalTheme.background }}
|
style={{ backgroundColor: terminalBackgroundColor }}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchMove={handleTouchMove}
|
onTouchMove={handleTouchMove}
|
||||||
@@ -2448,8 +2489,8 @@ export function TerminalPanel({
|
|||||||
className="flex-1 overflow-auto"
|
className="flex-1 overflow-auto"
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
backgroundColor: currentTerminalTheme.background,
|
backgroundColor: terminalBackgroundColor,
|
||||||
color: currentTerminalTheme.foreground,
|
color: terminalForegroundColor,
|
||||||
fontFamily: getTerminalFontFamily(fontFamily),
|
fontFamily: getTerminalFontFamily(fontFamily),
|
||||||
fontSize: `${fontSize}px`,
|
fontSize: `${fontSize}px`,
|
||||||
lineHeight: `${lineHeight || 1.0}`,
|
lineHeight: `${lineHeight || 1.0}`,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { ScrollText, Play, Settings2 } from 'lucide-react';
|
import { ScrollText, Play, Settings2, SquareArrowOutUpRight } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -17,6 +17,8 @@ import { DEFAULT_TERMINAL_SCRIPTS } from '../project-settings-view/terminal-scri
|
|||||||
interface TerminalScriptsDropdownProps {
|
interface TerminalScriptsDropdownProps {
|
||||||
/** Callback to send a command + newline to the terminal */
|
/** Callback to send a command + newline to the terminal */
|
||||||
onRunCommand: (command: string) => void;
|
onRunCommand: (command: string) => void;
|
||||||
|
/** Callback to run a command in a new terminal tab */
|
||||||
|
onRunCommandInNewTab?: (command: string) => void;
|
||||||
/** Whether the terminal is connected and ready */
|
/** Whether the terminal is connected and ready */
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
/** Optional callback to navigate to project settings scripts section */
|
/** Optional callback to navigate to project settings scripts section */
|
||||||
@@ -25,11 +27,13 @@ interface TerminalScriptsDropdownProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Dropdown menu in the terminal header bar that provides quick-access
|
* Dropdown menu in the terminal header bar that provides quick-access
|
||||||
* to user-configured project scripts. Clicking a script inserts the
|
* to user-configured project scripts. Each script is a split button:
|
||||||
* command into the terminal and presses Enter.
|
* clicking the left side runs the command in the current terminal,
|
||||||
|
* clicking the "new tab" icon on the right runs it in a new tab.
|
||||||
*/
|
*/
|
||||||
export function TerminalScriptsDropdown({
|
export function TerminalScriptsDropdown({
|
||||||
onRunCommand,
|
onRunCommand,
|
||||||
|
onRunCommandInNewTab,
|
||||||
isConnected,
|
isConnected,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
}: TerminalScriptsDropdownProps) {
|
}: TerminalScriptsDropdownProps) {
|
||||||
@@ -53,6 +57,14 @@ export function TerminalScriptsDropdown({
|
|||||||
[isConnected, onRunCommand]
|
[isConnected, onRunCommand]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleRunScriptInNewTab = useCallback(
|
||||||
|
(command: string) => {
|
||||||
|
if (!isConnected || !onRunCommandInNewTab) return;
|
||||||
|
onRunCommandInNewTab(command);
|
||||||
|
},
|
||||||
|
[isConnected, onRunCommandInNewTab]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -82,7 +94,7 @@ export function TerminalScriptsDropdown({
|
|||||||
key={script.id}
|
key={script.id}
|
||||||
onClick={() => handleRunScript(script.command)}
|
onClick={() => handleRunScript(script.command)}
|
||||||
disabled={!isConnected}
|
disabled={!isConnected}
|
||||||
className="gap-2"
|
className="gap-2 pr-1"
|
||||||
>
|
>
|
||||||
<Play className={cn('h-3.5 w-3.5 shrink-0 text-brand-500')} />
|
<Play className={cn('h-3.5 w-3.5 shrink-0 text-brand-500')} />
|
||||||
<div className="flex flex-col min-w-0 flex-1">
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
@@ -91,17 +103,43 @@ export function TerminalScriptsDropdown({
|
|||||||
{script.command}
|
{script.command}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{onRunCommandInNewTab && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 ml-1 p-1 rounded-sm border-l border-border',
|
||||||
|
'text-muted-foreground hover:text-foreground hover:bg-accent/80',
|
||||||
|
'transition-colors',
|
||||||
|
!isConnected && 'pointer-events-none opacity-50'
|
||||||
|
)}
|
||||||
|
title="Run in new tab"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
handleRunScriptInNewTab(script.command);
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
// Prevent the DropdownMenuItem from handling this pointer event
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onPointerUp={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SquareArrowOutUpRight className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
{onOpenSettings && (
|
<DropdownMenuSeparator />
|
||||||
<>
|
<DropdownMenuItem
|
||||||
<DropdownMenuSeparator />
|
onClick={onOpenSettings}
|
||||||
<DropdownMenuItem onClick={onOpenSettings} className="gap-2 text-muted-foreground">
|
className="gap-2 text-muted-foreground"
|
||||||
<Settings2 className="h-3.5 w-3.5 shrink-0" />
|
disabled={!onOpenSettings}
|
||||||
<span className="text-sm">Configure Scripts...</span>
|
>
|
||||||
</DropdownMenuItem>
|
<Settings2 className="h-3.5 w-3.5 shrink-0" />
|
||||||
</>
|
<span className="text-sm">Edit Commands & Scripts</span>
|
||||||
)}
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -81,12 +81,12 @@ export function getTerminalFontFamily(fontValue: string | undefined): string {
|
|||||||
return fontValue;
|
return fontValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dark theme (default)
|
// Dark theme (default) - true black background with white foreground
|
||||||
const darkTheme: TerminalTheme = {
|
const darkTheme: TerminalTheme = {
|
||||||
background: '#0a0a0a',
|
background: '#000000',
|
||||||
foreground: '#d4d4d4',
|
foreground: '#ffffff',
|
||||||
cursor: '#d4d4d4',
|
cursor: '#ffffff',
|
||||||
cursorAccent: '#0a0a0a',
|
cursorAccent: '#000000',
|
||||||
selectionBackground: '#264f78',
|
selectionBackground: '#264f78',
|
||||||
black: '#1e1e1e',
|
black: '#1e1e1e',
|
||||||
red: '#f44747',
|
red: '#f44747',
|
||||||
@@ -626,4 +626,29 @@ export function getTerminalTheme(theme: ThemeMode): TerminalTheme {
|
|||||||
return terminalThemes[theme] || darkTheme;
|
return terminalThemes[theme] || darkTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get terminal theme with optional custom color overrides
|
||||||
|
* @param theme - The app theme mode
|
||||||
|
* @param customBackgroundColor - Optional custom background color (hex string) to override theme default
|
||||||
|
* @param customForegroundColor - Optional custom foreground/text color (hex string) to override theme default
|
||||||
|
* @returns Terminal theme with custom colors if provided
|
||||||
|
*/
|
||||||
|
export function getTerminalThemeWithOverride(
|
||||||
|
theme: ThemeMode,
|
||||||
|
customBackgroundColor: string | null,
|
||||||
|
customForegroundColor?: string | null
|
||||||
|
): TerminalTheme {
|
||||||
|
const baseTheme = getTerminalTheme(theme);
|
||||||
|
|
||||||
|
if (customBackgroundColor || customForegroundColor) {
|
||||||
|
return {
|
||||||
|
...baseTheme,
|
||||||
|
...(customBackgroundColor && { background: customBackgroundColor }),
|
||||||
|
...(customForegroundColor && { foreground: customForegroundColor }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseTheme;
|
||||||
|
}
|
||||||
|
|
||||||
export default terminalThemes;
|
export default terminalThemes;
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export {
|
|||||||
useValidateIssue,
|
useValidateIssue,
|
||||||
useMarkValidationViewed,
|
useMarkValidationViewed,
|
||||||
useGetValidationStatus,
|
useGetValidationStatus,
|
||||||
|
useResolveReviewThread,
|
||||||
} from './use-github-mutations';
|
} from './use-github-mutations';
|
||||||
|
|
||||||
// Ideation mutations
|
// Ideation mutations
|
||||||
|
|||||||
@@ -135,6 +135,55 @@ export function useMarkValidationViewed(projectPath: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve or unresolve a PR review thread
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @param prNumber - PR number (for cache invalidation)
|
||||||
|
* @returns Mutation for resolving/unresolving a review thread
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const resolveThread = useResolveReviewThread(projectPath, prNumber);
|
||||||
|
* resolveThread.mutate({ threadId: comment.threadId, resolve: true });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useResolveReviewThread(projectPath: string, prNumber: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ threadId, resolve }: { threadId: string; resolve: boolean }) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.github?.resolveReviewThread) {
|
||||||
|
throw new Error('Resolve review thread API not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.github.resolveReviewThread(projectPath, threadId, resolve);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to resolve review thread');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isResolved: result.isResolved ?? resolve };
|
||||||
|
},
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
const action = variables.resolve ? 'resolved' : 'unresolved';
|
||||||
|
toast.success(`Comment ${action}`, {
|
||||||
|
description: `The review thread has been ${action} on GitHub`,
|
||||||
|
});
|
||||||
|
// Invalidate the PR review comments cache to reflect updated resolved status
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.github.prReviewComments(projectPath, prNumber),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error('Failed to update comment', {
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get running validation status
|
* Get running validation status
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export {
|
|||||||
useGitHubValidations,
|
useGitHubValidations,
|
||||||
useGitHubRemote,
|
useGitHubRemote,
|
||||||
useGitHubIssueComments,
|
useGitHubIssueComments,
|
||||||
|
useGitHubPRReviewComments,
|
||||||
} from './use-github';
|
} from './use-github';
|
||||||
|
|
||||||
// Usage
|
// Usage
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
* automatic caching, deduplication, and background refetching.
|
* automatic caching, deduplication, and background refetching.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useEffect, useRef } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { queryKeys } from '@/lib/query-keys';
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { STALE_TIMES } from '@/lib/query-client';
|
import { STALE_TIMES } from '@/lib/query-client';
|
||||||
@@ -151,6 +151,34 @@ export function useFeatures(projectPath: string | undefined) {
|
|||||||
[projectPath]
|
[projectPath]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Subscribe to React Query cache changes for features and sync to localStorage.
|
||||||
|
// This ensures optimistic updates (e.g., status changes to 'verified') are
|
||||||
|
// persisted to localStorage immediately, not just when queryFn runs.
|
||||||
|
// Without this, a page refresh after an optimistic update could show stale
|
||||||
|
// localStorage data where features appear in the wrong column (e.g., verified
|
||||||
|
// features showing up in backlog).
|
||||||
|
const projectPathRef = useRef(projectPath);
|
||||||
|
projectPathRef.current = projectPath;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectPath) return;
|
||||||
|
const targetQueryHash = JSON.stringify(queryKeys.features.all(projectPath));
|
||||||
|
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
|
||||||
|
if (
|
||||||
|
event.type === 'updated' &&
|
||||||
|
event.action.type === 'success' &&
|
||||||
|
event.query.queryHash === targetQueryHash
|
||||||
|
) {
|
||||||
|
const features = event.query.state.data as Feature[] | undefined;
|
||||||
|
if (features && projectPathRef.current) {
|
||||||
|
writePersistedFeatures(projectPathRef.current, features);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
}, [projectPath, queryClient]);
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.features.all(projectPath ?? ''),
|
queryKey: queryKeys.features.all(projectPath ?? ''),
|
||||||
queryFn: async (): Promise<Feature[]> => {
|
queryFn: async (): Promise<Feature[]> => {
|
||||||
@@ -166,7 +194,11 @@ export function useFeatures(projectPath: string | undefined) {
|
|||||||
},
|
},
|
||||||
enabled: !!projectPath,
|
enabled: !!projectPath,
|
||||||
initialData: () => persisted?.features,
|
initialData: () => persisted?.features,
|
||||||
initialDataUpdatedAt: () => persisted?.timestamp,
|
// Always treat localStorage cache as stale so React Query immediately
|
||||||
|
// fetches fresh data from the server on page load. This prevents stale
|
||||||
|
// feature statuses (e.g., 'verified' features appearing in backlog)
|
||||||
|
// while still showing cached data instantly for a fast initial render.
|
||||||
|
initialDataUpdatedAt: 0,
|
||||||
staleTime: STALE_TIMES.FEATURES,
|
staleTime: STALE_TIMES.FEATURES,
|
||||||
refetchInterval: createSmartPollingInterval(FEATURES_POLLING_INTERVAL),
|
refetchInterval: createSmartPollingInterval(FEATURES_POLLING_INTERVAL),
|
||||||
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
|
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
|
|||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { queryKeys } from '@/lib/query-keys';
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { STALE_TIMES } from '@/lib/query-client';
|
import { STALE_TIMES } from '@/lib/query-client';
|
||||||
import type { GitHubIssue, GitHubPR, GitHubComment, StoredValidation } from '@/lib/electron';
|
import type {
|
||||||
|
GitHubIssue,
|
||||||
|
GitHubPR,
|
||||||
|
GitHubComment,
|
||||||
|
PRReviewComment,
|
||||||
|
StoredValidation,
|
||||||
|
} from '@/lib/electron';
|
||||||
|
|
||||||
interface GitHubIssuesResult {
|
interface GitHubIssuesResult {
|
||||||
openIssues: GitHubIssue[];
|
openIssues: GitHubIssue[];
|
||||||
@@ -197,3 +203,45 @@ export function useGitHubIssueComments(
|
|||||||
staleTime: STALE_TIMES.GITHUB,
|
staleTime: STALE_TIMES.GITHUB,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch review comments for a GitHub PR
|
||||||
|
*
|
||||||
|
* Fetches both regular PR comments and inline code review comments
|
||||||
|
* with file path and line context for each.
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @param prNumber - PR number
|
||||||
|
* @returns Query result with review comments
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { data, isLoading } = useGitHubPRReviewComments(projectPath, prNumber);
|
||||||
|
* const comments = data?.comments ?? [];
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useGitHubPRReviewComments(
|
||||||
|
projectPath: string | undefined,
|
||||||
|
prNumber: number | undefined
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.github.prReviewComments(projectPath ?? '', prNumber ?? 0),
|
||||||
|
queryFn: async (): Promise<{ comments: PRReviewComment[]; totalCount: number }> => {
|
||||||
|
if (!projectPath || !prNumber) throw new Error('Missing project path or PR number');
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.github) {
|
||||||
|
throw new Error('GitHub API not available');
|
||||||
|
}
|
||||||
|
const result = await api.github.getPRReviewComments(projectPath, prNumber);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to fetch PR review comments');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
comments: (result.comments ?? []) as PRReviewComment[],
|
||||||
|
totalCount: result.totalCount ?? 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: !!projectPath && !!prNumber,
|
||||||
|
staleTime: STALE_TIMES.GITHUB,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -108,10 +108,17 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
|||||||
// Derive branchName from worktree:
|
// Derive branchName from worktree:
|
||||||
// If worktree is provided, use its branch name (even for main worktree, as it might be on a feature branch)
|
// If worktree is provided, use its branch name (even for main worktree, as it might be on a feature branch)
|
||||||
// If not provided, default to null (main worktree default)
|
// If not provided, default to null (main worktree default)
|
||||||
|
// IMPORTANT: Depend on primitive values (isMain, branch) instead of the worktree object
|
||||||
|
// reference to avoid re-computing when the parent passes a new object with the same values.
|
||||||
|
// This prevents a cascading re-render loop: new worktree ref → new branchName useMemo →
|
||||||
|
// new refreshStatus callback → effect re-fires → store update → re-render → React error #185.
|
||||||
|
const worktreeIsMain = worktree?.isMain;
|
||||||
|
const worktreeBranch = worktree?.branch;
|
||||||
|
const hasWorktree = worktree !== undefined;
|
||||||
const branchName = useMemo(() => {
|
const branchName = useMemo(() => {
|
||||||
if (!worktree) return null;
|
if (!hasWorktree) return null;
|
||||||
return worktree.isMain ? null : worktree.branch || null;
|
return worktreeIsMain ? null : worktreeBranch || null;
|
||||||
}, [worktree]);
|
}, [hasWorktree, worktreeIsMain, worktreeBranch]);
|
||||||
|
|
||||||
// Helper to look up project ID from path
|
// Helper to look up project ID from path
|
||||||
const getProjectIdFromPath = useCallback(
|
const getProjectIdFromPath = useCallback(
|
||||||
@@ -245,10 +252,19 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
|||||||
}
|
}
|
||||||
}, [branchName, currentProject, setAutoModeRunning]);
|
}, [branchName, currentProject, setAutoModeRunning]);
|
||||||
|
|
||||||
// On mount, query backend for current auto loop status and sync UI state.
|
// On mount (and when refreshStatus identity changes, e.g. project switch),
|
||||||
|
// query backend for current auto loop status and sync UI state.
|
||||||
// This handles cases where the backend is still running after a page refresh.
|
// This handles cases where the backend is still running after a page refresh.
|
||||||
|
//
|
||||||
|
// IMPORTANT: Debounce with a short delay to prevent a synchronous cascade
|
||||||
|
// during project switches. Without this, the sequence is:
|
||||||
|
// refreshStatus() → setAutoModeRunning() → store update → re-render →
|
||||||
|
// other effects fire → more store updates → React error #185.
|
||||||
|
// The 150ms delay lets React settle the initial mount renders before we
|
||||||
|
// trigger additional store mutations from the API response.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshStatus();
|
const timer = setTimeout(() => void refreshStatus(), 150);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
}, [refreshStatus]);
|
}, [refreshStatus]);
|
||||||
|
|
||||||
// Periodic polling fallback when WebSocket events are stale.
|
// Periodic polling fallback when WebSocket events are stale.
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export function useProjectSettingsLoader() {
|
|||||||
(state) => state.setAutoDismissInitScriptIndicator
|
(state) => state.setAutoDismissInitScriptIndicator
|
||||||
);
|
);
|
||||||
const setWorktreeCopyFiles = useAppStore((state) => state.setWorktreeCopyFiles);
|
const setWorktreeCopyFiles = useAppStore((state) => state.setWorktreeCopyFiles);
|
||||||
const setCurrentProject = useAppStore((state) => state.setCurrentProject);
|
|
||||||
|
|
||||||
const appliedProjectRef = useRef<{ path: string; dataUpdatedAt: number } | null>(null);
|
const appliedProjectRef = useRef<{ path: string; dataUpdatedAt: number } | null>(null);
|
||||||
|
|
||||||
@@ -116,30 +115,39 @@ export function useProjectSettingsLoader() {
|
|||||||
|
|
||||||
// Check if we need to update the project
|
// Check if we need to update the project
|
||||||
const storeState = useAppStore.getState();
|
const storeState = useAppStore.getState();
|
||||||
const updatedProject = storeState.currentProject;
|
// snapshotProject is the store's current value at this point in time;
|
||||||
if (updatedProject && updatedProject.path === projectPath) {
|
// it is distinct from updatedProjectData which is the new value we build below.
|
||||||
|
const snapshotProject = storeState.currentProject;
|
||||||
|
if (snapshotProject && snapshotProject.path === projectPath) {
|
||||||
const needsUpdate =
|
const needsUpdate =
|
||||||
(activeClaudeApiProfileId !== undefined &&
|
(activeClaudeApiProfileId !== undefined &&
|
||||||
updatedProject.activeClaudeApiProfileId !== activeClaudeApiProfileId) ||
|
snapshotProject.activeClaudeApiProfileId !== activeClaudeApiProfileId) ||
|
||||||
(phaseModelOverrides !== undefined &&
|
(phaseModelOverrides !== undefined &&
|
||||||
JSON.stringify(updatedProject.phaseModelOverrides) !==
|
JSON.stringify(snapshotProject.phaseModelOverrides) !==
|
||||||
JSON.stringify(phaseModelOverrides));
|
JSON.stringify(phaseModelOverrides));
|
||||||
|
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
const updatedProjectData = {
|
const updatedProjectData = {
|
||||||
...updatedProject,
|
...snapshotProject,
|
||||||
...(activeClaudeApiProfileId !== undefined && { activeClaudeApiProfileId }),
|
...(activeClaudeApiProfileId !== undefined && { activeClaudeApiProfileId }),
|
||||||
...(phaseModelOverrides !== undefined && { phaseModelOverrides }),
|
...(phaseModelOverrides !== undefined && { phaseModelOverrides }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update currentProject
|
// Update both currentProject and projects array in a single setState call
|
||||||
setCurrentProject(updatedProjectData);
|
// to avoid two separate re-renders that can cascade during initialization
|
||||||
|
// and contribute to React error #185 (maximum update depth exceeded).
|
||||||
// Also update the project in the projects array to keep them in sync
|
|
||||||
const updatedProjects = storeState.projects.map((p) =>
|
const updatedProjects = storeState.projects.map((p) =>
|
||||||
p.id === updatedProject.id ? updatedProjectData : p
|
p.id === snapshotProject.id ? updatedProjectData : p
|
||||||
);
|
);
|
||||||
useAppStore.setState({ projects: updatedProjects });
|
// NOTE: Intentionally bypasses setCurrentProject() to avoid a second
|
||||||
|
// render cycle that can trigger React error #185 (maximum update depth
|
||||||
|
// exceeded). This means persistEffectiveThemeForProject() is skipped,
|
||||||
|
// which is safe because only activeClaudeApiProfileId and
|
||||||
|
// phaseModelOverrides are mutated here — not the project theme.
|
||||||
|
useAppStore.setState({
|
||||||
|
currentProject: updatedProjectData,
|
||||||
|
projects: updatedProjects,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -159,6 +167,5 @@ export function useProjectSettingsLoader() {
|
|||||||
setDefaultDeleteBranch,
|
setDefaultDeleteBranch,
|
||||||
setAutoDismissInitScriptIndicator,
|
setAutoDismissInitScriptIndicator,
|
||||||
setWorktreeCopyFiles,
|
setWorktreeCopyFiles,
|
||||||
setCurrentProject,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,6 +213,12 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
|||||||
// Claude Compatible Providers (new system)
|
// Claude Compatible Providers (new system)
|
||||||
claudeCompatibleProviders:
|
claudeCompatibleProviders:
|
||||||
(state.claudeCompatibleProviders as GlobalSettings['claudeCompatibleProviders']) ?? [],
|
(state.claudeCompatibleProviders as GlobalSettings['claudeCompatibleProviders']) ?? [],
|
||||||
|
// Settings that were previously missing from migration (added for sync parity)
|
||||||
|
enableAiCommitMessages: state.enableAiCommitMessages as boolean | undefined,
|
||||||
|
enableSkills: state.enableSkills as boolean | undefined,
|
||||||
|
skillsSources: state.skillsSources as GlobalSettings['skillsSources'] | undefined,
|
||||||
|
enableSubagents: state.enableSubagents as boolean | undefined,
|
||||||
|
subagentsSources: state.subagentsSources as GlobalSettings['subagentsSources'] | undefined,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to parse localStorage settings:', error);
|
logger.error('Failed to parse localStorage settings:', error);
|
||||||
@@ -357,6 +363,27 @@ export function mergeSettings(
|
|||||||
merged.claudeCompatibleProviders = localSettings.claudeCompatibleProviders;
|
merged.claudeCompatibleProviders = localSettings.claudeCompatibleProviders;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preserve new settings fields from localStorage if server has defaults
|
||||||
|
// Use nullish coalescing to accept stored falsy values (e.g. false)
|
||||||
|
if (localSettings.enableAiCommitMessages != null && merged.enableAiCommitMessages == null) {
|
||||||
|
merged.enableAiCommitMessages = localSettings.enableAiCommitMessages;
|
||||||
|
}
|
||||||
|
if (localSettings.enableSkills != null && merged.enableSkills == null) {
|
||||||
|
merged.enableSkills = localSettings.enableSkills;
|
||||||
|
}
|
||||||
|
if (localSettings.skillsSources && (!merged.skillsSources || merged.skillsSources.length === 0)) {
|
||||||
|
merged.skillsSources = localSettings.skillsSources;
|
||||||
|
}
|
||||||
|
if (localSettings.enableSubagents != null && merged.enableSubagents == null) {
|
||||||
|
merged.enableSubagents = localSettings.enableSubagents;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
localSettings.subagentsSources &&
|
||||||
|
(!merged.subagentsSources || merged.subagentsSources.length === 0)
|
||||||
|
) {
|
||||||
|
merged.subagentsSources = localSettings.subagentsSources;
|
||||||
|
}
|
||||||
|
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,7 +755,12 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
|||||||
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
|
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
|
||||||
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
||||||
disabledProviders: settings.disabledProviders ?? [],
|
disabledProviders: settings.disabledProviders ?? [],
|
||||||
autoLoadClaudeMd: settings.autoLoadClaudeMd ?? false,
|
enableAiCommitMessages: settings.enableAiCommitMessages ?? true,
|
||||||
|
enableSkills: settings.enableSkills ?? true,
|
||||||
|
skillsSources: settings.skillsSources ?? ['user', 'project'],
|
||||||
|
enableSubagents: settings.enableSubagents ?? true,
|
||||||
|
subagentsSources: settings.subagentsSources ?? ['user', 'project'],
|
||||||
|
autoLoadClaudeMd: settings.autoLoadClaudeMd ?? true,
|
||||||
skipSandboxWarning: settings.skipSandboxWarning ?? false,
|
skipSandboxWarning: settings.skipSandboxWarning ?? false,
|
||||||
codexAutoLoadAgents: settings.codexAutoLoadAgents ?? false,
|
codexAutoLoadAgents: settings.codexAutoLoadAgents ?? false,
|
||||||
codexSandboxMode: settings.codexSandboxMode ?? 'workspace-write',
|
codexSandboxMode: settings.codexSandboxMode ?? 'workspace-write',
|
||||||
@@ -763,11 +795,25 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
|||||||
editorFontFamily: settings.editorFontFamily ?? 'default',
|
editorFontFamily: settings.editorFontFamily ?? 'default',
|
||||||
editorAutoSave: settings.editorAutoSave ?? false,
|
editorAutoSave: settings.editorAutoSave ?? false,
|
||||||
editorAutoSaveDelay: settings.editorAutoSaveDelay ?? 1000,
|
editorAutoSaveDelay: settings.editorAutoSaveDelay ?? 1000,
|
||||||
// Terminal font (nested in terminalState)
|
// Terminal settings (nested in terminalState)
|
||||||
...(settings.terminalFontFamily && {
|
...((settings.terminalFontFamily ||
|
||||||
|
(settings as unknown as Record<string, unknown>).terminalCustomBackgroundColor !==
|
||||||
|
undefined ||
|
||||||
|
(settings as unknown as Record<string, unknown>).terminalCustomForegroundColor !==
|
||||||
|
undefined) && {
|
||||||
terminalState: {
|
terminalState: {
|
||||||
...current.terminalState,
|
...current.terminalState,
|
||||||
fontFamily: settings.terminalFontFamily,
|
...(settings.terminalFontFamily && { fontFamily: settings.terminalFontFamily }),
|
||||||
|
...((settings as unknown as Record<string, unknown>).terminalCustomBackgroundColor !==
|
||||||
|
undefined && {
|
||||||
|
customBackgroundColor: (settings as unknown as Record<string, unknown>)
|
||||||
|
.terminalCustomBackgroundColor as string | null,
|
||||||
|
}),
|
||||||
|
...((settings as unknown as Record<string, unknown>).terminalCustomForegroundColor !==
|
||||||
|
undefined && {
|
||||||
|
customForegroundColor: (settings as unknown as Record<string, unknown>)
|
||||||
|
.terminalCustomForegroundColor as string | null,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -827,6 +873,11 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
|||||||
defaultReasoningEffort: state.defaultReasoningEffort,
|
defaultReasoningEffort: state.defaultReasoningEffort,
|
||||||
enabledDynamicModelIds: state.enabledDynamicModelIds,
|
enabledDynamicModelIds: state.enabledDynamicModelIds,
|
||||||
disabledProviders: state.disabledProviders,
|
disabledProviders: state.disabledProviders,
|
||||||
|
enableAiCommitMessages: state.enableAiCommitMessages,
|
||||||
|
enableSkills: state.enableSkills,
|
||||||
|
skillsSources: state.skillsSources,
|
||||||
|
enableSubagents: state.enableSubagents,
|
||||||
|
subagentsSources: state.subagentsSources,
|
||||||
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
||||||
skipSandboxWarning: state.skipSandboxWarning,
|
skipSandboxWarning: state.skipSandboxWarning,
|
||||||
codexAutoLoadAgents: state.codexAutoLoadAgents,
|
codexAutoLoadAgents: state.codexAutoLoadAgents,
|
||||||
@@ -858,6 +909,8 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
|||||||
editorAutoSave: state.editorAutoSave,
|
editorAutoSave: state.editorAutoSave,
|
||||||
editorAutoSaveDelay: state.editorAutoSaveDelay,
|
editorAutoSaveDelay: state.editorAutoSaveDelay,
|
||||||
terminalFontFamily: state.terminalState.fontFamily,
|
terminalFontFamily: state.terminalState.fontFamily,
|
||||||
|
terminalCustomBackgroundColor: state.terminalState.customBackgroundColor,
|
||||||
|
terminalCustomForegroundColor: state.terminalState.customForegroundColor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
|||||||
'fontFamilyMono',
|
'fontFamilyMono',
|
||||||
'terminalFontFamily', // Maps to terminalState.fontFamily
|
'terminalFontFamily', // Maps to terminalState.fontFamily
|
||||||
'openTerminalMode', // Maps to terminalState.openTerminalMode
|
'openTerminalMode', // Maps to terminalState.openTerminalMode
|
||||||
|
'terminalCustomBackgroundColor', // Maps to terminalState.customBackgroundColor
|
||||||
|
'terminalCustomForegroundColor', // Maps to terminalState.customForegroundColor
|
||||||
'sidebarOpen',
|
'sidebarOpen',
|
||||||
'sidebarStyle',
|
'sidebarStyle',
|
||||||
'collapsedNavSections',
|
'collapsedNavSections',
|
||||||
@@ -90,8 +92,14 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
|||||||
'editorAutoSave',
|
'editorAutoSave',
|
||||||
'editorAutoSaveDelay',
|
'editorAutoSaveDelay',
|
||||||
'defaultTerminalId',
|
'defaultTerminalId',
|
||||||
|
'enableAiCommitMessages',
|
||||||
|
'enableSkills',
|
||||||
|
'skillsSources',
|
||||||
|
'enableSubagents',
|
||||||
|
'subagentsSources',
|
||||||
'promptCustomization',
|
'promptCustomization',
|
||||||
'eventHooks',
|
'eventHooks',
|
||||||
|
'claudeCompatibleProviders',
|
||||||
'claudeApiProfiles',
|
'claudeApiProfiles',
|
||||||
'activeClaudeApiProfileId',
|
'activeClaudeApiProfileId',
|
||||||
'projects',
|
'projects',
|
||||||
@@ -100,6 +108,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
|||||||
'projectHistory',
|
'projectHistory',
|
||||||
'projectHistoryIndex',
|
'projectHistoryIndex',
|
||||||
'lastSelectedSessionByProject',
|
'lastSelectedSessionByProject',
|
||||||
|
'currentWorktreeByProject',
|
||||||
// Codex CLI Settings
|
// Codex CLI Settings
|
||||||
'codexAutoLoadAgents',
|
'codexAutoLoadAgents',
|
||||||
'codexSandboxMode',
|
'codexSandboxMode',
|
||||||
@@ -108,6 +117,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
|||||||
'codexEnableImages',
|
'codexEnableImages',
|
||||||
'codexAdditionalDirs',
|
'codexAdditionalDirs',
|
||||||
'codexThreadId',
|
'codexThreadId',
|
||||||
|
// Max Turns Setting
|
||||||
|
'defaultMaxTurns',
|
||||||
// UI State (previously in localStorage)
|
// UI State (previously in localStorage)
|
||||||
'worktreePanelCollapsed',
|
'worktreePanelCollapsed',
|
||||||
'lastProjectDir',
|
'lastProjectDir',
|
||||||
@@ -142,6 +153,12 @@ function getSettingsFieldValue(
|
|||||||
if (field === 'openTerminalMode') {
|
if (field === 'openTerminalMode') {
|
||||||
return appState.terminalState.openTerminalMode;
|
return appState.terminalState.openTerminalMode;
|
||||||
}
|
}
|
||||||
|
if (field === 'terminalCustomBackgroundColor') {
|
||||||
|
return appState.terminalState.customBackgroundColor;
|
||||||
|
}
|
||||||
|
if (field === 'terminalCustomForegroundColor') {
|
||||||
|
return appState.terminalState.customForegroundColor;
|
||||||
|
}
|
||||||
if (field === 'autoModeByWorktree') {
|
if (field === 'autoModeByWorktree') {
|
||||||
// Only persist settings (maxConcurrency), not runtime state (isRunning, runningTasks)
|
// Only persist settings (maxConcurrency), not runtime state (isRunning, runningTasks)
|
||||||
const autoModeByWorktree = appState.autoModeByWorktree;
|
const autoModeByWorktree = appState.autoModeByWorktree;
|
||||||
@@ -185,6 +202,16 @@ function hasSettingsFieldChanged(
|
|||||||
if (field === 'openTerminalMode') {
|
if (field === 'openTerminalMode') {
|
||||||
return newState.terminalState.openTerminalMode !== prevState.terminalState.openTerminalMode;
|
return newState.terminalState.openTerminalMode !== prevState.terminalState.openTerminalMode;
|
||||||
}
|
}
|
||||||
|
if (field === 'terminalCustomBackgroundColor') {
|
||||||
|
return (
|
||||||
|
newState.terminalState.customBackgroundColor !== prevState.terminalState.customBackgroundColor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (field === 'terminalCustomForegroundColor') {
|
||||||
|
return (
|
||||||
|
newState.terminalState.customForegroundColor !== prevState.terminalState.customForegroundColor
|
||||||
|
);
|
||||||
|
}
|
||||||
const key = field as keyof typeof newState;
|
const key = field as keyof typeof newState;
|
||||||
return newState[key] !== prevState[key];
|
return newState[key] !== prevState[key];
|
||||||
}
|
}
|
||||||
@@ -730,6 +757,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
? migratePhaseModelEntry(serverSettings.defaultFeatureModel)
|
? migratePhaseModelEntry(serverSettings.defaultFeatureModel)
|
||||||
: { model: 'claude-opus' },
|
: { model: 'claude-opus' },
|
||||||
muteDoneSound: serverSettings.muteDoneSound,
|
muteDoneSound: serverSettings.muteDoneSound,
|
||||||
|
defaultMaxTurns: serverSettings.defaultMaxTurns ?? 1000,
|
||||||
disableSplashScreen: serverSettings.disableSplashScreen ?? false,
|
disableSplashScreen: serverSettings.disableSplashScreen ?? false,
|
||||||
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
|
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
|
||||||
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
|
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
|
||||||
@@ -746,7 +774,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
copilotDefaultModel: sanitizedCopilotDefaultModel,
|
copilotDefaultModel: sanitizedCopilotDefaultModel,
|
||||||
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
||||||
disabledProviders: serverSettings.disabledProviders ?? [],
|
disabledProviders: serverSettings.disabledProviders ?? [],
|
||||||
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,
|
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? true,
|
||||||
keyboardShortcuts: {
|
keyboardShortcuts: {
|
||||||
...currentAppState.keyboardShortcuts,
|
...currentAppState.keyboardShortcuts,
|
||||||
...(serverSettings.keyboardShortcuts as unknown as Partial<
|
...(serverSettings.keyboardShortcuts as unknown as Partial<
|
||||||
@@ -768,6 +796,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
projectHistory: serverSettings.projectHistory,
|
projectHistory: serverSettings.projectHistory,
|
||||||
projectHistoryIndex: serverSettings.projectHistoryIndex,
|
projectHistoryIndex: serverSettings.projectHistoryIndex,
|
||||||
lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject,
|
lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject,
|
||||||
|
currentWorktreeByProject:
|
||||||
|
serverSettings.currentWorktreeByProject ?? currentAppState.currentWorktreeByProject,
|
||||||
// UI State (previously in localStorage)
|
// UI State (previously in localStorage)
|
||||||
worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false,
|
worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false,
|
||||||
lastProjectDir: serverSettings.lastProjectDir ?? '',
|
lastProjectDir: serverSettings.lastProjectDir ?? '',
|
||||||
@@ -783,7 +813,12 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
codexAdditionalDirs: serverSettings.codexAdditionalDirs ?? [],
|
codexAdditionalDirs: serverSettings.codexAdditionalDirs ?? [],
|
||||||
codexThreadId: serverSettings.codexThreadId,
|
codexThreadId: serverSettings.codexThreadId,
|
||||||
// Terminal settings (nested in terminalState)
|
// Terminal settings (nested in terminalState)
|
||||||
...((serverSettings.terminalFontFamily || serverSettings.openTerminalMode) && {
|
...((serverSettings.terminalFontFamily ||
|
||||||
|
serverSettings.openTerminalMode ||
|
||||||
|
(serverSettings as unknown as Record<string, unknown>).terminalCustomBackgroundColor !==
|
||||||
|
undefined ||
|
||||||
|
(serverSettings as unknown as Record<string, unknown>).terminalCustomForegroundColor !==
|
||||||
|
undefined) && {
|
||||||
terminalState: {
|
terminalState: {
|
||||||
...currentAppState.terminalState,
|
...currentAppState.terminalState,
|
||||||
...(serverSettings.terminalFontFamily && {
|
...(serverSettings.terminalFontFamily && {
|
||||||
@@ -792,6 +827,16 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
...(serverSettings.openTerminalMode && {
|
...(serverSettings.openTerminalMode && {
|
||||||
openTerminalMode: serverSettings.openTerminalMode,
|
openTerminalMode: serverSettings.openTerminalMode,
|
||||||
}),
|
}),
|
||||||
|
...((serverSettings as unknown as Record<string, unknown>)
|
||||||
|
.terminalCustomBackgroundColor !== undefined && {
|
||||||
|
customBackgroundColor: (serverSettings as unknown as Record<string, unknown>)
|
||||||
|
.terminalCustomBackgroundColor as string | null,
|
||||||
|
}),
|
||||||
|
...((serverSettings as unknown as Record<string, unknown>)
|
||||||
|
.terminalCustomForegroundColor !== undefined && {
|
||||||
|
customForegroundColor: (serverSettings as unknown as Record<string, unknown>)
|
||||||
|
.terminalCustomForegroundColor as string | null,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -313,6 +313,36 @@ export interface GitHubRemoteStatus {
|
|||||||
repo: string | null;
|
repo: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A review comment on a pull request (inline code comment or general PR comment) */
|
||||||
|
export interface PRReviewComment {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
body: string;
|
||||||
|
/** File path for inline review comments */
|
||||||
|
path?: string;
|
||||||
|
/** Line number for inline review comments */
|
||||||
|
line?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
/** Whether this is an inline code review comment (vs general PR comment) */
|
||||||
|
isReviewComment: boolean;
|
||||||
|
/** Whether this comment is outdated (code has changed since) */
|
||||||
|
isOutdated?: boolean;
|
||||||
|
/** Whether the review thread containing this comment has been resolved */
|
||||||
|
isResolved?: boolean;
|
||||||
|
/** The GraphQL node ID of the review thread (used for resolve/unresolve mutations) */
|
||||||
|
threadId?: string;
|
||||||
|
/** The diff hunk context for the comment */
|
||||||
|
diffHunk?: string;
|
||||||
|
/** The side of the diff (LEFT or RIGHT) */
|
||||||
|
side?: string;
|
||||||
|
/** The commit ID the comment was made on */
|
||||||
|
commitId?: string;
|
||||||
|
/** Whether the comment author is a bot/app account */
|
||||||
|
isBot?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitHubAPI {
|
export interface GitHubAPI {
|
||||||
checkRemote: (projectPath: string) => Promise<{
|
checkRemote: (projectPath: string) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -389,6 +419,26 @@ export interface GitHubAPI {
|
|||||||
endCursor?: string;
|
endCursor?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
/** Fetch review comments for a specific pull request */
|
||||||
|
getPRReviewComments: (
|
||||||
|
projectPath: string,
|
||||||
|
prNumber: number
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
comments?: PRReviewComment[];
|
||||||
|
totalCount?: number;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
/** Resolve or unresolve a PR review thread */
|
||||||
|
resolveReviewThread: (
|
||||||
|
projectPath: string,
|
||||||
|
threadId: string,
|
||||||
|
resolve: boolean
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
isResolved?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spec Regeneration types
|
// Spec Regeneration types
|
||||||
@@ -3980,6 +4030,21 @@ function createMockGitHubAPI(): GitHubAPI {
|
|||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
getPRReviewComments: async (projectPath: string, prNumber: number) => {
|
||||||
|
console.log('[Mock] Getting PR review comments:', { projectPath, prNumber });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
comments: [],
|
||||||
|
totalCount: 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
resolveReviewThread: async (projectPath: string, threadId: string, resolve: boolean) => {
|
||||||
|
console.log('[Mock] Resolving review thread:', { projectPath, threadId, resolve });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
isResolved: resolve,
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2474,6 +2474,10 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.subscribeToEvent('issue-validation:event', callback as EventCallback),
|
this.subscribeToEvent('issue-validation:event', callback as EventCallback),
|
||||||
getIssueComments: (projectPath: string, issueNumber: number, cursor?: string) =>
|
getIssueComments: (projectPath: string, issueNumber: number, cursor?: string) =>
|
||||||
this.post('/api/github/issue-comments', { projectPath, issueNumber, cursor }),
|
this.post('/api/github/issue-comments', { projectPath, issueNumber, cursor }),
|
||||||
|
getPRReviewComments: (projectPath: string, prNumber: number) =>
|
||||||
|
this.post('/api/github/pr-review-comments', { projectPath, prNumber }),
|
||||||
|
resolveReviewThread: (projectPath: string, threadId: string, resolve: boolean) =>
|
||||||
|
this.post('/api/github/resolve-pr-comment', { projectPath, threadId, resolve }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Workspace API
|
// Workspace API
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export const queryKeys = {
|
|||||||
/** Issue comments */
|
/** Issue comments */
|
||||||
issueComments: (projectPath: string, issueNumber: number) =>
|
issueComments: (projectPath: string, issueNumber: number) =>
|
||||||
['github', 'issues', projectPath, issueNumber, 'comments'] as const,
|
['github', 'issues', projectPath, issueNumber, 'comments'] as const,
|
||||||
|
/** PR review comments */
|
||||||
|
prReviewComments: (projectPath: string, prNumber: number) =>
|
||||||
|
['github', 'prs', projectPath, prNumber, 'review-comments'] as const,
|
||||||
/** Remote info */
|
/** Remote info */
|
||||||
remote: (projectPath: string) => ['github', 'remote', projectPath] as const,
|
remote: (projectPath: string) => ['github', 'remote', projectPath] as const,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from './app';
|
import App from './app';
|
||||||
|
import { AppErrorBoundary } from './components/ui/app-error-boundary';
|
||||||
import { isMobileDevice, isPwaStandalone } from './lib/mobile-detect';
|
import { isMobileDevice, isPwaStandalone } from './lib/mobile-detect';
|
||||||
|
|
||||||
// Defensive fallback: index.html's inline script already applies data-pwa="standalone"
|
// Defensive fallback: index.html's inline script already applies data-pwa="standalone"
|
||||||
@@ -250,8 +251,12 @@ function warmAssetCache(registration: ServiceWorkerRegistration): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render the app - prioritize First Contentful Paint
|
// Render the app - prioritize First Contentful Paint
|
||||||
|
// AppErrorBoundary catches uncaught React errors and shows a friendly error screen
|
||||||
|
// instead of TanStack Router's default "Something went wrong!" overlay.
|
||||||
createRoot(document.getElementById('app')!).render(
|
createRoot(document.getElementById('app')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<AppErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</AppErrorBoundary>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -182,25 +182,39 @@ function selectAutoOpenProject(
|
|||||||
|
|
||||||
function RootLayoutContent() {
|
function RootLayoutContent() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const {
|
|
||||||
setIpcConnected,
|
// IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent
|
||||||
projects,
|
// re-rendering on every store mutation. The bare call subscribes to the ENTIRE store,
|
||||||
currentProject,
|
// which during initialization causes cascading re-renders as multiple effects write
|
||||||
projectHistory,
|
// to the store (settings hydration, project settings, auto-open, etc.). With enough
|
||||||
upsertAndSetCurrentProject,
|
// rapid mutations, React hits the maximum update depth limit (error #185).
|
||||||
getEffectiveTheme,
|
//
|
||||||
getEffectiveFontSans,
|
// Each selector only triggers a re-render when its specific slice of state changes.
|
||||||
getEffectiveFontMono,
|
const projects = useAppStore((s) => s.projects);
|
||||||
// Subscribe to theme and font state to trigger re-renders when they change
|
const currentProject = useAppStore((s) => s.currentProject);
|
||||||
theme,
|
const projectHistory = useAppStore((s) => s.projectHistory);
|
||||||
fontFamilySans,
|
const sidebarStyle = useAppStore((s) => s.sidebarStyle);
|
||||||
fontFamilyMono,
|
const skipSandboxWarning = useAppStore((s) => s.skipSandboxWarning);
|
||||||
sidebarStyle,
|
// Subscribe to theme and font state to trigger re-renders when they change
|
||||||
skipSandboxWarning,
|
const theme = useAppStore((s) => s.theme);
|
||||||
setSkipSandboxWarning,
|
const fontFamilySans = useAppStore((s) => s.fontFamilySans);
|
||||||
fetchCodexModels,
|
const fontFamilyMono = useAppStore((s) => s.fontFamilyMono);
|
||||||
} = useAppStore();
|
// Subscribe to previewTheme so that getEffectiveTheme() re-renders when
|
||||||
const { setupComplete, codexCliStatus } = useSetupStore();
|
// hover previews change the document theme. Without this, the selector
|
||||||
|
// for getEffectiveTheme (a stable function ref) won't trigger re-renders.
|
||||||
|
const previewTheme = useAppStore((s) => s.previewTheme);
|
||||||
|
void previewTheme; // Used only for subscription
|
||||||
|
// Actions (stable references from Zustand - never change between renders)
|
||||||
|
const setIpcConnected = useAppStore((s) => s.setIpcConnected);
|
||||||
|
const upsertAndSetCurrentProject = useAppStore((s) => s.upsertAndSetCurrentProject);
|
||||||
|
const getEffectiveTheme = useAppStore((s) => s.getEffectiveTheme);
|
||||||
|
const getEffectiveFontSans = useAppStore((s) => s.getEffectiveFontSans);
|
||||||
|
const getEffectiveFontMono = useAppStore((s) => s.getEffectiveFontMono);
|
||||||
|
const setSkipSandboxWarning = useAppStore((s) => s.setSkipSandboxWarning);
|
||||||
|
const fetchCodexModels = useAppStore((s) => s.fetchCodexModels);
|
||||||
|
|
||||||
|
const setupComplete = useSetupStore((s) => s.setupComplete);
|
||||||
|
const codexCliStatus = useSetupStore((s) => s.codexCliStatus);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
|
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
|
||||||
@@ -400,19 +414,16 @@ function RootLayoutContent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleLoggedOut = () => {
|
const handleLoggedOut = () => {
|
||||||
logger.warn('automaker:logged-out event received!');
|
logger.warn('automaker:logged-out event received!');
|
||||||
|
// Only update auth state — the centralized routing effect will handle
|
||||||
|
// navigation to /logged-out when it detects isAuthenticated is false
|
||||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
|
|
||||||
if (location.pathname !== '/logged-out') {
|
|
||||||
logger.warn('Navigating to /logged-out due to logged-out event');
|
|
||||||
navigate({ to: '/logged-out' });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('automaker:logged-out', handleLoggedOut);
|
window.addEventListener('automaker:logged-out', handleLoggedOut);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('automaker:logged-out', handleLoggedOut);
|
window.removeEventListener('automaker:logged-out', handleLoggedOut);
|
||||||
};
|
};
|
||||||
}, [location.pathname, navigate]);
|
}, []);
|
||||||
|
|
||||||
// Global listener for server offline/connection errors.
|
// Global listener for server offline/connection errors.
|
||||||
// This is triggered when a connection error is detected (e.g., server stopped).
|
// This is triggered when a connection error is detected (e.g., server stopped).
|
||||||
@@ -724,33 +735,31 @@ function RootLayoutContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If we can't load settings, we must NOT start syncing defaults to the server.
|
// If we can't load settings, we must NOT start syncing defaults to the server.
|
||||||
|
// Only update auth state — the routing effect handles navigation to /logged-out.
|
||||||
|
// Calling navigate() here AND in the routing effect causes duplicate navigations
|
||||||
|
// that can trigger React error #185 (maximum update depth exceeded) on cold start.
|
||||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
signalMigrationComplete();
|
signalMigrationComplete();
|
||||||
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
|
|
||||||
navigate({ to: '/logged-out' });
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Session is definitively invalid (server returned 401/403) - treat as not authenticated
|
// Session is definitively invalid (server returned 401/403) - treat as not authenticated.
|
||||||
|
// Only update auth state — the routing effect handles navigation to /logged-out.
|
||||||
|
// Calling navigate() here AND in the routing effect causes duplicate navigations
|
||||||
|
// that can trigger React error #185 (maximum update depth exceeded) on cold start.
|
||||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
// Signal migration complete so sync hook doesn't hang (nothing to sync when not authenticated)
|
// Signal migration complete so sync hook doesn't hang (nothing to sync when not authenticated)
|
||||||
signalMigrationComplete();
|
signalMigrationComplete();
|
||||||
|
|
||||||
// Redirect to logged-out if not already there or login
|
|
||||||
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
|
|
||||||
navigate({ to: '/logged-out' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to initialize auth:', error);
|
logger.error('Failed to initialize auth:', error);
|
||||||
// On error, treat as not authenticated
|
// On error, treat as not authenticated.
|
||||||
|
// Only update auth state — the routing effect handles navigation to /logged-out.
|
||||||
|
// Calling navigate() here AND in the routing effect causes duplicate navigations
|
||||||
|
// that can trigger React error #185 (maximum update depth exceeded) on cold start.
|
||||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
// Signal migration complete so sync hook doesn't hang
|
// Signal migration complete so sync hook doesn't hang
|
||||||
signalMigrationComplete();
|
signalMigrationComplete();
|
||||||
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
|
|
||||||
navigate({ to: '/logged-out' });
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
authCheckRunning.current = false;
|
authCheckRunning.current = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { ProjectSettingsView } from '@/components/views/project-settings-view';
|
import { ProjectSettingsView } from '@/components/views/project-settings-view';
|
||||||
|
import type { ProjectSettingsViewId } from '@/components/views/project-settings-view/hooks/use-project-settings-view';
|
||||||
|
|
||||||
|
interface ProjectSettingsSearchParams {
|
||||||
|
section?: ProjectSettingsViewId;
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute('/project-settings')({
|
export const Route = createFileRoute('/project-settings')({
|
||||||
component: ProjectSettingsView,
|
component: ProjectSettingsView,
|
||||||
|
validateSearch: (search: Record<string, unknown>): ProjectSettingsSearchParams => {
|
||||||
|
return {
|
||||||
|
section: search.section as ProjectSettingsViewId | undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ export const Route = createLazyFileRoute('/terminal')({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { cwd, branch, mode, nonce } = useSearch({ from: '/terminal' });
|
const { cwd, branch, mode, nonce, command } = useSearch({ from: '/terminal' });
|
||||||
return <TerminalView initialCwd={cwd} initialBranch={branch} initialMode={mode} nonce={nonce} />;
|
return (
|
||||||
|
<TerminalView
|
||||||
|
initialCwd={cwd}
|
||||||
|
initialBranch={branch}
|
||||||
|
initialMode={mode}
|
||||||
|
nonce={nonce}
|
||||||
|
initialCommand={command}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const terminalSearchSchema = z.object({
|
|||||||
branch: z.string().optional(),
|
branch: z.string().optional(),
|
||||||
mode: z.enum(['tab', 'split']).optional(),
|
mode: z.enum(['tab', 'split']).optional(),
|
||||||
nonce: z.coerce.number().optional(),
|
nonce: z.coerce.number().optional(),
|
||||||
|
command: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Component is lazy-loaded via terminal.lazy.tsx for code splitting
|
// Component is lazy-loaded via terminal.lazy.tsx for code splitting
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ const initialState: AppState = {
|
|||||||
defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel,
|
defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel,
|
||||||
defaultThinkingLevel: DEFAULT_GLOBAL_SETTINGS.defaultThinkingLevel ?? 'none',
|
defaultThinkingLevel: DEFAULT_GLOBAL_SETTINGS.defaultThinkingLevel ?? 'none',
|
||||||
defaultReasoningEffort: DEFAULT_GLOBAL_SETTINGS.defaultReasoningEffort ?? 'none',
|
defaultReasoningEffort: DEFAULT_GLOBAL_SETTINGS.defaultReasoningEffort ?? 'none',
|
||||||
|
defaultMaxTurns: DEFAULT_GLOBAL_SETTINGS.defaultMaxTurns ?? 1000,
|
||||||
pendingPlanApproval: null,
|
pendingPlanApproval: null,
|
||||||
claudeRefreshInterval: 60,
|
claudeRefreshInterval: 60,
|
||||||
claudeUsage: null,
|
claudeUsage: null,
|
||||||
@@ -991,7 +992,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
const key = get().getWorktreeKey(projectId, branchName);
|
const key = get().getWorktreeKey(projectId, branchName);
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const current = state.autoModeByWorktree[key] || {
|
const current = state.autoModeByWorktree[key] || {
|
||||||
isRunning: true,
|
isRunning: false,
|
||||||
runningTasks: [],
|
runningTasks: [],
|
||||||
branchName,
|
branchName,
|
||||||
};
|
};
|
||||||
@@ -1109,7 +1110,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
// Sync to server
|
// Sync to server
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { skipVerificationInAutoMode: enabled });
|
await httpApi.settings.updateGlobal({ skipVerificationInAutoMode: enabled });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync skipVerificationInAutoMode:', error);
|
logger.error('Failed to sync skipVerificationInAutoMode:', error);
|
||||||
}
|
}
|
||||||
@@ -1119,7 +1120,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
// Sync to server
|
// Sync to server
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { enableAiCommitMessages: enabled });
|
await httpApi.settings.updateGlobal({ enableAiCommitMessages: enabled });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync enableAiCommitMessages:', error);
|
logger.error('Failed to sync enableAiCommitMessages:', error);
|
||||||
}
|
}
|
||||||
@@ -1129,7 +1130,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
// Sync to server
|
// Sync to server
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { mergePostAction: action });
|
await httpApi.settings.updateGlobal({ mergePostAction: action });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync mergePostAction:', error);
|
logger.error('Failed to sync mergePostAction:', error);
|
||||||
}
|
}
|
||||||
@@ -1139,7 +1140,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
// Sync to server
|
// Sync to server
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { planUseSelectedWorktreeBranch: enabled });
|
await httpApi.settings.updateGlobal({ planUseSelectedWorktreeBranch: enabled });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync planUseSelectedWorktreeBranch:', error);
|
logger.error('Failed to sync planUseSelectedWorktreeBranch:', error);
|
||||||
}
|
}
|
||||||
@@ -1149,7 +1150,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
// Sync to server
|
// Sync to server
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { addFeatureUseSelectedWorktreeBranch: enabled });
|
await httpApi.settings.updateGlobal({ addFeatureUseSelectedWorktreeBranch: enabled });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync addFeatureUseSelectedWorktreeBranch:', error);
|
logger.error('Failed to sync addFeatureUseSelectedWorktreeBranch:', error);
|
||||||
}
|
}
|
||||||
@@ -1222,7 +1223,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
// Sync to server
|
// Sync to server
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { phaseModels: get().phaseModels });
|
await httpApi.settings.updateGlobal({ phaseModels: get().phaseModels });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync phase model:', error);
|
logger.error('Failed to sync phase model:', error);
|
||||||
}
|
}
|
||||||
@@ -1234,7 +1235,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
// Sync to server
|
// Sync to server
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { phaseModels: get().phaseModels });
|
await httpApi.settings.updateGlobal({ phaseModels: get().phaseModels });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync phase models:', error);
|
logger.error('Failed to sync phase models:', error);
|
||||||
}
|
}
|
||||||
@@ -1244,7 +1245,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
// Sync to server
|
// Sync to server
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { phaseModels: DEFAULT_PHASE_MODELS });
|
await httpApi.settings.updateGlobal({ phaseModels: DEFAULT_PHASE_MODELS });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync phase models reset:', error);
|
logger.error('Failed to sync phase models reset:', error);
|
||||||
}
|
}
|
||||||
@@ -1279,7 +1280,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
set({ codexAutoLoadAgents: enabled });
|
set({ codexAutoLoadAgents: enabled });
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { codexAutoLoadAgents: enabled });
|
await httpApi.settings.updateGlobal({ codexAutoLoadAgents: enabled });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync codexAutoLoadAgents:', error);
|
logger.error('Failed to sync codexAutoLoadAgents:', error);
|
||||||
}
|
}
|
||||||
@@ -1288,7 +1289,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
set({ codexSandboxMode: mode });
|
set({ codexSandboxMode: mode });
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { codexSandboxMode: mode });
|
await httpApi.settings.updateGlobal({ codexSandboxMode: mode });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync codexSandboxMode:', error);
|
logger.error('Failed to sync codexSandboxMode:', error);
|
||||||
}
|
}
|
||||||
@@ -1297,7 +1298,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
set({ codexApprovalPolicy: policy });
|
set({ codexApprovalPolicy: policy });
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { codexApprovalPolicy: policy });
|
await httpApi.settings.updateGlobal({ codexApprovalPolicy: policy });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync codexApprovalPolicy:', error);
|
logger.error('Failed to sync codexApprovalPolicy:', error);
|
||||||
}
|
}
|
||||||
@@ -1306,7 +1307,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
set({ codexEnableWebSearch: enabled });
|
set({ codexEnableWebSearch: enabled });
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { codexEnableWebSearch: enabled });
|
await httpApi.settings.updateGlobal({ codexEnableWebSearch: enabled });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync codexEnableWebSearch:', error);
|
logger.error('Failed to sync codexEnableWebSearch:', error);
|
||||||
}
|
}
|
||||||
@@ -1315,7 +1316,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
set({ codexEnableImages: enabled });
|
set({ codexEnableImages: enabled });
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { codexEnableImages: enabled });
|
await httpApi.settings.updateGlobal({ codexEnableImages: enabled });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync codexEnableImages:', error);
|
logger.error('Failed to sync codexEnableImages:', error);
|
||||||
}
|
}
|
||||||
@@ -1375,7 +1376,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
set({ autoLoadClaudeMd: enabled });
|
set({ autoLoadClaudeMd: enabled });
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { autoLoadClaudeMd: enabled });
|
await httpApi.settings.updateGlobal({ autoLoadClaudeMd: enabled });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync autoLoadClaudeMd:', error);
|
logger.error('Failed to sync autoLoadClaudeMd:', error);
|
||||||
}
|
}
|
||||||
@@ -1384,7 +1385,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
set({ skipSandboxWarning: skip });
|
set({ skipSandboxWarning: skip });
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { skipSandboxWarning: skip });
|
await httpApi.settings.updateGlobal({ skipSandboxWarning: skip });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync skipSandboxWarning:', error);
|
logger.error('Failed to sync skipSandboxWarning:', error);
|
||||||
}
|
}
|
||||||
@@ -1407,7 +1408,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
set({ promptCustomization: customization });
|
set({ promptCustomization: customization });
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { promptCustomization: customization });
|
await httpApi.settings.updateGlobal({ promptCustomization: customization });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync prompt customization:', error);
|
logger.error('Failed to sync prompt customization:', error);
|
||||||
}
|
}
|
||||||
@@ -1423,7 +1424,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', {
|
await httpApi.settings.updateGlobal({
|
||||||
claudeCompatibleProviders: get().claudeCompatibleProviders,
|
claudeCompatibleProviders: get().claudeCompatibleProviders,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1438,7 +1439,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', {
|
await httpApi.settings.updateGlobal({
|
||||||
claudeCompatibleProviders: get().claudeCompatibleProviders,
|
claudeCompatibleProviders: get().claudeCompatibleProviders,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1451,7 +1452,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', {
|
await httpApi.settings.updateGlobal({
|
||||||
claudeCompatibleProviders: get().claudeCompatibleProviders,
|
claudeCompatibleProviders: get().claudeCompatibleProviders,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1462,7 +1463,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
set({ claudeCompatibleProviders: providers });
|
set({ claudeCompatibleProviders: providers });
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { claudeCompatibleProviders: providers });
|
await httpApi.settings.updateGlobal({ claudeCompatibleProviders: providers });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync Claude-compatible providers:', error);
|
logger.error('Failed to sync Claude-compatible providers:', error);
|
||||||
}
|
}
|
||||||
@@ -1475,7 +1476,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', {
|
await httpApi.settings.updateGlobal({
|
||||||
claudeCompatibleProviders: get().claudeCompatibleProviders,
|
claudeCompatibleProviders: get().claudeCompatibleProviders,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1490,7 +1491,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { claudeApiProfiles: get().claudeApiProfiles });
|
await httpApi.settings.updateGlobal({ claudeApiProfiles: get().claudeApiProfiles });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync Claude API profiles:', error);
|
logger.error('Failed to sync Claude API profiles:', error);
|
||||||
}
|
}
|
||||||
@@ -1503,7 +1504,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { claudeApiProfiles: get().claudeApiProfiles });
|
await httpApi.settings.updateGlobal({ claudeApiProfiles: get().claudeApiProfiles });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync Claude API profiles:', error);
|
logger.error('Failed to sync Claude API profiles:', error);
|
||||||
}
|
}
|
||||||
@@ -1516,7 +1517,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', {
|
await httpApi.settings.updateGlobal({
|
||||||
claudeApiProfiles: get().claudeApiProfiles,
|
claudeApiProfiles: get().claudeApiProfiles,
|
||||||
activeClaudeApiProfileId: get().activeClaudeApiProfileId,
|
activeClaudeApiProfileId: get().activeClaudeApiProfileId,
|
||||||
});
|
});
|
||||||
@@ -1528,7 +1529,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
set({ activeClaudeApiProfileId: id });
|
set({ activeClaudeApiProfileId: id });
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { activeClaudeApiProfileId: id });
|
await httpApi.settings.updateGlobal({ activeClaudeApiProfileId: id });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync active Claude API profile:', error);
|
logger.error('Failed to sync active Claude API profile:', error);
|
||||||
}
|
}
|
||||||
@@ -1537,7 +1538,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
set({ claudeApiProfiles: profiles });
|
set({ claudeApiProfiles: profiles });
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { claudeApiProfiles: profiles });
|
await httpApi.settings.updateGlobal({ claudeApiProfiles: profiles });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync Claude API profiles:', error);
|
logger.error('Failed to sync Claude API profiles:', error);
|
||||||
}
|
}
|
||||||
@@ -1947,6 +1948,16 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
terminalState: { ...state.terminalState, openTerminalMode: mode },
|
terminalState: { ...state.terminalState, openTerminalMode: mode },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
setTerminalBackgroundColor: (color) =>
|
||||||
|
set((state) => ({
|
||||||
|
terminalState: { ...state.terminalState, customBackgroundColor: color },
|
||||||
|
})),
|
||||||
|
|
||||||
|
setTerminalForegroundColor: (color) =>
|
||||||
|
set((state) => ({
|
||||||
|
terminalState: { ...state.terminalState, customForegroundColor: color },
|
||||||
|
})),
|
||||||
|
|
||||||
addTerminalTab: (name) => {
|
addTerminalTab: (name) => {
|
||||||
const newTabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
const newTabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
const tabNumber = get().terminalState.tabs.length + 1;
|
const tabNumber = get().terminalState.tabs.length + 1;
|
||||||
@@ -2341,7 +2352,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
// Sync to server
|
// Sync to server
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { defaultThinkingLevel: level });
|
await httpApi.settings.updateGlobal({ defaultThinkingLevel: level });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync defaultThinkingLevel:', error);
|
logger.error('Failed to sync defaultThinkingLevel:', error);
|
||||||
}
|
}
|
||||||
@@ -2352,12 +2363,27 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
// Sync to server
|
// Sync to server
|
||||||
try {
|
try {
|
||||||
const httpApi = getHttpApiClient();
|
const httpApi = getHttpApiClient();
|
||||||
await httpApi.put('/api/settings', { defaultReasoningEffort: effort });
|
await httpApi.settings.updateGlobal({ defaultReasoningEffort: effort });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync defaultReasoningEffort:', error);
|
logger.error('Failed to sync defaultReasoningEffort:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setDefaultMaxTurns: async (maxTurns: number) => {
|
||||||
|
// Guard against NaN/Infinity before flooring and clamping
|
||||||
|
const safeValue = Number.isFinite(maxTurns) ? maxTurns : 1;
|
||||||
|
// Clamp to valid range
|
||||||
|
const clamped = Math.max(1, Math.min(2000, Math.floor(safeValue)));
|
||||||
|
set({ defaultMaxTurns: clamped });
|
||||||
|
// Sync to server
|
||||||
|
try {
|
||||||
|
const httpApi = getHttpApiClient();
|
||||||
|
await httpApi.settings.updateGlobal({ defaultMaxTurns: clamped });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to sync defaultMaxTurns:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Plan Approval actions
|
// Plan Approval actions
|
||||||
setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }),
|
setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }),
|
||||||
|
|
||||||
|
|||||||
@@ -18,4 +18,6 @@ export const defaultTerminalState: TerminalState = {
|
|||||||
maxSessions: 100,
|
maxSessions: 100,
|
||||||
lastActiveProjectPath: null,
|
lastActiveProjectPath: null,
|
||||||
openTerminalMode: 'newTab',
|
openTerminalMode: 'newTab',
|
||||||
|
customBackgroundColor: null,
|
||||||
|
customForegroundColor: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -182,6 +182,9 @@ export interface AppState {
|
|||||||
defaultThinkingLevel: ThinkingLevel;
|
defaultThinkingLevel: ThinkingLevel;
|
||||||
defaultReasoningEffort: ReasoningEffort;
|
defaultReasoningEffort: ReasoningEffort;
|
||||||
|
|
||||||
|
// Default max turns for agent execution (1-2000)
|
||||||
|
defaultMaxTurns: number;
|
||||||
|
|
||||||
// Cursor CLI Settings (global)
|
// Cursor CLI Settings (global)
|
||||||
enabledCursorModels: CursorModelId[]; // Which Cursor models are available in feature modal
|
enabledCursorModels: CursorModelId[]; // Which Cursor models are available in feature modal
|
||||||
cursorDefaultModel: CursorModelId; // Default Cursor model selection
|
cursorDefaultModel: CursorModelId; // Default Cursor model selection
|
||||||
@@ -564,6 +567,7 @@ export interface AppActions {
|
|||||||
toggleFavoriteModel: (modelId: string) => void;
|
toggleFavoriteModel: (modelId: string) => void;
|
||||||
setDefaultThinkingLevel: (level: ThinkingLevel) => void;
|
setDefaultThinkingLevel: (level: ThinkingLevel) => void;
|
||||||
setDefaultReasoningEffort: (effort: ReasoningEffort) => void;
|
setDefaultReasoningEffort: (effort: ReasoningEffort) => void;
|
||||||
|
setDefaultMaxTurns: (maxTurns: number) => void;
|
||||||
|
|
||||||
// Cursor CLI Settings actions
|
// Cursor CLI Settings actions
|
||||||
setEnabledCursorModels: (models: CursorModelId[]) => void;
|
setEnabledCursorModels: (models: CursorModelId[]) => void;
|
||||||
@@ -708,6 +712,8 @@ export interface AppActions {
|
|||||||
setTerminalMaxSessions: (maxSessions: number) => void;
|
setTerminalMaxSessions: (maxSessions: number) => void;
|
||||||
setTerminalLastActiveProjectPath: (projectPath: string | null) => void;
|
setTerminalLastActiveProjectPath: (projectPath: string | null) => void;
|
||||||
setOpenTerminalMode: (mode: 'newTab' | 'split') => void;
|
setOpenTerminalMode: (mode: 'newTab' | 'split') => void;
|
||||||
|
setTerminalBackgroundColor: (color: string | null) => void;
|
||||||
|
setTerminalForegroundColor: (color: string | null) => void;
|
||||||
addTerminalTab: (name?: string) => string;
|
addTerminalTab: (name?: string) => string;
|
||||||
removeTerminalTab: (tabId: string) => void;
|
removeTerminalTab: (tabId: string) => void;
|
||||||
setActiveTerminalTab: (tabId: string) => void;
|
setActiveTerminalTab: (tabId: string) => void;
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface TerminalState {
|
|||||||
maxSessions: number; // Maximum concurrent terminal sessions (server setting)
|
maxSessions: number; // Maximum concurrent terminal sessions (server setting)
|
||||||
lastActiveProjectPath: string | null; // Last project path to detect route changes vs project switches
|
lastActiveProjectPath: string | null; // Last project path to detect route changes vs project switches
|
||||||
openTerminalMode: 'newTab' | 'split'; // How to open terminals from "Open in Terminal" action
|
openTerminalMode: 'newTab' | 'split'; // How to open terminals from "Open in Terminal" action
|
||||||
|
customBackgroundColor: string | null; // Custom background color override (hex color string, null = use theme default)
|
||||||
|
customForegroundColor: string | null; // Custom foreground/text color override (hex color string, null = use theme default)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persisted terminal layout - now includes sessionIds for reconnection
|
// Persisted terminal layout - now includes sessionIds for reconnection
|
||||||
@@ -79,4 +81,6 @@ export interface PersistedTerminalSettings {
|
|||||||
lineHeight: number;
|
lineHeight: number;
|
||||||
maxSessions: number;
|
maxSessions: number;
|
||||||
openTerminalMode: 'newTab' | 'split';
|
openTerminalMode: 'newTab' | 'split';
|
||||||
|
customBackgroundColor: string | null; // Custom background color override (hex color string, null = use theme default)
|
||||||
|
customForegroundColor: string | null; // Custom foreground/text color override (hex color string, null = use theme default)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,8 +113,25 @@ export function syncUICache(appState: {
|
|||||||
if ('collapsedNavSections' in appState) {
|
if ('collapsedNavSections' in appState) {
|
||||||
update.cachedCollapsedNavSections = appState.collapsedNavSections;
|
update.cachedCollapsedNavSections = appState.collapsedNavSections;
|
||||||
}
|
}
|
||||||
if ('currentWorktreeByProject' in appState) {
|
if ('currentWorktreeByProject' in appState && appState.currentWorktreeByProject) {
|
||||||
update.cachedCurrentWorktreeByProject = appState.currentWorktreeByProject;
|
// Sanitize on write: only persist entries where path is null (main branch).
|
||||||
|
// Non-null paths point to worktree directories on disk that may be deleted
|
||||||
|
// while the app is not running. Persisting stale paths can cause crash loops
|
||||||
|
// on restore (the board renders with an invalid selection, the error boundary
|
||||||
|
// reloads, which restores the same bad cache). This mirrors the sanitization
|
||||||
|
// in restoreFromUICache() for defense-in-depth.
|
||||||
|
const sanitized: Record<string, { path: string | null; branch: string }> = {};
|
||||||
|
for (const [projectPath, worktree] of Object.entries(appState.currentWorktreeByProject)) {
|
||||||
|
if (
|
||||||
|
typeof worktree === 'object' &&
|
||||||
|
worktree !== null &&
|
||||||
|
'path' in worktree &&
|
||||||
|
worktree.path === null
|
||||||
|
) {
|
||||||
|
sanitized[projectPath] = worktree;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update.cachedCurrentWorktreeByProject = sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(update).length > 0) {
|
if (Object.keys(update).length > 0) {
|
||||||
@@ -160,11 +177,37 @@ export function restoreFromUICache(
|
|||||||
|
|
||||||
// Restore last selected worktree per project so the board doesn't
|
// Restore last selected worktree per project so the board doesn't
|
||||||
// reset to main branch after PWA memory eviction or tab discard.
|
// reset to main branch after PWA memory eviction or tab discard.
|
||||||
|
//
|
||||||
|
// IMPORTANT: Only restore entries where path is null (main branch selection).
|
||||||
|
// Non-null paths point to worktree directories on disk that may have been
|
||||||
|
// deleted while the PWA was evicted. Restoring a stale worktree path causes
|
||||||
|
// the board to render with an invalid selection, and if the server can't
|
||||||
|
// validate it fast enough, the app enters an unrecoverable crash loop
|
||||||
|
// (the error boundary reloads, which restores the same bad cache).
|
||||||
|
// Main branch (path=null) is always valid and safe to restore.
|
||||||
if (
|
if (
|
||||||
cache.cachedCurrentWorktreeByProject &&
|
cache.cachedCurrentWorktreeByProject &&
|
||||||
Object.keys(cache.cachedCurrentWorktreeByProject).length > 0
|
Object.keys(cache.cachedCurrentWorktreeByProject).length > 0
|
||||||
) {
|
) {
|
||||||
stateUpdate.currentWorktreeByProject = cache.cachedCurrentWorktreeByProject;
|
const sanitized: Record<string, { path: string | null; branch: string }> = {};
|
||||||
|
for (const [projectPath, worktree] of Object.entries(cache.cachedCurrentWorktreeByProject)) {
|
||||||
|
if (
|
||||||
|
typeof worktree === 'object' &&
|
||||||
|
worktree !== null &&
|
||||||
|
'path' in worktree &&
|
||||||
|
worktree.path === null
|
||||||
|
) {
|
||||||
|
// Main branch selection — always safe to restore
|
||||||
|
sanitized[projectPath] = worktree;
|
||||||
|
}
|
||||||
|
// Non-null paths are dropped; the app will re-discover actual worktrees
|
||||||
|
// from the server and the validation effect in use-worktrees will handle
|
||||||
|
// resetting to main if the cached worktree no longer exists.
|
||||||
|
// Null/malformed entries are also dropped to prevent crashes.
|
||||||
|
}
|
||||||
|
if (Object.keys(sanitized).length > 0) {
|
||||||
|
stateUpdate.currentWorktreeByProject = sanitized;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore the project context when the project object is available.
|
// Restore the project context when the project object is available.
|
||||||
|
|||||||
14
apps/ui/src/types/electron.d.ts
vendored
14
apps/ui/src/types/electron.d.ts
vendored
@@ -69,6 +69,7 @@ export interface SessionListItem {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
|
workingDirectory?: string; // The worktree/directory this session runs in
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
@@ -910,6 +911,19 @@ export interface WorktreeAPI {
|
|||||||
path: string;
|
path: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
isNew: boolean;
|
isNew: boolean;
|
||||||
|
/** Short commit hash the worktree is based on */
|
||||||
|
baseCommitHash?: string;
|
||||||
|
/** Result of syncing the base branch with its remote tracking branch */
|
||||||
|
syncResult?: {
|
||||||
|
/** Whether the sync succeeded */
|
||||||
|
synced: boolean;
|
||||||
|
/** The remote that was synced from */
|
||||||
|
remote?: string;
|
||||||
|
/** Human-readable message about the sync result */
|
||||||
|
message?: string;
|
||||||
|
/** Whether the branch had diverged (local commits ahead of remote) */
|
||||||
|
diverged?: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -64,8 +64,10 @@ services:
|
|||||||
# Optional - data directory for sessions, settings, etc. (container-only)
|
# Optional - data directory for sessions, settings, etc. (container-only)
|
||||||
- DATA_DIR=/data
|
- DATA_DIR=/data
|
||||||
|
|
||||||
# Optional - CORS origin (default allows all)
|
# Optional - CORS origin (default: auto-detect local network origins)
|
||||||
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3007}
|
# With nginx proxying API requests, CORS is not needed for same-origin access.
|
||||||
|
# Set explicitly only if accessing the API from a different domain.
|
||||||
|
- CORS_ORIGIN=${CORS_ORIGIN:-}
|
||||||
|
|
||||||
# Internal - indicates the API is running in a containerized sandbox environment
|
# Internal - indicates the API is running in a containerized sandbox environment
|
||||||
# This is used by the UI to determine if sandbox risk warnings should be shown
|
# This is used by the UI to determine if sandbox risk warnings should be shown
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user