mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-04-01 15:03:07 +00:00
Compare commits
11 Commits
feature/pu
...
2f071a1ba3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f071a1ba3 | ||
|
|
1d732916f1 | ||
|
|
629fd24d9f | ||
|
|
72cb942788 | ||
|
|
91bff21d58 | ||
|
|
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,
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
supportsReasoningEffort,
|
supportsReasoningEffort,
|
||||||
validateBareModelId,
|
validateBareModelId,
|
||||||
calculateReasoningTimeout,
|
calculateReasoningTimeout,
|
||||||
DEFAULT_TIMEOUT_MS,
|
|
||||||
type CodexApprovalPolicy,
|
type CodexApprovalPolicy,
|
||||||
type CodexSandboxMode,
|
type CodexSandboxMode,
|
||||||
type CodexAuthStatus,
|
type CodexAuthStatus,
|
||||||
@@ -98,7 +97,7 @@ const TEXT_ENCODING = 'utf-8';
|
|||||||
*
|
*
|
||||||
* @see calculateReasoningTimeout from @automaker/types
|
* @see calculateReasoningTimeout from @automaker/types
|
||||||
*/
|
*/
|
||||||
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
|
const CODEX_CLI_TIMEOUT_MS = 120000; // 2 minutes — matches CLI provider base timeout
|
||||||
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
|
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
|
||||||
const SYSTEM_PROMPT_SEPARATOR = '\n\n';
|
const SYSTEM_PROMPT_SEPARATOR = '\n\n';
|
||||||
const CODEX_INSTRUCTIONS_DIR = '.codex';
|
const CODEX_INSTRUCTIONS_DIR = '.codex';
|
||||||
@@ -738,6 +737,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(
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ export function createAnalyzeProjectHandler(autoModeService: AutoModeServiceComp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start analysis in background
|
// Kick off analysis in the background; attach a rejection handler so
|
||||||
autoModeService.analyzeProject(projectPath).catch((error) => {
|
// unhandled-promise warnings don't surface and errors are at least logged.
|
||||||
logger.error(`[AutoMode] Project analysis error:`, error);
|
// Synchronous throws (e.g. "not implemented") still propagate here.
|
||||||
});
|
const analysisPromise = autoModeService.analyzeProject(projectPath);
|
||||||
|
analysisPromise.catch((err) => logError(err, 'Background analyzeProject failed'));
|
||||||
|
|
||||||
res.json({ success: true, message: 'Project analysis started' });
|
res.json({ success: true, message: 'Project analysis started' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,287 +5,26 @@
|
|||||||
* for a specific pull request, providing file path and line context.
|
* for a specific pull request, providing file path and line context.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from 'child_process';
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
import { getErrorMessage, logError } from './common.js';
|
||||||
import { checkGitHubRemote } from './check-github-remote.js';
|
import { checkGitHubRemote } from './check-github-remote.js';
|
||||||
|
import {
|
||||||
|
fetchPRReviewComments,
|
||||||
|
fetchReviewThreadResolvedStatus,
|
||||||
|
type PRReviewComment,
|
||||||
|
type ListPRReviewCommentsResult,
|
||||||
|
} from '../../../services/pr-review-comments.service.js';
|
||||||
|
|
||||||
export interface PRReviewComment {
|
// Re-export types so existing callers continue to work
|
||||||
id: string;
|
export type { PRReviewComment, ListPRReviewCommentsResult };
|
||||||
author: string;
|
// Re-export service functions so existing callers continue to work
|
||||||
avatarUrl?: string;
|
export { fetchPRReviewComments, fetchReviewThreadResolvedStatus };
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ListPRReviewCommentsResult {
|
|
||||||
success: boolean;
|
|
||||||
comments?: PRReviewComment[];
|
|
||||||
totalCount?: number;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ListPRReviewCommentsRequest {
|
interface ListPRReviewCommentsRequest {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
prNumber: number;
|
prNumber: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Timeout for GitHub GraphQL API requests in milliseconds */
|
|
||||||
const GITHUB_API_TIMEOUT_MS = 30000;
|
|
||||||
|
|
||||||
interface GraphQLReviewThreadComment {
|
|
||||||
databaseId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GraphQLReviewThread {
|
|
||||||
id: string;
|
|
||||||
isResolved: boolean;
|
|
||||||
comments: {
|
|
||||||
nodes: GraphQLReviewThreadComment[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GraphQLResponse {
|
|
||||||
data?: {
|
|
||||||
repository?: {
|
|
||||||
pullRequest?: {
|
|
||||||
reviewThreads?: {
|
|
||||||
nodes: GraphQLReviewThread[];
|
|
||||||
};
|
|
||||||
} | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
errors?: Array<{ message: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReviewThreadInfo {
|
|
||||||
isResolved: boolean;
|
|
||||||
threadId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch review thread resolved status and thread IDs using GitHub GraphQL API.
|
|
||||||
* Returns a map of comment ID (string) -> { isResolved, threadId }.
|
|
||||||
*/
|
|
||||||
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!
|
|
||||||
) {
|
|
||||||
repository(owner: $owner, name: $repo) {
|
|
||||||
pullRequest(number: $prNumber) {
|
|
||||||
reviewThreads(first: 100) {
|
|
||||||
nodes {
|
|
||||||
id
|
|
||||||
isResolved
|
|
||||||
comments(first: 100) {
|
|
||||||
nodes {
|
|
||||||
databaseId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const variables = { owner, repo, prNumber };
|
|
||||||
const requestBody = JSON.stringify({ query, variables });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await new Promise<GraphQLResponse>((resolve, reject) => {
|
|
||||||
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
|
|
||||||
cwd: projectPath,
|
|
||||||
env: execEnv,
|
|
||||||
});
|
|
||||||
|
|
||||||
const 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.write(requestBody);
|
|
||||||
gh.stdin.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.errors && response.errors.length > 0) {
|
|
||||||
throw new Error(response.errors[0].message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const threads = response.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
|
||||||
for (const thread of threads) {
|
|
||||||
const info: ReviewThreadInfo = { isResolved: thread.isResolved, threadId: thread.id };
|
|
||||||
for (const comment of thread.comments.nodes) {
|
|
||||||
resolvedMap.set(String(comment.databaseId), info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} 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 (both regular and inline review comments)
|
|
||||||
*/
|
|
||||||
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)
|
|
||||||
try {
|
|
||||||
const { stdout: commentsOutput } = await execAsync(
|
|
||||||
`gh pr view ${prNumber} -R ${owner}/${repo} --json comments`,
|
|
||||||
{
|
|
||||||
cwd: projectPath,
|
|
||||||
env: execEnv,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const commentsData = JSON.parse(commentsOutput);
|
|
||||||
const regularComments = (commentsData.comments || []).map(
|
|
||||||
(c: {
|
|
||||||
id: string;
|
|
||||||
author: { login: string; avatarUrl?: string };
|
|
||||||
body: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
}) => ({
|
|
||||||
id: String(c.id),
|
|
||||||
author: c.author?.login || 'unknown',
|
|
||||||
avatarUrl: c.author?.avatarUrl,
|
|
||||||
body: c.body,
|
|
||||||
createdAt: c.createdAt,
|
|
||||||
updatedAt: c.updatedAt,
|
|
||||||
isReviewComment: false,
|
|
||||||
isOutdated: false,
|
|
||||||
// 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 execAsync(`gh api ${reviewsEndpoint} --paginate`, {
|
|
||||||
cwd: projectPath,
|
|
||||||
env: execEnv,
|
|
||||||
});
|
|
||||||
|
|
||||||
const reviewsData = JSON.parse(reviewsOutput);
|
|
||||||
const reviewComments = (Array.isArray(reviewsData) ? reviewsData : []).map(
|
|
||||||
(c: {
|
|
||||||
id: number;
|
|
||||||
user: { login: string; avatar_url?: string };
|
|
||||||
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;
|
|
||||||
}) => ({
|
|
||||||
id: String(c.id),
|
|
||||||
author: c.user?.login || '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 && !c.line,
|
|
||||||
// isResolved will be filled in below from GraphQL data
|
|
||||||
isResolved: false,
|
|
||||||
diffHunk: c.diff_hunk,
|
|
||||||
side: c.side,
|
|
||||||
commitId: c.commit_id,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
allComments.push(...reviewComments);
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Failed to fetch inline review comments');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for resolved status and apply to inline review comments
|
|
||||||
const resolvedMap = await resolvedStatusPromise;
|
|
||||||
if (resolvedMap.size > 0) {
|
|
||||||
for (const comment of allComments) {
|
|
||||||
if (comment.isReviewComment && resolvedMap.has(comment.id)) {
|
|
||||||
const info = resolvedMap.get(comment.id);
|
|
||||||
comment.isResolved = info?.isResolved ?? false;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createListPRReviewCommentsHandler() {
|
export function createListPRReviewCommentsHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
* identified by its GraphQL node ID (threadId).
|
* identified by its GraphQL node ID (threadId).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from 'child_process';
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { execEnv, getErrorMessage, logError } from './common.js';
|
import { getErrorMessage, logError } from './common.js';
|
||||||
import { checkGitHubRemote } from './check-github-remote.js';
|
import { checkGitHubRemote } from './check-github-remote.js';
|
||||||
|
import { executeReviewThreadMutation } from '../../../services/github-pr-comment.service.js';
|
||||||
|
|
||||||
export interface ResolvePRCommentResult {
|
export interface ResolvePRCommentResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -22,91 +22,6 @@ interface ResolvePRCommentRequest {
|
|||||||
resolve: boolean;
|
resolve: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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.
|
|
||||||
*/
|
|
||||||
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 });
|
|
||||||
|
|
||||||
const response = await new Promise<GraphQLMutationResponse>((res, rej) => {
|
|
||||||
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
|
|
||||||
cwd: projectPath,
|
|
||||||
env: execEnv,
|
|
||||||
});
|
|
||||||
|
|
||||||
const 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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createResolvePRCommentHandler() {
|
export function createResolvePRCommentHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ import { createAbortOperationHandler } from './routes/abort-operation.js';
|
|||||||
import { createContinueOperationHandler } from './routes/continue-operation.js';
|
import { createContinueOperationHandler } from './routes/continue-operation.js';
|
||||||
import { createStageFilesHandler } from './routes/stage-files.js';
|
import { createStageFilesHandler } from './routes/stage-files.js';
|
||||||
import { createCheckChangesHandler } from './routes/check-changes.js';
|
import { createCheckChangesHandler } from './routes/check-changes.js';
|
||||||
|
import { createSetTrackingHandler } from './routes/set-tracking.js';
|
||||||
|
import { createSyncHandler } from './routes/sync.js';
|
||||||
import type { SettingsService } from '../../services/settings-service.js';
|
import type { SettingsService } from '../../services/settings-service.js';
|
||||||
|
|
||||||
export function createWorktreeRoutes(
|
export function createWorktreeRoutes(
|
||||||
@@ -118,6 +120,18 @@ export function createWorktreeRoutes(
|
|||||||
requireValidWorktree,
|
requireValidWorktree,
|
||||||
createPullHandler()
|
createPullHandler()
|
||||||
);
|
);
|
||||||
|
router.post(
|
||||||
|
'/sync',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireValidWorktree,
|
||||||
|
createSyncHandler()
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/set-tracking',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireValidWorktree,
|
||||||
|
createSetTrackingHandler()
|
||||||
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/checkout-branch',
|
'/checkout-branch',
|
||||||
validatePathParams('worktreePath'),
|
validatePathParams('worktreePath'),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
|
import fs from 'fs/promises';
|
||||||
import { isGitRepo } from '@automaker/git-utils';
|
import { isGitRepo } from '@automaker/git-utils';
|
||||||
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
|
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
|
||||||
import { execGitCommand } from '../../../lib/git.js';
|
import { execGitCommand } from '../../../lib/git.js';
|
||||||
@@ -46,20 +47,79 @@ export function createDeleteHandler() {
|
|||||||
});
|
});
|
||||||
branchName = stdout.trim();
|
branchName = stdout.trim();
|
||||||
} catch {
|
} catch {
|
||||||
// Could not get branch name
|
// Could not get branch name - worktree directory may already be gone
|
||||||
|
logger.debug('Could not determine branch for worktree, directory may be missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the worktree (using array arguments to prevent injection)
|
// Remove the worktree (using array arguments to prevent injection)
|
||||||
|
let removeSucceeded = false;
|
||||||
try {
|
try {
|
||||||
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
|
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
|
||||||
} catch {
|
removeSucceeded = true;
|
||||||
// Try with prune if remove fails
|
} catch (removeError) {
|
||||||
await execGitCommand(['worktree', 'prune'], projectPath);
|
// `git worktree remove` can fail if the directory is already missing
|
||||||
|
// or in a bad state. Try pruning stale worktree entries as a fallback.
|
||||||
|
logger.debug('git worktree remove failed, trying prune', {
|
||||||
|
error: getErrorMessage(removeError),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await execGitCommand(['worktree', 'prune'], projectPath);
|
||||||
|
|
||||||
|
// Verify the specific worktree is no longer registered after prune.
|
||||||
|
// `git worktree prune` exits 0 even if worktreePath was never registered,
|
||||||
|
// so we must explicitly check the worktree list to avoid false positives.
|
||||||
|
const { stdout: listOut } = await execAsync('git worktree list --porcelain', {
|
||||||
|
cwd: projectPath,
|
||||||
|
});
|
||||||
|
// Parse porcelain output and check for an exact path match.
|
||||||
|
// Using substring .includes() can produce false positives when one
|
||||||
|
// worktree path is a prefix of another (e.g. /foo vs /foobar).
|
||||||
|
const stillRegistered = listOut
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.startsWith('worktree '))
|
||||||
|
.map((line) => line.slice('worktree '.length).trim())
|
||||||
|
.some((registeredPath) => registeredPath === worktreePath);
|
||||||
|
if (stillRegistered) {
|
||||||
|
// Prune didn't clean up our entry - treat as failure
|
||||||
|
throw removeError;
|
||||||
|
}
|
||||||
|
removeSucceeded = true;
|
||||||
|
} catch (pruneError) {
|
||||||
|
// If pruneError is the original removeError re-thrown, propagate it
|
||||||
|
if (pruneError === removeError) {
|
||||||
|
throw removeError;
|
||||||
|
}
|
||||||
|
logger.warn('git worktree prune also failed', {
|
||||||
|
error: getErrorMessage(pruneError),
|
||||||
|
});
|
||||||
|
// If both remove and prune fail, still try to return success
|
||||||
|
// if the worktree directory no longer exists (it may have been
|
||||||
|
// manually deleted already).
|
||||||
|
let dirExists = false;
|
||||||
|
try {
|
||||||
|
await fs.access(worktreePath);
|
||||||
|
dirExists = true;
|
||||||
|
} catch {
|
||||||
|
// Directory doesn't exist
|
||||||
|
}
|
||||||
|
if (dirExists) {
|
||||||
|
// Directory still exists - this is a real failure
|
||||||
|
throw removeError;
|
||||||
|
}
|
||||||
|
// Directory is gone, treat as success
|
||||||
|
removeSucceeded = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally delete the branch
|
// Optionally delete the branch (only if worktree was successfully removed)
|
||||||
let branchDeleted = false;
|
let branchDeleted = false;
|
||||||
if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') {
|
if (
|
||||||
|
removeSucceeded &&
|
||||||
|
deleteBranch &&
|
||||||
|
branchName &&
|
||||||
|
branchName !== 'main' &&
|
||||||
|
branchName !== 'master'
|
||||||
|
) {
|
||||||
// Validate branch name to prevent command injection
|
// Validate branch name to prevent command injection
|
||||||
if (!isValidBranchName(branchName)) {
|
if (!isValidBranchName(branchName)) {
|
||||||
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
|
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
@@ -222,7 +241,7 @@ export function createGenerateCommitMessageHandler(
|
|||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ Rules:
|
|||||||
- Focus on the user-facing impact when possible
|
- Focus on the user-facing impact when possible
|
||||||
- If there are breaking changes, mention them prominently
|
- If there are breaking changes, mention them prominently
|
||||||
- The diff may include both committed changes and uncommitted working directory changes. Treat all changes as part of the PR since uncommitted changes will be committed when the PR is created
|
- The diff may include both committed changes and uncommitted working directory changes. Treat all changes as part of the PR since uncommitted changes will be committed when the PR is created
|
||||||
- Do NOT distinguish between committed and uncommitted changes in the output - describe all changes as a unified set of PR changes`;
|
- Do NOT distinguish between committed and uncommitted changes in the output - describe all changes as a unified set of PR changes
|
||||||
|
- EXCLUDE any files that are gitignored (e.g., node_modules, dist, build, .env files, lock files, generated files, binary artifacts, coverage reports, cache directories). These should not be mentioned in the description even if they appear in the diff
|
||||||
|
- Focus only on meaningful source code changes that are tracked by git and relevant to reviewers`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps an async generator with a timeout.
|
* Wraps an async generator with a timeout.
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* POST /push endpoint - Push a worktree branch to remote
|
* POST /push endpoint - Push a worktree branch to remote
|
||||||
*
|
*
|
||||||
|
* Git business logic is delegated to push-service.ts.
|
||||||
|
*
|
||||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||||
* the requireValidWorktree middleware in index.ts
|
* the requireValidWorktree middleware in index.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { performPush } from '../../../services/push-service.js';
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
export function createPushHandler() {
|
export function createPushHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath, force, remote } = req.body as {
|
const { worktreePath, force, remote, autoResolve } = req.body as {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
remote?: string;
|
remote?: string;
|
||||||
|
autoResolve?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!worktreePath) {
|
if (!worktreePath) {
|
||||||
@@ -29,34 +29,28 @@ export function createPushHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get branch name
|
const result = await performPush(worktreePath, { remote, force, autoResolve });
|
||||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
const branchName = branchOutput.trim();
|
|
||||||
|
|
||||||
// Use specified remote or default to 'origin'
|
if (!result.success) {
|
||||||
const targetRemote = remote || 'origin';
|
const statusCode = isClientError(result.error ?? '') ? 400 : 500;
|
||||||
|
res.status(statusCode).json({
|
||||||
// Push the branch
|
success: false,
|
||||||
const forceFlag = force ? '--force' : '';
|
error: result.error,
|
||||||
try {
|
diverged: result.diverged,
|
||||||
await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, {
|
hasConflicts: result.hasConflicts,
|
||||||
cwd: worktreePath,
|
conflictFiles: result.conflictFiles,
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Try setting upstream
|
|
||||||
await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, {
|
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
branch: branchName,
|
branch: result.branch,
|
||||||
pushed: true,
|
pushed: result.pushed,
|
||||||
message: `Successfully pushed ${branchName} to ${targetRemote}`,
|
diverged: result.diverged,
|
||||||
|
autoResolved: result.autoResolved,
|
||||||
|
message: result.message,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -65,3 +59,15 @@ export function createPushHandler() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether an error message represents a client error (400)
|
||||||
|
* vs a server error (500).
|
||||||
|
*/
|
||||||
|
function isClientError(errorMessage: string): boolean {
|
||||||
|
return (
|
||||||
|
errorMessage.includes('detached HEAD') ||
|
||||||
|
errorMessage.includes('rejected') ||
|
||||||
|
errorMessage.includes('diverged')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
76
apps/server/src/routes/worktree/routes/set-tracking.ts
Normal file
76
apps/server/src/routes/worktree/routes/set-tracking.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* POST /set-tracking endpoint - Set the upstream tracking branch for a worktree
|
||||||
|
*
|
||||||
|
* Sets `git branch --set-upstream-to=<remote>/<branch>` for the current branch.
|
||||||
|
*
|
||||||
|
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||||
|
* the requireValidWorktree middleware in index.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { execGitCommand } from '@automaker/git-utils';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { getCurrentBranch } from '../../../lib/git.js';
|
||||||
|
|
||||||
|
export function createSetTrackingHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { worktreePath, remote, branch } = req.body as {
|
||||||
|
worktreePath: string;
|
||||||
|
remote: string;
|
||||||
|
branch?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!worktreePath) {
|
||||||
|
res.status(400).json({ success: false, error: 'worktreePath required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!remote) {
|
||||||
|
res.status(400).json({ success: false, error: 'remote required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current branch if not provided
|
||||||
|
let targetBranch = branch;
|
||||||
|
if (!targetBranch) {
|
||||||
|
try {
|
||||||
|
targetBranch = await getCurrentBranch(worktreePath);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Failed to get current branch: ${getErrorMessage(err)}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetBranch === 'HEAD') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Cannot set tracking in detached HEAD state.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set upstream tracking (pass local branch name as final arg to be explicit)
|
||||||
|
await execGitCommand(
|
||||||
|
['branch', '--set-upstream-to', `${remote}/${targetBranch}`, targetBranch],
|
||||||
|
worktreePath
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
branch: targetBranch,
|
||||||
|
remote,
|
||||||
|
upstream: `${remote}/${targetBranch}`,
|
||||||
|
message: `Set tracking branch to ${remote}/${targetBranch}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Set tracking branch failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
66
apps/server/src/routes/worktree/routes/sync.ts
Normal file
66
apps/server/src/routes/worktree/routes/sync.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* POST /sync endpoint - Pull then push a worktree branch
|
||||||
|
*
|
||||||
|
* Performs a full sync operation: pull latest from remote, then push
|
||||||
|
* local commits. Handles divergence automatically.
|
||||||
|
*
|
||||||
|
* Git business logic is delegated to sync-service.ts.
|
||||||
|
*
|
||||||
|
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||||
|
* the requireValidWorktree middleware in index.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { performSync } from '../../../services/sync-service.js';
|
||||||
|
|
||||||
|
export function createSyncHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { worktreePath, remote } = req.body as {
|
||||||
|
worktreePath: string;
|
||||||
|
remote?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!worktreePath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'worktreePath required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await performSync(worktreePath, { remote });
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const statusCode = result.hasConflicts ? 409 : 500;
|
||||||
|
res.status(statusCode).json({
|
||||||
|
success: false,
|
||||||
|
error: result.error,
|
||||||
|
hasConflicts: result.hasConflicts,
|
||||||
|
conflictFiles: result.conflictFiles,
|
||||||
|
conflictSource: result.conflictSource,
|
||||||
|
pulled: result.pulled,
|
||||||
|
pushed: result.pushed,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
branch: result.branch,
|
||||||
|
pulled: result.pulled,
|
||||||
|
pushed: result.pushed,
|
||||||
|
isFastForward: result.isFastForward,
|
||||||
|
isMerge: result.isMerge,
|
||||||
|
autoResolved: result.autoResolved,
|
||||||
|
message: result.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Sync worktree failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
@@ -851,7 +910,7 @@ export class AutoModeServiceFacade {
|
|||||||
if (feature) {
|
if (feature) {
|
||||||
title = feature.title;
|
title = feature.title;
|
||||||
description = feature.description;
|
description = feature.description;
|
||||||
branchName = feature.branchName;
|
branchName = feature.branchName ?? undefined;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore
|
// Silently ignore
|
||||||
@@ -1081,10 +1140,31 @@ export class AutoModeServiceFacade {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save execution state for recovery
|
* Save execution state for recovery.
|
||||||
|
*
|
||||||
|
* Uses the active auto-loop config for each worktree so that the persisted
|
||||||
|
* state reflects the real branch and maxConcurrency values rather than the
|
||||||
|
* hard-coded fallbacks (null / DEFAULT_MAX_CONCURRENCY).
|
||||||
*/
|
*/
|
||||||
private async saveExecutionState(): Promise<void> {
|
private async saveExecutionState(): Promise<void> {
|
||||||
return this.saveExecutionStateForProject(null, DEFAULT_MAX_CONCURRENCY);
|
const projectWorktrees = this.autoLoopCoordinator
|
||||||
|
.getActiveWorktrees()
|
||||||
|
.filter((w) => w.projectPath === this.projectPath);
|
||||||
|
|
||||||
|
if (projectWorktrees.length === 0) {
|
||||||
|
// No active auto loops — save with defaults as a best-effort fallback.
|
||||||
|
return this.saveExecutionStateForProject(null, DEFAULT_MAX_CONCURRENCY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save state for every active worktree using its real config values.
|
||||||
|
for (const { branchName } of projectWorktrees) {
|
||||||
|
const config = this.autoLoopCoordinator.getAutoLoopConfigForProject(
|
||||||
|
this.projectPath,
|
||||||
|
branchName
|
||||||
|
);
|
||||||
|
const maxConcurrency = config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
|
||||||
|
await this.saveExecutionStateForProject(branchName, maxConcurrency);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export class GlobalAutoModeService {
|
|||||||
if (feature) {
|
if (feature) {
|
||||||
title = feature.title;
|
title = feature.title;
|
||||||
description = feature.description;
|
description = feature.description;
|
||||||
branchName = feature.branchName;
|
branchName = feature.branchName ?? undefined;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore
|
// Silently ignore
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
258
apps/server/src/services/push-service.ts
Normal file
258
apps/server/src/services/push-service.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* PushService - Push git operations without HTTP
|
||||||
|
*
|
||||||
|
* Encapsulates the full git push workflow including:
|
||||||
|
* - Branch name and detached HEAD detection
|
||||||
|
* - Safe array-based command execution (no shell interpolation)
|
||||||
|
* - Divergent branch detection and auto-resolution via pull-then-retry
|
||||||
|
* - Structured result reporting
|
||||||
|
*
|
||||||
|
* Mirrors the pull-service.ts pattern for consistency.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createLogger, getErrorMessage } from '@automaker/utils';
|
||||||
|
import { execGitCommand } from '@automaker/git-utils';
|
||||||
|
import { getCurrentBranch } from '../lib/git.js';
|
||||||
|
import { performPull } from './pull-service.js';
|
||||||
|
|
||||||
|
const logger = createLogger('PushService');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface PushOptions {
|
||||||
|
/** Remote name to push to (defaults to 'origin') */
|
||||||
|
remote?: string;
|
||||||
|
/** Force push */
|
||||||
|
force?: boolean;
|
||||||
|
/** When true and push is rejected due to divergence, pull then retry push */
|
||||||
|
autoResolve?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
branch?: string;
|
||||||
|
pushed?: boolean;
|
||||||
|
/** Whether the push was initially rejected because the branches diverged */
|
||||||
|
diverged?: boolean;
|
||||||
|
/** Whether divergence was automatically resolved via pull-then-retry */
|
||||||
|
autoResolved?: boolean;
|
||||||
|
/** Whether the auto-resolve pull resulted in merge conflicts */
|
||||||
|
hasConflicts?: boolean;
|
||||||
|
/** Files with merge conflicts (only when hasConflicts is true) */
|
||||||
|
conflictFiles?: string[];
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether push error output indicates a diverged/non-fast-forward rejection.
|
||||||
|
*/
|
||||||
|
function isDivergenceError(errorOutput: string): boolean {
|
||||||
|
const lower = errorOutput.toLowerCase();
|
||||||
|
// Require specific divergence indicators rather than just 'rejected' alone,
|
||||||
|
// which could match pre-receive hook rejections or protected branch errors.
|
||||||
|
const hasNonFastForward = lower.includes('non-fast-forward');
|
||||||
|
const hasFetchFirst = lower.includes('fetch first');
|
||||||
|
const hasFailedToPush = lower.includes('failed to push some refs');
|
||||||
|
const hasRejected = lower.includes('rejected');
|
||||||
|
return hasNonFastForward || hasFetchFirst || (hasRejected && hasFailedToPush);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Service Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a git push on the given worktree.
|
||||||
|
*
|
||||||
|
* The workflow:
|
||||||
|
* 1. Get current branch name (detect detached HEAD)
|
||||||
|
* 2. Attempt `git push <remote> <branch>` with safe array args
|
||||||
|
* 3. If push fails with divergence and autoResolve is true:
|
||||||
|
* a. Pull from the same remote (with stash support)
|
||||||
|
* b. If pull succeeds without conflicts, retry push
|
||||||
|
* 4. If push fails with "no upstream" error, retry with --set-upstream
|
||||||
|
* 5. Return structured result
|
||||||
|
*
|
||||||
|
* @param worktreePath - Path to the git worktree
|
||||||
|
* @param options - Push options (remote, force, autoResolve)
|
||||||
|
* @returns PushResult with detailed status information
|
||||||
|
*/
|
||||||
|
export async function performPush(
|
||||||
|
worktreePath: string,
|
||||||
|
options?: PushOptions
|
||||||
|
): Promise<PushResult> {
|
||||||
|
const targetRemote = options?.remote || 'origin';
|
||||||
|
const force = options?.force ?? false;
|
||||||
|
const autoResolve = options?.autoResolve ?? false;
|
||||||
|
|
||||||
|
// 1. Get current branch name
|
||||||
|
let branchName: string;
|
||||||
|
try {
|
||||||
|
branchName = await getCurrentBranch(worktreePath);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to get current branch: ${getErrorMessage(err)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check for detached HEAD state
|
||||||
|
if (branchName === 'HEAD') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Cannot push in detached HEAD state. Please checkout a branch first.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Build push args (no -u flag; upstream is set in the fallback path only when needed)
|
||||||
|
const pushArgs = ['push', targetRemote, branchName];
|
||||||
|
if (force) {
|
||||||
|
pushArgs.push('--force');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Attempt push
|
||||||
|
try {
|
||||||
|
await execGitCommand(pushArgs, worktreePath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
branch: branchName,
|
||||||
|
pushed: true,
|
||||||
|
message: `Successfully pushed ${branchName} to ${targetRemote}`,
|
||||||
|
};
|
||||||
|
} catch (pushError: unknown) {
|
||||||
|
const err = pushError as { stderr?: string; stdout?: string; message?: string };
|
||||||
|
const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`;
|
||||||
|
|
||||||
|
// 5. Check if the error is a divergence rejection
|
||||||
|
if (isDivergenceError(errorOutput)) {
|
||||||
|
if (!autoResolve) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
branch: branchName,
|
||||||
|
pushed: false,
|
||||||
|
diverged: true,
|
||||||
|
error: `Push rejected: remote has changes not present locally. Use sync or pull first, or enable auto-resolve.`,
|
||||||
|
message: `Push to ${targetRemote} was rejected because the remote branch has diverged.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Auto-resolve: pull then retry push
|
||||||
|
logger.info('Push rejected due to divergence, attempting auto-resolve via pull', {
|
||||||
|
worktreePath,
|
||||||
|
remote: targetRemote,
|
||||||
|
branch: branchName,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pullResult = await performPull(worktreePath, {
|
||||||
|
remote: targetRemote,
|
||||||
|
stashIfNeeded: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pullResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
branch: branchName,
|
||||||
|
pushed: false,
|
||||||
|
diverged: true,
|
||||||
|
autoResolved: false,
|
||||||
|
error: `Auto-resolve failed during pull: ${pullResult.error}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pullResult.hasConflicts) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
branch: branchName,
|
||||||
|
pushed: false,
|
||||||
|
diverged: true,
|
||||||
|
autoResolved: false,
|
||||||
|
hasConflicts: true,
|
||||||
|
conflictFiles: pullResult.conflictFiles,
|
||||||
|
error:
|
||||||
|
'Auto-resolve pull resulted in merge conflicts. Resolve conflicts and push again.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Retry push after successful pull
|
||||||
|
try {
|
||||||
|
await execGitCommand(pushArgs, worktreePath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
branch: branchName,
|
||||||
|
pushed: true,
|
||||||
|
diverged: true,
|
||||||
|
autoResolved: true,
|
||||||
|
message: `Push succeeded after auto-resolving divergence (pulled from ${targetRemote} first).`,
|
||||||
|
};
|
||||||
|
} catch (retryError: unknown) {
|
||||||
|
const retryErr = retryError as { stderr?: string; message?: string };
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
branch: branchName,
|
||||||
|
pushed: false,
|
||||||
|
diverged: true,
|
||||||
|
autoResolved: false,
|
||||||
|
error: `Push failed after auto-resolve pull: ${retryErr.stderr || retryErr.message || 'Unknown error'}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (pullError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
branch: branchName,
|
||||||
|
pushed: false,
|
||||||
|
diverged: true,
|
||||||
|
autoResolved: false,
|
||||||
|
error: `Auto-resolve pull failed: ${getErrorMessage(pullError)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6b. Non-divergence error (e.g. no upstream configured) - retry with --set-upstream
|
||||||
|
const isNoUpstreamError =
|
||||||
|
errorOutput.toLowerCase().includes('no upstream') ||
|
||||||
|
errorOutput.toLowerCase().includes('has no upstream branch') ||
|
||||||
|
errorOutput.toLowerCase().includes('set-upstream');
|
||||||
|
if (isNoUpstreamError) {
|
||||||
|
try {
|
||||||
|
const setUpstreamArgs = ['push', '--set-upstream', targetRemote, branchName];
|
||||||
|
if (force) {
|
||||||
|
setUpstreamArgs.push('--force');
|
||||||
|
}
|
||||||
|
await execGitCommand(setUpstreamArgs, worktreePath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
branch: branchName,
|
||||||
|
pushed: true,
|
||||||
|
message: `Successfully pushed ${branchName} to ${targetRemote} (set upstream)`,
|
||||||
|
};
|
||||||
|
} catch (upstreamError: unknown) {
|
||||||
|
const upstreamErr = upstreamError as { stderr?: string; message?: string };
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
branch: branchName,
|
||||||
|
pushed: false,
|
||||||
|
error: upstreamErr.stderr || upstreamErr.message || getErrorMessage(pushError),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6c. Other push error - return as-is
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
branch: branchName,
|
||||||
|
pushed: false,
|
||||||
|
error: err.stderr || err.message || getErrorMessage(pushError),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -573,6 +573,17 @@ export class SettingsService {
|
|||||||
ignoreEmptyArrayOverwrite('claudeApiProfiles');
|
ignoreEmptyArrayOverwrite('claudeApiProfiles');
|
||||||
// Note: claudeCompatibleProviders intentionally NOT guarded - users should be able to delete all providers
|
// Note: claudeCompatibleProviders intentionally NOT guarded - users should be able to delete all providers
|
||||||
|
|
||||||
|
// Check for explicit permission to clear eventHooks (escape hatch for intentional clearing)
|
||||||
|
const allowEmptyEventHooks =
|
||||||
|
(sanitizedUpdates as Record<string, unknown>).__allowEmptyEventHooks === true;
|
||||||
|
// Remove the flag so it doesn't get persisted
|
||||||
|
delete (sanitizedUpdates as Record<string, unknown>).__allowEmptyEventHooks;
|
||||||
|
|
||||||
|
// Only guard eventHooks if explicit permission wasn't granted
|
||||||
|
if (!allowEmptyEventHooks) {
|
||||||
|
ignoreEmptyArrayOverwrite('eventHooks');
|
||||||
|
}
|
||||||
|
|
||||||
// Empty object overwrite guard
|
// Empty object overwrite guard
|
||||||
const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
|
const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
|
||||||
const nextVal = sanitizedUpdates[key] as unknown;
|
const nextVal = sanitizedUpdates[key] as unknown;
|
||||||
|
|||||||
209
apps/server/src/services/sync-service.ts
Normal file
209
apps/server/src/services/sync-service.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* SyncService - Pull then push in a single operation
|
||||||
|
*
|
||||||
|
* Composes performPull() and performPush() to synchronize a branch
|
||||||
|
* with its remote. Always uses stashIfNeeded for the pull step.
|
||||||
|
* If push fails with divergence after pull, retries once.
|
||||||
|
*
|
||||||
|
* Follows the same pattern as pull-service.ts and push-service.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createLogger, getErrorMessage } from '@automaker/utils';
|
||||||
|
import { performPull } from './pull-service.js';
|
||||||
|
import { performPush } from './push-service.js';
|
||||||
|
import type { PullResult } from './pull-service.js';
|
||||||
|
import type { PushResult } from './push-service.js';
|
||||||
|
|
||||||
|
const logger = createLogger('SyncService');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface SyncOptions {
|
||||||
|
/** Remote name (defaults to 'origin') */
|
||||||
|
remote?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
branch?: string;
|
||||||
|
/** Whether the pull step was performed */
|
||||||
|
pulled?: boolean;
|
||||||
|
/** Whether the push step was performed */
|
||||||
|
pushed?: boolean;
|
||||||
|
/** Pull resulted in conflicts */
|
||||||
|
hasConflicts?: boolean;
|
||||||
|
/** Files with merge conflicts */
|
||||||
|
conflictFiles?: string[];
|
||||||
|
/** Source of conflicts ('pull' | 'stash') */
|
||||||
|
conflictSource?: 'pull' | 'stash';
|
||||||
|
/** Whether the pull was a fast-forward */
|
||||||
|
isFastForward?: boolean;
|
||||||
|
/** Whether the pull resulted in a merge commit */
|
||||||
|
isMerge?: boolean;
|
||||||
|
/** Whether push divergence was auto-resolved */
|
||||||
|
autoResolved?: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Service Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a sync operation (pull then push) on the given worktree.
|
||||||
|
*
|
||||||
|
* The workflow:
|
||||||
|
* 1. Pull from remote with stashIfNeeded: true
|
||||||
|
* 2. If pull has conflicts, stop and return conflict info
|
||||||
|
* 3. Push to remote
|
||||||
|
* 4. If push fails with divergence after pull, retry once
|
||||||
|
*
|
||||||
|
* @param worktreePath - Path to the git worktree
|
||||||
|
* @param options - Sync options (remote)
|
||||||
|
* @returns SyncResult with detailed status information
|
||||||
|
*/
|
||||||
|
export async function performSync(
|
||||||
|
worktreePath: string,
|
||||||
|
options?: SyncOptions
|
||||||
|
): Promise<SyncResult> {
|
||||||
|
const targetRemote = options?.remote || 'origin';
|
||||||
|
|
||||||
|
// 1. Pull from remote
|
||||||
|
logger.info('Sync: starting pull', { worktreePath, remote: targetRemote });
|
||||||
|
|
||||||
|
let pullResult: PullResult;
|
||||||
|
try {
|
||||||
|
pullResult = await performPull(worktreePath, {
|
||||||
|
remote: targetRemote,
|
||||||
|
stashIfNeeded: true,
|
||||||
|
});
|
||||||
|
} catch (pullError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Sync pull failed: ${getErrorMessage(pullError)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pullResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
branch: pullResult.branch,
|
||||||
|
pulled: false,
|
||||||
|
pushed: false,
|
||||||
|
error: `Sync pull failed: ${pullResult.error}`,
|
||||||
|
hasConflicts: pullResult.hasConflicts,
|
||||||
|
conflictFiles: pullResult.conflictFiles,
|
||||||
|
conflictSource: pullResult.conflictSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If pull had conflicts, stop and return conflict info
|
||||||
|
if (pullResult.hasConflicts) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
branch: pullResult.branch,
|
||||||
|
pulled: true,
|
||||||
|
pushed: false,
|
||||||
|
hasConflicts: true,
|
||||||
|
conflictFiles: pullResult.conflictFiles,
|
||||||
|
conflictSource: pullResult.conflictSource,
|
||||||
|
isFastForward: pullResult.isFastForward,
|
||||||
|
isMerge: pullResult.isMerge,
|
||||||
|
error: 'Sync stopped: pull resulted in merge conflicts. Resolve conflicts and try again.',
|
||||||
|
message: pullResult.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Push to remote
|
||||||
|
logger.info('Sync: pull succeeded, starting push', { worktreePath, remote: targetRemote });
|
||||||
|
|
||||||
|
let pushResult: PushResult;
|
||||||
|
try {
|
||||||
|
pushResult = await performPush(worktreePath, {
|
||||||
|
remote: targetRemote,
|
||||||
|
});
|
||||||
|
} catch (pushError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
branch: pullResult.branch,
|
||||||
|
pulled: true,
|
||||||
|
pushed: false,
|
||||||
|
isFastForward: pullResult.isFastForward,
|
||||||
|
isMerge: pullResult.isMerge,
|
||||||
|
error: `Sync push failed: ${getErrorMessage(pushError)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pushResult.success) {
|
||||||
|
// 4. If push diverged after pull, retry once with autoResolve
|
||||||
|
if (pushResult.diverged) {
|
||||||
|
logger.info('Sync: push diverged after pull, retrying with autoResolve', {
|
||||||
|
worktreePath,
|
||||||
|
remote: targetRemote,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const retryResult = await performPush(worktreePath, {
|
||||||
|
remote: targetRemote,
|
||||||
|
autoResolve: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (retryResult.success) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
branch: retryResult.branch,
|
||||||
|
pulled: true,
|
||||||
|
pushed: true,
|
||||||
|
autoResolved: true,
|
||||||
|
isFastForward: pullResult.isFastForward,
|
||||||
|
isMerge: pullResult.isMerge,
|
||||||
|
message: 'Sync completed (push required auto-resolve).',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
branch: retryResult.branch,
|
||||||
|
pulled: true,
|
||||||
|
pushed: false,
|
||||||
|
hasConflicts: retryResult.hasConflicts,
|
||||||
|
conflictFiles: retryResult.conflictFiles,
|
||||||
|
error: retryResult.error,
|
||||||
|
};
|
||||||
|
} catch (retryError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
branch: pullResult.branch,
|
||||||
|
pulled: true,
|
||||||
|
pushed: false,
|
||||||
|
error: `Sync push retry failed: ${getErrorMessage(retryError)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
branch: pushResult.branch,
|
||||||
|
pulled: true,
|
||||||
|
pushed: false,
|
||||||
|
isFastForward: pullResult.isFastForward,
|
||||||
|
isMerge: pullResult.isMerge,
|
||||||
|
error: `Sync push failed: ${pushResult.error}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
branch: pushResult.branch,
|
||||||
|
pulled: pullResult.pulled ?? true,
|
||||||
|
pushed: true,
|
||||||
|
isFastForward: pullResult.isFastForward,
|
||||||
|
isMerge: pullResult.isMerge,
|
||||||
|
message: pullResult.pulled
|
||||||
|
? 'Sync completed: pulled latest changes and pushed.'
|
||||||
|
: 'Sync completed: already up to date, pushed local commits.',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -320,8 +320,10 @@ describe('codex-provider.ts', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
||||||
// High reasoning effort should have 3x the default timeout (90000ms)
|
// High reasoning effort should have 3x the CLI base timeout (120000ms)
|
||||||
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high);
|
// CODEX_CLI_TIMEOUT_MS = 120000, multiplier for 'high' = 3.0 → 360000ms
|
||||||
|
const CODEX_CLI_TIMEOUT_MS = 120000;
|
||||||
|
expect(call.timeout).toBe(CODEX_CLI_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes extended timeout for xhigh reasoning effort', async () => {
|
it('passes extended timeout for xhigh reasoning effort', async () => {
|
||||||
@@ -357,8 +359,10 @@ describe('codex-provider.ts', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
||||||
// No reasoning effort should use the default timeout
|
// No reasoning effort should use the CLI base timeout (2 minutes)
|
||||||
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS);
|
// CODEX_CLI_TIMEOUT_MS = 120000ms, no multiplier applied
|
||||||
|
const CODEX_CLI_TIMEOUT_MS = 120000;
|
||||||
|
expect(call.timeout).toBe(CODEX_CLI_TIMEOUT_MS);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
Maximize2,
|
Maximize2,
|
||||||
Check,
|
Check,
|
||||||
Undo2,
|
Undo2,
|
||||||
|
RefreshCw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -36,8 +37,7 @@ import { Switch } from '@/components/ui/switch';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { Markdown } from '@/components/ui/markdown';
|
import { Markdown } from '@/components/ui/markdown';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { cn, modelSupportsThinking, generateUUID } from '@/lib/utils';
|
||||||
import { cn, modelSupportsThinking } from '@/lib/utils';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useGitHubPRReviewComments } from '@/hooks/queries';
|
import { useGitHubPRReviewComments } from '@/hooks/queries';
|
||||||
import { useCreateFeature, useResolveReviewThread } from '@/hooks/mutations';
|
import { useCreateFeature, useResolveReviewThread } from '@/hooks/mutations';
|
||||||
@@ -46,7 +46,8 @@ import type { PRReviewComment } from '@/lib/electron';
|
|||||||
import type { Feature } from '@/store/app-store';
|
import type { Feature } from '@/store/app-store';
|
||||||
import type { PhaseModelEntry } from '@automaker/types';
|
import type { PhaseModelEntry } from '@automaker/types';
|
||||||
import { supportsReasoningEffort, isAdaptiveThinkingModel } from '@automaker/types';
|
import { supportsReasoningEffort, isAdaptiveThinkingModel } from '@automaker/types';
|
||||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
|
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults';
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Types
|
// Types
|
||||||
@@ -75,7 +76,7 @@ interface PRCommentResolutionDialogProps {
|
|||||||
|
|
||||||
/** Generate a feature ID */
|
/** Generate a feature ID */
|
||||||
function generateFeatureId(): string {
|
function generateFeatureId(): string {
|
||||||
return `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
return generateUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format a date string for display */
|
/** Format a date string for display */
|
||||||
@@ -247,39 +248,22 @@ function CommentRow({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-start gap-3 p-3 rounded-lg border border-border transition-colors cursor-pointer',
|
'flex items-start gap-3 p-3 rounded-lg border border-border transition-colors',
|
||||||
|
needsExpansion ? 'cursor-pointer' : 'cursor-default',
|
||||||
isSelected ? 'bg-accent/50 border-primary/30' : 'hover:bg-accent/30'
|
isSelected ? 'bg-accent/50 border-primary/30' : 'hover:bg-accent/30'
|
||||||
)}
|
)}
|
||||||
onClick={onToggle}
|
onClick={needsExpansion ? () => setIsExpanded((prev) => !prev) : undefined}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onCheckedChange={() => onToggle()}
|
onCheckedChange={() => onToggle()}
|
||||||
className="mt-0.5"
|
className="mt-0.5 shrink-0"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Header: disclosure triangle + author + file location + tags */}
|
{/* Header: disclosure triangle + author + file location + tags */}
|
||||||
<div className="flex items-start gap-1.5 flex-wrap mb-1">
|
<div className="flex items-start gap-1.5 flex-wrap mb-1">
|
||||||
{/* Disclosure triangle - always shown, toggles expand/collapse */}
|
|
||||||
{needsExpansion ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleExpandToggle}
|
|
||||||
className="mt-0.5 shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
title={isExpanded ? 'Collapse comment' : 'Expand comment'}
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-3.5 w-3.5 -rotate-90" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="mt-0.5 shrink-0 w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap flex-1 min-w-0">
|
<div className="flex items-center gap-2 flex-wrap flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{comment.avatarUrl ? (
|
{comment.avatarUrl ? (
|
||||||
@@ -303,6 +287,12 @@ function CommentRow({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{comment.isBot && (
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-purple-500/10 text-purple-500">
|
||||||
|
Bot
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{comment.isOutdated && (
|
{comment.isOutdated && (
|
||||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-yellow-500/10 text-yellow-500">
|
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-yellow-500/10 text-yellow-500">
|
||||||
Outdated
|
Outdated
|
||||||
@@ -346,27 +336,47 @@ function CommentRow({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Expand detail button */}
|
<div className="ml-auto shrink-0 flex items-center gap-1">
|
||||||
<button
|
{/* Disclosure triangle - toggles expand/collapse */}
|
||||||
type="button"
|
{needsExpansion ? (
|
||||||
onClick={handleExpandDetail}
|
<button
|
||||||
className="ml-auto shrink-0 text-muted-foreground hover:text-foreground transition-colors p-0.5 rounded hover:bg-muted"
|
type="button"
|
||||||
title="View full comment details"
|
onClick={handleExpandToggle}
|
||||||
>
|
className="text-muted-foreground hover:text-foreground transition-colors p-0.5 rounded hover:bg-muted"
|
||||||
<Maximize2 className="h-3.5 w-3.5" />
|
title={isExpanded ? 'Collapse comment' : 'Expand comment'}
|
||||||
</button>
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 -rotate-90" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expand detail button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExpandDetail}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors p-0.5 rounded hover:bg-muted"
|
||||||
|
title="View full comment details"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comment body - collapsible, rendered as markdown */}
|
{/* Comment body - collapsible, rendered as markdown */}
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<div className="pl-5" onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Markdown className="text-sm [&_p]:text-muted-foreground [&_li]:text-muted-foreground">
|
<Markdown className="text-sm [&_p]:text-muted-foreground [&_li]:text-muted-foreground">
|
||||||
{comment.body}
|
{comment.body}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="pl-5 line-clamp-2">
|
<div className="line-clamp-2">
|
||||||
<Markdown className="text-sm [&_p]:text-muted-foreground [&_li]:text-muted-foreground [&_p]:my-0 [&_ul]:my-0 [&_ol]:my-0 [&_h1]:text-sm [&_h2]:text-sm [&_h3]:text-sm [&_h4]:text-sm [&_h1]:my-0 [&_h2]:my-0 [&_h3]:my-0 [&_h4]:my-0 [&_pre]:my-0 [&_blockquote]:my-0">
|
<Markdown className="text-sm [&_p]:text-muted-foreground [&_li]:text-muted-foreground [&_p]:my-0 [&_ul]:my-0 [&_ol]:my-0 [&_h1]:text-sm [&_h2]:text-sm [&_h3]:text-sm [&_h4]:text-sm [&_h1]:my-0 [&_h2]:my-0 [&_h3]:my-0 [&_h4]:my-0 [&_pre]:my-0 [&_blockquote]:my-0">
|
||||||
{comment.body}
|
{comment.body}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
@@ -374,7 +384,7 @@ function CommentRow({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Date row */}
|
{/* Date row */}
|
||||||
<div className="flex items-center mt-1 pl-5">
|
<div className="flex items-center mt-1">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="text-xs text-muted-foreground">{formatDate(comment.createdAt)}</div>
|
<div className="text-xs text-muted-foreground">{formatDate(comment.createdAt)}</div>
|
||||||
<div className="text-xs text-muted-foreground/70">{formatTime(comment.createdAt)}</div>
|
<div className="text-xs text-muted-foreground/70">{formatTime(comment.createdAt)}</div>
|
||||||
@@ -413,7 +423,7 @@ function CommentDetailDialog({ comment, open, onOpenChange }: CommentDetailDialo
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<ScrollArea className="flex-1 min-h-0 h-full -mx-6 px-6">
|
<div className="flex-1 min-h-0 overflow-y-auto -mx-6 px-6">
|
||||||
<div className="space-y-4 pb-2">
|
<div className="space-y-4 pb-2">
|
||||||
{/* Author & metadata section */}
|
{/* Author & metadata section */}
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
@@ -439,6 +449,11 @@ function CommentDetailDialog({ comment, open, onOpenChange }: CommentDetailDialo
|
|||||||
|
|
||||||
{/* Badges */}
|
{/* Badges */}
|
||||||
<div className="flex items-center gap-1.5 ml-auto">
|
<div className="flex items-center gap-1.5 ml-auto">
|
||||||
|
{comment.isBot && (
|
||||||
|
<span className="px-2 py-0.5 text-xs font-medium rounded bg-purple-500/10 text-purple-500">
|
||||||
|
Bot
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{comment.isOutdated && (
|
{comment.isOutdated && (
|
||||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-yellow-500/10 text-yellow-500">
|
<span className="px-2 py-0.5 text-xs font-medium rounded bg-yellow-500/10 text-yellow-500">
|
||||||
Outdated
|
Outdated
|
||||||
@@ -495,7 +510,7 @@ function CommentDetailDialog({ comment, open, onOpenChange }: CommentDetailDialo
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
<DialogFooter className="mt-4">
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
@@ -565,22 +580,15 @@ export function PRCommentResolutionDialog({
|
|||||||
>([]);
|
>([]);
|
||||||
const [detailComment, setDetailComment] = useState<PRReviewComment | null>(null);
|
const [detailComment, setDetailComment] = useState<PRReviewComment | null>(null);
|
||||||
|
|
||||||
|
// Per-thread resolving state - tracks which threads are currently being resolved/unresolved
|
||||||
|
const [resolvingThreads, setResolvingThreads] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Model selection state
|
// Model selection state
|
||||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'claude-sonnet' });
|
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'claude-sonnet' });
|
||||||
|
|
||||||
// Track previous open state to detect when dialog opens
|
// Track previous open state to detect when dialog opens
|
||||||
const wasOpenRef = useRef(false);
|
const wasOpenRef = useRef(false);
|
||||||
|
|
||||||
// Sync model defaults only when dialog opens (transitions from closed to open)
|
|
||||||
useEffect(() => {
|
|
||||||
const justOpened = open && !wasOpenRef.current;
|
|
||||||
wasOpenRef.current = open;
|
|
||||||
|
|
||||||
if (justOpened) {
|
|
||||||
setModelEntry(effectiveDefaultFeatureModel);
|
|
||||||
}
|
|
||||||
}, [open, effectiveDefaultFeatureModel]);
|
|
||||||
|
|
||||||
const handleModelChange = useCallback((entry: PhaseModelEntry) => {
|
const handleModelChange = useCallback((entry: PhaseModelEntry) => {
|
||||||
// Normalize thinking level when switching between adaptive and non-adaptive models
|
// Normalize thinking level when switching between adaptive and non-adaptive models
|
||||||
const isNewModelAdaptive =
|
const isNewModelAdaptive =
|
||||||
@@ -602,10 +610,23 @@ export function PRCommentResolutionDialog({
|
|||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isLoading: loading,
|
isLoading: loading,
|
||||||
|
isFetching: refreshing,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
} = useGitHubPRReviewComments(currentProject?.path, open ? pr.number : undefined);
|
} = useGitHubPRReviewComments(currentProject?.path, open ? pr.number : undefined);
|
||||||
|
|
||||||
|
// Sync model defaults and refresh comments when dialog opens (transitions from closed to open)
|
||||||
|
useEffect(() => {
|
||||||
|
const justOpened = open && !wasOpenRef.current;
|
||||||
|
wasOpenRef.current = open;
|
||||||
|
|
||||||
|
if (justOpened) {
|
||||||
|
setModelEntry(effectiveDefaultFeatureModel);
|
||||||
|
// Force refresh PR comments from GitHub when dialog opens
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
}, [open, effectiveDefaultFeatureModel, refetch]);
|
||||||
|
|
||||||
const allComments = useMemo(() => {
|
const allComments = useMemo(() => {
|
||||||
const raw = data?.comments ?? [];
|
const raw = data?.comments ?? [];
|
||||||
// Sort based on current sort order
|
// Sort based on current sort order
|
||||||
@@ -635,8 +656,8 @@ export function PRCommentResolutionDialog({
|
|||||||
const resolveThread = useResolveReviewThread(currentProject?.path ?? '', pr.number);
|
const resolveThread = useResolveReviewThread(currentProject?.path ?? '', pr.number);
|
||||||
|
|
||||||
// Derived state
|
// Derived state
|
||||||
const allSelected = comments.length > 0 && selectedIds.size === comments.length;
|
const allSelected = comments.length > 0 && comments.every((c) => selectedIds.has(c.id));
|
||||||
const someSelected = selectedIds.size > 0 && selectedIds.size < comments.length;
|
const someSelected = selectedIds.size > 0 && !allSelected;
|
||||||
const noneSelected = selectedIds.size === 0;
|
const noneSelected = selectedIds.size === 0;
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -658,7 +679,24 @@ export function PRCommentResolutionDialog({
|
|||||||
const handleResolveComment = useCallback(
|
const handleResolveComment = useCallback(
|
||||||
(comment: PRReviewComment, resolve: boolean) => {
|
(comment: PRReviewComment, resolve: boolean) => {
|
||||||
if (!comment.threadId) return;
|
if (!comment.threadId) return;
|
||||||
resolveThread.mutate({ threadId: comment.threadId, resolve });
|
const threadId = comment.threadId;
|
||||||
|
setResolvingThreads((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(threadId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
resolveThread.mutate(
|
||||||
|
{ threadId, resolve },
|
||||||
|
{
|
||||||
|
onSettled: () => {
|
||||||
|
setResolvingThreads((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(threadId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[resolveThread]
|
[resolveThread]
|
||||||
);
|
);
|
||||||
@@ -703,7 +741,7 @@ export function PRCommentResolutionDialog({
|
|||||||
const selectedComments = comments.filter((c) => selectedIds.has(c.id));
|
const selectedComments = comments.filter((c) => selectedIds.has(c.id));
|
||||||
|
|
||||||
// Resolve model settings from the current model entry
|
// Resolve model settings from the current model entry
|
||||||
const selectedModel = modelEntry.model;
|
const selectedModel = resolveModelString(modelEntry.model);
|
||||||
const normalizedThinking = modelSupportsThinking(selectedModel)
|
const normalizedThinking = modelSupportsThinking(selectedModel)
|
||||||
? modelEntry.thinkingLevel || 'none'
|
? modelEntry.thinkingLevel || 'none'
|
||||||
: 'none';
|
: 'none';
|
||||||
@@ -810,6 +848,7 @@ export function PRCommentResolutionDialog({
|
|||||||
setShowResolved(false);
|
setShowResolved(false);
|
||||||
setCreationErrors([]);
|
setCreationErrors([]);
|
||||||
setDetailComment(null);
|
setDetailComment(null);
|
||||||
|
setResolvingThreads(new Set());
|
||||||
setModelEntry(effectiveDefaultFeatureModel);
|
setModelEntry(effectiveDefaultFeatureModel);
|
||||||
}
|
}
|
||||||
onOpenChange(newOpen);
|
onOpenChange(newOpen);
|
||||||
@@ -825,10 +864,22 @@ export function PRCommentResolutionDialog({
|
|||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="max-w-3xl max-h-[80vh] flex flex-col">
|
<DialogContent className="max-w-3xl max-h-[80vh] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<div className="flex items-center justify-between pr-10">
|
||||||
<MessageSquare className="h-5 w-5 text-blue-500" />
|
<DialogTitle className="flex items-center gap-2">
|
||||||
Manage PR Review Comments
|
<MessageSquare className="h-5 w-5 text-blue-500" />
|
||||||
</DialogTitle>
|
Manage PR Review Comments
|
||||||
|
</DialogTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 shrink-0"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={refreshing}
|
||||||
|
title="Refresh comments"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Select comments from PR #{pr.number} to create feature tasks that address them.
|
Select comments from PR #{pr.number} to create feature tasks that address them.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -863,7 +914,7 @@ export function PRCommentResolutionDialog({
|
|||||||
{!loading && !error && allComments.length > 0 && (
|
{!loading && !error && allComments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{/* Controls Bar */}
|
{/* Controls Bar */}
|
||||||
<div className="flex items-center justify-between gap-4 px-1">
|
<div className="flex flex-wrap items-center justify-between gap-2 px-1">
|
||||||
{/* Select All - only interactive when there are visible comments */}
|
{/* Select All - only interactive when there are visible comments */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -884,7 +935,7 @@ export function PRCommentResolutionDialog({
|
|||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{/* Show/Hide Resolved Filter Toggle - always visible */}
|
{/* Show/Hide Resolved Filter Toggle - always visible */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -939,7 +990,7 @@ export function PRCommentResolutionDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Mode Toggle */}
|
{/* Mode Toggle */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Label
|
<Label
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs cursor-pointer',
|
'text-xs cursor-pointer',
|
||||||
@@ -1008,7 +1059,9 @@ export function PRCommentResolutionDialog({
|
|||||||
onToggle={() => handleToggleComment(comment.id)}
|
onToggle={() => handleToggleComment(comment.id)}
|
||||||
onExpandDetail={() => setDetailComment(comment)}
|
onExpandDetail={() => setDetailComment(comment)}
|
||||||
onResolve={handleResolveComment}
|
onResolve={handleResolveComment}
|
||||||
isResolvingThread={resolveThread.isPending}
|
isResolvingThread={
|
||||||
|
!!comment.threadId && resolvingThreads.has(comment.threadId)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -112,9 +112,17 @@ export function ProjectSwitcher() {
|
|||||||
// Continue with switch even if initialization fails -
|
// Continue with switch even if initialization fails -
|
||||||
// the project may already be initialized
|
// the project may already be initialized
|
||||||
}
|
}
|
||||||
setCurrentProject(project);
|
// Wrap in startTransition to let React batch the project switch and
|
||||||
// Navigate to board view when switching projects
|
// navigation into a single low-priority update. Without this, the two
|
||||||
navigate({ to: '/board' });
|
// 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]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -33,11 +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 {
|
import {
|
||||||
|
BoardBackgroundModal,
|
||||||
PRCommentResolutionDialog,
|
PRCommentResolutionDialog,
|
||||||
type PRCommentResolutionPRInfo,
|
type PRCommentResolutionPRInfo,
|
||||||
} from '@/components/dialogs/pr-comment-resolution-dialog';
|
} 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';
|
||||||
@@ -99,6 +99,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||||||
import { queryKeys } from '@/lib/query-keys';
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { useAutoModeQueryInvalidation } from '@/hooks/use-query-invalidation';
|
import { useAutoModeQueryInvalidation } from '@/hooks/use-query-invalidation';
|
||||||
import { useUpdateGlobalSettings } from '@/hooks/mutations/use-settings-mutations';
|
import { useUpdateGlobalSettings } from '@/hooks/mutations/use-settings-mutations';
|
||||||
|
import { forceSyncSettingsToServer } from '@/hooks/use-settings-sync';
|
||||||
|
|
||||||
// Stable empty array to avoid infinite loop in selector
|
// Stable empty array to avoid infinite loop in selector
|
||||||
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
|
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
|
||||||
@@ -114,6 +115,7 @@ export function BoardView() {
|
|||||||
pendingPlanApproval,
|
pendingPlanApproval,
|
||||||
setPendingPlanApproval,
|
setPendingPlanApproval,
|
||||||
updateFeature,
|
updateFeature,
|
||||||
|
batchUpdateFeatures,
|
||||||
getCurrentWorktree,
|
getCurrentWorktree,
|
||||||
setCurrentWorktree,
|
setCurrentWorktree,
|
||||||
getWorktrees,
|
getWorktrees,
|
||||||
@@ -132,6 +134,7 @@ export function BoardView() {
|
|||||||
pendingPlanApproval: state.pendingPlanApproval,
|
pendingPlanApproval: state.pendingPlanApproval,
|
||||||
setPendingPlanApproval: state.setPendingPlanApproval,
|
setPendingPlanApproval: state.setPendingPlanApproval,
|
||||||
updateFeature: state.updateFeature,
|
updateFeature: state.updateFeature,
|
||||||
|
batchUpdateFeatures: state.batchUpdateFeatures,
|
||||||
getCurrentWorktree: state.getCurrentWorktree,
|
getCurrentWorktree: state.getCurrentWorktree,
|
||||||
setCurrentWorktree: state.setCurrentWorktree,
|
setCurrentWorktree: state.setCurrentWorktree,
|
||||||
getWorktrees: state.getWorktrees,
|
getWorktrees: state.getWorktrees,
|
||||||
@@ -411,25 +414,34 @@ export function BoardView() {
|
|||||||
currentProject,
|
currentProject,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Shared helper: batch-reset branch assignment and persist for each affected feature.
|
||||||
|
// Used when worktrees are deleted or branches are removed during merge.
|
||||||
|
const batchResetBranchFeatures = useCallback(
|
||||||
|
(branchName: string) => {
|
||||||
|
const affectedIds = hookFeatures.filter((f) => f.branchName === branchName).map((f) => f.id);
|
||||||
|
if (affectedIds.length === 0) return;
|
||||||
|
const updates: Partial<Feature> = { branchName: null };
|
||||||
|
batchUpdateFeatures(affectedIds, updates);
|
||||||
|
for (const id of affectedIds) {
|
||||||
|
persistFeatureUpdate(id, updates).catch((err: unknown) => {
|
||||||
|
console.error(
|
||||||
|
`[batchResetBranchFeatures] Failed to persist update for feature ${id}:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hookFeatures, batchUpdateFeatures, persistFeatureUpdate]
|
||||||
|
);
|
||||||
|
|
||||||
// Memoize the removed worktrees handler to prevent infinite loops
|
// Memoize the removed worktrees handler to prevent infinite loops
|
||||||
const handleRemovedWorktrees = useCallback(
|
const handleRemovedWorktrees = useCallback(
|
||||||
(removedWorktrees: Array<{ path: string; branch: string }>) => {
|
(removedWorktrees: Array<{ path: string; branch: string }>) => {
|
||||||
// Reset features that were assigned to the removed worktrees (by branch)
|
for (const { branch } of removedWorktrees) {
|
||||||
hookFeatures.forEach((feature) => {
|
batchResetBranchFeatures(branch);
|
||||||
const matchesRemovedWorktree = removedWorktrees.some((removed) => {
|
}
|
||||||
// Match by branch name since worktreePath is no longer stored
|
|
||||||
return feature.branchName === removed.branch;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (matchesRemovedWorktree) {
|
|
||||||
// Reset the feature's branch assignment - update both local state and persist
|
|
||||||
const updates = { branchName: null as unknown as string | undefined };
|
|
||||||
updateFeature(feature.id, updates);
|
|
||||||
persistFeatureUpdate(feature.id, updates);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[hookFeatures, updateFeature, persistFeatureUpdate]
|
[batchResetBranchFeatures]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get current worktree info (path) for filtering features
|
// Get current worktree info (path) for filtering features
|
||||||
@@ -437,57 +449,80 @@ export function BoardView() {
|
|||||||
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
|
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
|
||||||
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
||||||
|
|
||||||
// Track the previous worktree path to detect worktree switches
|
// Select worktrees for the current project directly from the store.
|
||||||
const prevWorktreePathRef = useRef<string | null | undefined>(undefined);
|
// Using a project-scoped selector prevents re-renders when OTHER projects'
|
||||||
|
// worktrees change (the old selector subscribed to the entire worktreesByProject
|
||||||
// When the active worktree changes, invalidate feature queries to ensure
|
// object, causing unnecessary re-renders that cascaded into selectedWorktree →
|
||||||
// feature cards (especially their todo lists / planSpec tasks) render fresh data.
|
// useAutoMode → refreshStatus → setAutoModeRunning → store update → re-render loop
|
||||||
// Without this, cards that unmount when filtered out and remount when the user
|
// that could trigger React error #185 on initial project open).
|
||||||
// switches back may show stale or missing todo list data until the next polling cycle.
|
const currentProjectPath = currentProject?.path;
|
||||||
useEffect(() => {
|
const worktrees = useAppStore(
|
||||||
// Skip the initial mount (prevWorktreePathRef starts as undefined)
|
useCallback(
|
||||||
if (prevWorktreePathRef.current === undefined) {
|
(s) =>
|
||||||
prevWorktreePathRef.current = currentWorktreePath;
|
currentProjectPath
|
||||||
return;
|
? (s.worktreesByProject[currentProjectPath] ?? EMPTY_WORKTREES)
|
||||||
}
|
: EMPTY_WORKTREES,
|
||||||
// Only invalidate when the worktree actually changed
|
[currentProjectPath]
|
||||||
if (prevWorktreePathRef.current !== currentWorktreePath && currentProject?.path) {
|
)
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: queryKeys.features.all(currentProject.path),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
prevWorktreePathRef.current = currentWorktreePath;
|
|
||||||
}, [currentWorktreePath, currentProject?.path, queryClient]);
|
|
||||||
|
|
||||||
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
|
|
||||||
const worktrees = useMemo(
|
|
||||||
() =>
|
|
||||||
currentProject
|
|
||||||
? (worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES)
|
|
||||||
: EMPTY_WORKTREES,
|
|
||||||
[currentProject, worktreesByProject]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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
|
||||||
@@ -1031,7 +1066,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
|
||||||
@@ -1558,17 +1593,7 @@ export function BoardView() {
|
|||||||
onStashPopConflict={handleStashPopConflict}
|
onStashPopConflict={handleStashPopConflict}
|
||||||
onStashApplyConflict={handleStashApplyConflict}
|
onStashApplyConflict={handleStashApplyConflict}
|
||||||
onBranchDeletedDuringMerge={(branchName) => {
|
onBranchDeletedDuringMerge={(branchName) => {
|
||||||
// Reset features that were assigned to the deleted branch (same logic as onDeleted in DeleteWorktreeDialog)
|
batchResetBranchFeatures(branchName);
|
||||||
hookFeatures.forEach((feature) => {
|
|
||||||
if (feature.branchName === branchName) {
|
|
||||||
// Reset the feature's branch assignment - update both local state and persist
|
|
||||||
const updates = {
|
|
||||||
branchName: null as unknown as string | undefined,
|
|
||||||
};
|
|
||||||
updateFeature(feature.id, updates);
|
|
||||||
persistFeatureUpdate(feature.id, updates);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setWorktreeRefreshKey((k) => k + 1);
|
setWorktreeRefreshKey((k) => k + 1);
|
||||||
}}
|
}}
|
||||||
onRemovedWorktrees={handleRemovedWorktrees}
|
onRemovedWorktrees={handleRemovedWorktrees}
|
||||||
@@ -1945,21 +1970,76 @@ export function BoardView() {
|
|||||||
}
|
}
|
||||||
defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)}
|
defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)}
|
||||||
onDeleted={(deletedWorktree, _deletedBranch) => {
|
onDeleted={(deletedWorktree, _deletedBranch) => {
|
||||||
// Reset features that were assigned to the deleted worktree (by branch)
|
// 1. Reset current worktree to main FIRST. This must happen
|
||||||
hookFeatures.forEach((feature) => {
|
// BEFORE removing from the list to ensure downstream hooks
|
||||||
// Match by branch name since worktreePath is no longer stored
|
// (useAutoMode, useBoardFeatures) see a valid worktree and
|
||||||
if (feature.branchName === deletedWorktree.branch) {
|
// never try to render the deleted worktree.
|
||||||
// Reset the feature's branch assignment - update both local state and persist
|
const mainBranch = worktrees.find((w) => w.isMain)?.branch || 'main';
|
||||||
const updates = {
|
setCurrentWorktree(currentProject.path, null, mainBranch);
|
||||||
branchName: null as unknown as string | undefined,
|
|
||||||
|
// 2. Immediately remove the deleted worktree from the store's
|
||||||
|
// worktree list so the UI never renders a stale tab/dropdown
|
||||||
|
// item that can be clicked and cause a crash.
|
||||||
|
const remainingWorktrees = worktrees.filter(
|
||||||
|
(w) => !pathsEqual(w.path, deletedWorktree.path)
|
||||||
|
);
|
||||||
|
setWorktrees(currentProject.path, remainingWorktrees);
|
||||||
|
|
||||||
|
// 3. Cancel any in-flight worktree queries, then optimistically
|
||||||
|
// update the React Query cache so the worktree disappears
|
||||||
|
// from the dropdown immediately. Cancelling first prevents a
|
||||||
|
// pending refetch from overwriting our optimistic update with
|
||||||
|
// stale server data.
|
||||||
|
const worktreeQueryKey = queryKeys.worktrees.all(currentProject.path);
|
||||||
|
void queryClient.cancelQueries({ queryKey: worktreeQueryKey });
|
||||||
|
queryClient.setQueryData(
|
||||||
|
worktreeQueryKey,
|
||||||
|
(
|
||||||
|
old:
|
||||||
|
| {
|
||||||
|
worktrees: WorktreeInfo[];
|
||||||
|
removedWorktrees: Array<{ path: string; branch: string }>;
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
worktrees: old.worktrees.filter(
|
||||||
|
(w: WorktreeInfo) => !pathsEqual(w.path, deletedWorktree.path)
|
||||||
|
),
|
||||||
};
|
};
|
||||||
updateFeature(feature.id, updates);
|
}
|
||||||
persistFeatureUpdate(feature.id, updates);
|
);
|
||||||
|
|
||||||
|
// 4. Batch-reset features assigned to the deleted worktree in one
|
||||||
|
// store mutation to avoid N individual updateFeature calls that
|
||||||
|
// cascade into React error #185.
|
||||||
|
batchResetBranchFeatures(deletedWorktree.branch);
|
||||||
|
|
||||||
|
// 5. Do NOT trigger setWorktreeRefreshKey here. The optimistic
|
||||||
|
// cache update (step 3) already removed the worktree from
|
||||||
|
// both the Zustand store and React Query cache. Incrementing
|
||||||
|
// the refresh key would cause invalidateQueries → server
|
||||||
|
// refetch, and if the server's .worktrees/ directory scan
|
||||||
|
// finds remnants of the deleted worktree, it would re-add
|
||||||
|
// it to the dropdown. The 30-second polling interval in
|
||||||
|
// WorktreePanel will eventually reconcile with the server.
|
||||||
|
setSelectedWorktreeForAction(null);
|
||||||
|
|
||||||
|
// 6. Force-sync settings immediately so the reset worktree
|
||||||
|
// selection is persisted before any potential page reload.
|
||||||
|
// Without this, the debounced sync (1s) may not complete
|
||||||
|
// in time and the stale worktree path survives in
|
||||||
|
// server settings, causing the deleted worktree to
|
||||||
|
// reappear on next load.
|
||||||
|
forceSyncSettingsToServer().then((ok) => {
|
||||||
|
if (!ok) {
|
||||||
|
logger.warn(
|
||||||
|
'forceSyncSettingsToServer failed after worktree deletion; stale path may reappear on reload'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setWorktreeRefreshKey((k) => k + 1);
|
|
||||||
setSelectedWorktreeForAction(null);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -227,9 +227,9 @@ export function AddFeatureDialog({
|
|||||||
|
|
||||||
if (justOpened) {
|
if (justOpened) {
|
||||||
// Initialize with prefilled values if provided, otherwise use defaults
|
// Initialize with prefilled values if provided, otherwise use defaults
|
||||||
setTitle(prefilledTitle || '');
|
setTitle(prefilledTitle ?? '');
|
||||||
setDescription(prefilledDescription || '');
|
setDescription(prefilledDescription ?? '');
|
||||||
setCategory(prefilledCategory || '');
|
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
|
||||||
|
|||||||
@@ -299,9 +299,13 @@ export function CreatePRDialog({
|
|||||||
const api = getHttpApiClient();
|
const api = getHttpApiClient();
|
||||||
// Resolve the display name to the actual branch name for the API
|
// Resolve the display name to the actual branch name for the API
|
||||||
const resolvedRef = branchFullRefMap.get(baseBranch) || baseBranch;
|
const resolvedRef = branchFullRefMap.get(baseBranch) || baseBranch;
|
||||||
const branchNameForApi = resolvedRef.includes('/')
|
// Only strip the remote prefix if the resolved ref differs from the original
|
||||||
? resolvedRef.substring(resolvedRef.indexOf('/') + 1)
|
// (indicating it was resolved from a full ref like "origin/main").
|
||||||
: resolvedRef;
|
// 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);
|
const result = await api.worktree.generatePRDescription(worktree.path, branchNameForApi);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -344,11 +348,13 @@ export function CreatePRDialog({
|
|||||||
// since the backend handles branch resolution. However, if the full ref is
|
// since the backend handles branch resolution. However, if the full ref is
|
||||||
// available, we can use it for more precise targeting.
|
// available, we can use it for more precise targeting.
|
||||||
const resolvedBaseBranch = branchFullRefMap.get(baseBranch) || baseBranch;
|
const resolvedBaseBranch = branchFullRefMap.get(baseBranch) || baseBranch;
|
||||||
// Strip the remote prefix from the resolved ref for the API call
|
// Only strip the remote prefix if the resolved ref differs from the original
|
||||||
// (e.g. "origin/main" → "main") since the backend expects the branch name only
|
// (indicating it was resolved from a full ref like "origin/main").
|
||||||
const baseBranchForApi = resolvedBaseBranch.includes('/')
|
// This preserves local branch names that contain slashes (e.g. "release/1.0").
|
||||||
? resolvedBaseBranch.substring(resolvedBaseBranch.indexOf('/') + 1)
|
const baseBranchForApi =
|
||||||
: resolvedBaseBranch;
|
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,
|
||||||
@@ -485,7 +491,7 @@ export function CreatePRDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-[550px]">
|
<DialogContent className="sm:max-w-[550px] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<GitPullRequest className="w-5 h-5" />
|
<GitPullRequest className="w-5 h-5" />
|
||||||
@@ -559,7 +565,7 @@ export function CreatePRDialog({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4 overflow-y-auto min-h-0 flex-1">
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="commit-message">
|
<Label htmlFor="commit-message">
|
||||||
@@ -733,7 +739,7 @@ export function CreatePRDialog({
|
|||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="shrink-0 pt-2 border-t">
|
||||||
<Button variant="ghost" onClick={handleClose} disabled={isLoading}>
|
<Button variant="ghost" onClick={handleClose} disabled={isLoading}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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...'}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -72,9 +72,19 @@ export function DeleteWorktreeDialog({
|
|||||||
? `Branch "${worktree.branch}" was also deleted`
|
? `Branch "${worktree.branch}" was also deleted`
|
||||||
: `Branch "${worktree.branch}" was kept`,
|
: `Branch "${worktree.branch}" was kept`,
|
||||||
});
|
});
|
||||||
onDeleted(worktree, deleteBranch);
|
// Close the dialog first, then notify the parent.
|
||||||
|
// This ensures the dialog unmounts before the parent
|
||||||
|
// triggers potentially heavy state updates (feature branch
|
||||||
|
// resets, worktree refresh), reducing concurrent re-renders
|
||||||
|
// that can cascade into React error #185.
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setDeleteBranch(false);
|
setDeleteBranch(false);
|
||||||
|
try {
|
||||||
|
onDeleted(worktree, deleteBranch);
|
||||||
|
} catch (error) {
|
||||||
|
// Prevent errors in onDeleted from propagating to the error boundary
|
||||||
|
console.error('onDeleted callback failed:', error);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error('Failed to delete worktree', {
|
toast.error('Failed to delete worktree', {
|
||||||
description: result.error,
|
description: result.error,
|
||||||
|
|||||||
@@ -84,17 +84,19 @@ export function useBoardActions({
|
|||||||
onWorktreeAutoSelect,
|
onWorktreeAutoSelect,
|
||||||
currentWorktreeBranch,
|
currentWorktreeBranch,
|
||||||
}: UseBoardActionsProps) {
|
}: UseBoardActionsProps) {
|
||||||
const {
|
// IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent
|
||||||
addFeature,
|
// subscribing to the entire store. Bare useAppStore() causes the host component
|
||||||
updateFeature,
|
// (BoardView) to re-render on EVERY store change, which cascades through effects
|
||||||
removeFeature,
|
// and triggers React error #185 (maximum update depth exceeded).
|
||||||
moveFeature,
|
const addFeature = useAppStore((s) => s.addFeature);
|
||||||
useWorktrees,
|
const updateFeature = useAppStore((s) => s.updateFeature);
|
||||||
enableDependencyBlocking,
|
const removeFeature = useAppStore((s) => s.removeFeature);
|
||||||
skipVerificationInAutoMode,
|
const moveFeature = useAppStore((s) => s.moveFeature);
|
||||||
isPrimaryWorktreeBranch,
|
const worktreesEnabled = useAppStore((s) => s.useWorktrees);
|
||||||
getPrimaryWorktreeBranch,
|
const enableDependencyBlocking = useAppStore((s) => s.enableDependencyBlocking);
|
||||||
} = useAppStore();
|
const skipVerificationInAutoMode = useAppStore((s) => s.skipVerificationInAutoMode);
|
||||||
|
const isPrimaryWorktreeBranch = useAppStore((s) => s.isPrimaryWorktreeBranch);
|
||||||
|
const getPrimaryWorktreeBranch = useAppStore((s) => s.getPrimaryWorktreeBranch);
|
||||||
const autoMode = useAutoMode();
|
const autoMode = useAutoMode();
|
||||||
|
|
||||||
// React Query mutations for feature operations
|
// React Query mutations for feature operations
|
||||||
@@ -217,9 +219,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 +247,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 || [];
|
||||||
@@ -543,7 +551,7 @@ export function useBoardActions({
|
|||||||
const result = await api.autoMode.runFeature(
|
const result = await api.autoMode.runFeature(
|
||||||
currentProject.path,
|
currentProject.path,
|
||||||
feature.id,
|
feature.id,
|
||||||
useWorktrees
|
worktreesEnabled
|
||||||
// No worktreePath - server derives from feature.branchName
|
// No worktreePath - server derives from feature.branchName
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -554,7 +562,7 @@ export function useBoardActions({
|
|||||||
throw new Error(result.error || 'Failed to start feature');
|
throw new Error(result.error || 'Failed to start feature');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentProject, useWorktrees]
|
[currentProject, worktreesEnabled]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStartImplementation = useCallback(
|
const handleStartImplementation = useCallback(
|
||||||
@@ -687,9 +695,9 @@ export function useBoardActions({
|
|||||||
logger.error('No current project');
|
logger.error('No current project');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resumeFeatureMutation.mutate({ featureId: feature.id, useWorktrees });
|
resumeFeatureMutation.mutate({ featureId: feature.id, useWorktrees: worktreesEnabled });
|
||||||
},
|
},
|
||||||
[currentProject, resumeFeatureMutation, useWorktrees]
|
[currentProject, resumeFeatureMutation, worktreesEnabled]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleManualVerify = useCallback(
|
const handleManualVerify = useCallback(
|
||||||
@@ -774,7 +782,7 @@ export function useBoardActions({
|
|||||||
followUpFeature.id,
|
followUpFeature.id,
|
||||||
followUpPrompt,
|
followUpPrompt,
|
||||||
imagePaths,
|
imagePaths,
|
||||||
useWorktrees
|
worktreesEnabled
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -812,7 +820,7 @@ export function useBoardActions({
|
|||||||
setFollowUpPrompt,
|
setFollowUpPrompt,
|
||||||
setFollowUpImagePaths,
|
setFollowUpImagePaths,
|
||||||
setFollowUpPreviewMap,
|
setFollowUpPreviewMap,
|
||||||
useWorktrees,
|
worktreesEnabled,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleCommitFeature = useCallback(
|
const handleCommitFeature = useCallback(
|
||||||
@@ -1161,10 +1169,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 +1225,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,
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ export function useBoardDragDrop({
|
|||||||
const [pendingDependencyLink, setPendingDependencyLink] = useState<PendingDependencyLink | null>(
|
const [pendingDependencyLink, setPendingDependencyLink] = useState<PendingDependencyLink | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
const { moveFeature, updateFeature } = useAppStore();
|
// IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent
|
||||||
|
// subscribing to the entire store. Bare useAppStore() causes the host component
|
||||||
|
// (BoardView) to re-render on EVERY store change, which cascades through effects
|
||||||
|
// and triggers React error #185 (maximum update depth exceeded).
|
||||||
|
const moveFeature = useAppStore((s) => s.moveFeature);
|
||||||
|
const updateFeature = useAppStore((s) => s.updateFeature);
|
||||||
const autoMode = useAutoMode();
|
const autoMode = useAutoMode();
|
||||||
|
|
||||||
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
|
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ interface UseBoardPersistenceProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps) {
|
export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps) {
|
||||||
const { updateFeature } = useAppStore();
|
// IMPORTANT: Use individual selector instead of bare useAppStore() to prevent
|
||||||
|
// subscribing to the entire store. Bare useAppStore() causes the host component
|
||||||
|
// (BoardView) to re-render on EVERY store change, which cascades through effects
|
||||||
|
// and triggers React error #185 (maximum update depth exceeded).
|
||||||
|
const updateFeature = useAppStore((s) => s.updateFeature);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Persist feature update to API (replaces saveFeatures)
|
// Persist feature update to API (replaces saveFeatures)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
Trash2,
|
Trash2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
GitBranch,
|
||||||
GitCommit,
|
GitCommit,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
Download,
|
Download,
|
||||||
@@ -137,6 +139,85 @@ interface WorktreeActionsDropdownProps {
|
|||||||
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
|
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
|
||||||
/** Callback to open the script editor UI */
|
/** Callback to open the script editor UI */
|
||||||
onEditScripts?: () => void;
|
onEditScripts?: () => void;
|
||||||
|
/** Whether sync is in progress */
|
||||||
|
isSyncing?: boolean;
|
||||||
|
/** Sync (pull + push) callback */
|
||||||
|
onSync?: (worktree: WorktreeInfo) => void;
|
||||||
|
/** Sync with a specific remote */
|
||||||
|
onSyncWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
|
/** Set tracking branch to a specific remote */
|
||||||
|
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A remote item that either renders as a split-button with "Set as Tracking Branch"
|
||||||
|
* sub-action, or a plain menu item if onSetTracking is not provided.
|
||||||
|
*/
|
||||||
|
function RemoteActionMenuItem({
|
||||||
|
remote,
|
||||||
|
icon: Icon,
|
||||||
|
trackingRemote,
|
||||||
|
isDisabled,
|
||||||
|
isGitOpsAvailable,
|
||||||
|
onAction,
|
||||||
|
onSetTracking,
|
||||||
|
}: {
|
||||||
|
remote: { name: string; url: string };
|
||||||
|
icon: typeof Download;
|
||||||
|
trackingRemote?: string;
|
||||||
|
isDisabled: boolean;
|
||||||
|
isGitOpsAvailable: boolean;
|
||||||
|
onAction: () => void;
|
||||||
|
onSetTracking?: () => void;
|
||||||
|
}) {
|
||||||
|
if (onSetTracking) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuSub key={remote.name}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={onAction}
|
||||||
|
disabled={isDisabled || !isGitOpsAvailable}
|
||||||
|
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||||
|
>
|
||||||
|
<Icon className="w-3.5 h-3.5 mr-2" />
|
||||||
|
{remote.name}
|
||||||
|
{trackingRemote === remote.name && (
|
||||||
|
<span className="ml-auto text-[10px] text-muted-foreground mr-1">tracking</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSubTrigger
|
||||||
|
className="text-xs px-1 rounded-l-none border-l border-border/30 h-8"
|
||||||
|
disabled={!isGitOpsAvailable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={onSetTracking}
|
||||||
|
disabled={!isGitOpsAvailable}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<GitBranch className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Set as Tracking Branch
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={remote.name}
|
||||||
|
onClick={onAction}
|
||||||
|
disabled={isDisabled || !isGitOpsAvailable}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Icon className="w-3.5 h-3.5 mr-2" />
|
||||||
|
{remote.name}
|
||||||
|
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
|
||||||
|
{remote.url}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorktreeActionsDropdown({
|
export function WorktreeActionsDropdown({
|
||||||
@@ -197,6 +278,10 @@ export function WorktreeActionsDropdown({
|
|||||||
terminalScripts,
|
terminalScripts,
|
||||||
onRunTerminalScript,
|
onRunTerminalScript,
|
||||||
onEditScripts,
|
onEditScripts,
|
||||||
|
isSyncing = false,
|
||||||
|
onSync,
|
||||||
|
onSyncWithRemote,
|
||||||
|
onSetTracking,
|
||||||
}: WorktreeActionsDropdownProps) {
|
}: WorktreeActionsDropdownProps) {
|
||||||
// Get available editors for the "Open In" submenu
|
// Get available editors for the "Open In" submenu
|
||||||
const { editors } = useAvailableEditors();
|
const { editors } = useAvailableEditors();
|
||||||
@@ -251,6 +336,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>
|
||||||
@@ -371,39 +478,59 @@ export function WorktreeActionsDropdown({
|
|||||||
? 'Dev Server Starting...'
|
? 'Dev Server Starting...'
|
||||||
: `Dev Server Running (:${devServerInfo?.port})`}
|
: `Dev Server Running (:${devServerInfo?.port})`}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{devServerInfo?.urlDetected !== false && (
|
{devServerInfo != null &&
|
||||||
<DropdownMenuItem
|
devServerInfo.port != null &&
|
||||||
onClick={() => onOpenDevServerUrl(worktree)}
|
devServerInfo.urlDetected !== false && (
|
||||||
className="text-xs"
|
<DropdownMenuItem
|
||||||
aria-label={`Open dev server on port ${devServerInfo?.port} in browser`}
|
onClick={() => onOpenDevServerUrl(worktree)}
|
||||||
>
|
className="text-xs"
|
||||||
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
|
aria-label={`Open dev server on port ${devServerInfo.port} in browser`}
|
||||||
Open in Browser
|
>
|
||||||
</DropdownMenuItem>
|
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
|
||||||
)}
|
Open in Browser
|
||||||
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
|
</DropdownMenuItem>
|
||||||
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
)}
|
||||||
View Logs
|
{/* Stop Dev Server - split button: click main area to stop, chevron for view logs */}
|
||||||
</DropdownMenuItem>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuItem
|
<div className="flex items-center">
|
||||||
onClick={() => onStopDevServer(worktree)}
|
<DropdownMenuItem
|
||||||
className="text-xs text-destructive focus:text-destructive"
|
onClick={() => onStopDevServer(worktree)}
|
||||||
>
|
className="text-xs flex-1 pr-0 rounded-r-none 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
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||||
|
</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 />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -592,7 +719,7 @@ export function WorktreeActionsDropdown({
|
|||||||
Scripts
|
Scripts
|
||||||
</DropdownMenuSubTrigger>
|
</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent className="w-52">
|
<DropdownMenuSubContent className="w-52">
|
||||||
{/* Re-run Init Script - always shown for non-main worktrees, disabled when no init script configured */}
|
{/* Re-run Init Script - always shown for non-main worktrees, disabled when no init script configured or no handler */}
|
||||||
{!worktree.isMain && (
|
{!worktree.isMain && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -613,6 +740,7 @@ export function WorktreeActionsDropdown({
|
|||||||
key={script.id}
|
key={script.id}
|
||||||
onClick={() => onRunTerminalScript?.(worktree, script.command)}
|
onClick={() => onRunTerminalScript?.(worktree, script.command)}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
|
disabled={!onRunTerminalScript}
|
||||||
>
|
>
|
||||||
<Play className="w-3.5 h-3.5 mr-2 shrink-0" />
|
<Play className="w-3.5 h-3.5 mr-2 shrink-0" />
|
||||||
<span className="truncate">{script.name}</span>
|
<span className="truncate">{script.name}</span>
|
||||||
@@ -625,7 +753,11 @@ export function WorktreeActionsDropdown({
|
|||||||
)}
|
)}
|
||||||
{/* Divider before Edit Commands & Scripts */}
|
{/* Divider before Edit Commands & Scripts */}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => onEditScripts?.()} className="text-xs">
|
<DropdownMenuItem
|
||||||
|
onClick={() => onEditScripts?.()}
|
||||||
|
className="text-xs"
|
||||||
|
disabled={!onEditScripts}
|
||||||
|
>
|
||||||
<Settings2 className="w-3.5 h-3.5 mr-2" />
|
<Settings2 className="w-3.5 h-3.5 mr-2" />
|
||||||
Edit Commands & Scripts
|
Edit Commands & Scripts
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -671,18 +803,20 @@ export function WorktreeActionsDropdown({
|
|||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{remotes.map((remote) => (
|
{remotes.map((remote) => (
|
||||||
<DropdownMenuItem
|
<RemoteActionMenuItem
|
||||||
key={remote.name}
|
key={remote.name}
|
||||||
onClick={() => isGitOpsAvailable && onPullWithRemote(worktree, remote.name)}
|
remote={remote}
|
||||||
disabled={isPulling || !isGitOpsAvailable}
|
icon={Download}
|
||||||
className="text-xs"
|
trackingRemote={trackingRemote}
|
||||||
>
|
isDisabled={isPulling}
|
||||||
<Download className="w-3.5 h-3.5 mr-2" />
|
isGitOpsAvailable={isGitOpsAvailable}
|
||||||
{remote.name}
|
onAction={() => isGitOpsAvailable && onPullWithRemote(worktree, remote.name)}
|
||||||
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
|
onSetTracking={
|
||||||
{remote.url}
|
onSetTracking
|
||||||
</span>
|
? () => isGitOpsAvailable && onSetTracking(worktree, remote.name)
|
||||||
</DropdownMenuItem>
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
@@ -770,18 +904,20 @@ export function WorktreeActionsDropdown({
|
|||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{remotes.map((remote) => (
|
{remotes.map((remote) => (
|
||||||
<DropdownMenuItem
|
<RemoteActionMenuItem
|
||||||
key={remote.name}
|
key={remote.name}
|
||||||
onClick={() => isGitOpsAvailable && onPushWithRemote(worktree, remote.name)}
|
remote={remote}
|
||||||
disabled={isPushing || !isGitOpsAvailable}
|
icon={Upload}
|
||||||
className="text-xs"
|
trackingRemote={trackingRemote}
|
||||||
>
|
isDisabled={isPushing}
|
||||||
<Upload className="w-3.5 h-3.5 mr-2" />
|
isGitOpsAvailable={isGitOpsAvailable}
|
||||||
{remote.name}
|
onAction={() => isGitOpsAvailable && onPushWithRemote(worktree, remote.name)}
|
||||||
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
|
onSetTracking={
|
||||||
{remote.url}
|
onSetTracking
|
||||||
</span>
|
? () => isGitOpsAvailable && onSetTracking(worktree, remote.name)
|
||||||
</DropdownMenuItem>
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
@@ -828,6 +964,72 @@ export function WorktreeActionsDropdown({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
|
{onSync && (
|
||||||
|
<TooltipWrapper
|
||||||
|
showTooltip={!!gitOpsDisabledReason}
|
||||||
|
tooltipContent={gitOpsDisabledReason}
|
||||||
|
>
|
||||||
|
{remotes && remotes.length > 1 && onSyncWithRemote ? (
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => isGitOpsAvailable && onSync(worktree)}
|
||||||
|
disabled={isSyncing || !isGitOpsAvailable}
|
||||||
|
className={cn(
|
||||||
|
'text-xs flex-1 pr-0 rounded-r-none',
|
||||||
|
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('w-3.5 h-3.5 mr-2', isSyncing && 'animate-spin')} />
|
||||||
|
{isSyncing ? 'Syncing...' : 'Sync'}
|
||||||
|
{!isGitOpsAvailable && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSubTrigger
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
||||||
|
(!isGitOpsAvailable || isSyncing) && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
disabled={!isGitOpsAvailable || isSyncing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
|
||||||
|
Sync with remote
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{remotes.map((remote) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={`sync-${remote.name}`}
|
||||||
|
onClick={() => isGitOpsAvailable && onSyncWithRemote(worktree, remote.name)}
|
||||||
|
disabled={isSyncing || !isGitOpsAvailable}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 mr-2" />
|
||||||
|
{remote.name}
|
||||||
|
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
|
||||||
|
{remote.url}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => isGitOpsAvailable && onSync(worktree)}
|
||||||
|
disabled={isSyncing || !isGitOpsAvailable}
|
||||||
|
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('w-3.5 h-3.5 mr-2', isSyncing && 'animate-spin')} />
|
||||||
|
{isSyncing ? 'Syncing...' : 'Sync'}
|
||||||
|
{!isGitOpsAvailable && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</TooltipWrapper>
|
||||||
|
)}
|
||||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => isGitOpsAvailable && onResolveConflicts(worktree)}
|
onClick={() => isGitOpsAvailable && onResolveConflicts(worktree)}
|
||||||
@@ -939,32 +1141,23 @@ export function WorktreeActionsDropdown({
|
|||||||
- worktree.hasChanges: View Changes action is available
|
- worktree.hasChanges: View Changes action is available
|
||||||
- (worktree.hasChanges && onStashChanges): Create Stash action is possible
|
- (worktree.hasChanges && onStashChanges): Create Stash action is possible
|
||||||
- onViewStashes: viewing existing stashes is possible */}
|
- onViewStashes: viewing existing stashes is possible */}
|
||||||
{(worktree.hasChanges || onViewStashes) && (
|
{/* View Changes split button - show submenu only when there are non-duplicate sub-actions */}
|
||||||
|
{worktree.hasChanges && (onStashChanges || onViewStashes) ? (
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{/* Main clickable area - view changes (primary action) */}
|
{/* Main clickable area - view changes (primary action) */}
|
||||||
{worktree.hasChanges ? (
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem
|
onClick={() => onViewChanges(worktree)}
|
||||||
onClick={() => onViewChanges(worktree)}
|
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||||
className="text-xs flex-1 pr-0 rounded-r-none"
|
>
|
||||||
>
|
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
View Changes
|
||||||
View Changes
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onViewStashes!(worktree)}
|
|
||||||
className="text-xs flex-1 pr-0 rounded-r-none"
|
|
||||||
>
|
|
||||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
|
||||||
View Stashes
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{/* Chevron trigger for submenu with stash options */}
|
{/* Chevron trigger for submenu with stash options */}
|
||||||
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent>
|
||||||
{worktree.hasChanges && onStashChanges && (
|
{onStashChanges && (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
showTooltip={!isGitOpsAvailable}
|
showTooltip={!isGitOpsAvailable}
|
||||||
tooltipContent={gitOpsDisabledReason}
|
tooltipContent={gitOpsDisabledReason}
|
||||||
@@ -993,7 +1186,17 @@ export function WorktreeActionsDropdown({
|
|||||||
)}
|
)}
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
)}
|
) : worktree.hasChanges ? (
|
||||||
|
<DropdownMenuItem onClick={() => onViewChanges(worktree)} className="text-xs">
|
||||||
|
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||||
|
View Changes
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : onViewStashes ? (
|
||||||
|
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
|
||||||
|
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||||
|
View Stashes
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
showTooltip={!!gitOpsDisabledReason}
|
showTooltip={!!gitOpsDisabledReason}
|
||||||
@@ -1032,7 +1235,7 @@ export function WorktreeActionsDropdown({
|
|||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)}
|
)}
|
||||||
{/* Show PR info with Address Comments in sub-menu if PR exists */}
|
{/* Show PR info with Address Comments in sub-menu if PR exists */}
|
||||||
{showPRInfo && worktree.pr && (
|
{prInfo && worktree.pr && (
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{/* Main clickable area - opens PR in browser */}
|
{/* Main clickable area - opens PR in browser */}
|
||||||
@@ -1044,7 +1247,16 @@ export function WorktreeActionsDropdown({
|
|||||||
>
|
>
|
||||||
<GitPullRequest className="w-3 h-3 mr-2" />
|
<GitPullRequest className="w-3 h-3 mr-2" />
|
||||||
PR #{worktree.pr.number}
|
PR #{worktree.pr.number}
|
||||||
<span className="ml-auto mr-1 text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded uppercase">
|
<span
|
||||||
|
className={cn(
|
||||||
|
'ml-auto mr-1 text-[10px] px-1.5 py-0.5 rounded uppercase',
|
||||||
|
worktree.pr.state === 'MERGED'
|
||||||
|
? 'bg-purple-500/20 text-purple-600'
|
||||||
|
: worktree.pr.state === 'CLOSED'
|
||||||
|
? 'bg-gray-500/20 text-gray-500'
|
||||||
|
: 'bg-green-500/20 text-green-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{worktree.pr.state}
|
{worktree.pr.state}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -1053,40 +1265,14 @@ export function WorktreeActionsDropdown({
|
|||||||
</div>
|
</div>
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => onAddressPRComments(worktree, prInfo)}
|
||||||
// Convert stored PR info to the full PRInfo format for the handler
|
|
||||||
// The handler will fetch full comments from GitHub
|
|
||||||
const prInfo: PRInfo = {
|
|
||||||
number: worktree.pr!.number,
|
|
||||||
title: worktree.pr!.title,
|
|
||||||
url: worktree.pr!.url,
|
|
||||||
state: worktree.pr!.state,
|
|
||||||
author: '', // Will be fetched
|
|
||||||
body: '', // Will be fetched
|
|
||||||
comments: [],
|
|
||||||
reviewComments: [],
|
|
||||||
};
|
|
||||||
onAddressPRComments(worktree, prInfo);
|
|
||||||
}}
|
|
||||||
className="text-xs text-blue-500 focus:text-blue-600"
|
className="text-xs text-blue-500 focus:text-blue-600"
|
||||||
>
|
>
|
||||||
<MessageSquare className="w-3.5 h-3.5 mr-2" />
|
<MessageSquare className="w-3.5 h-3.5 mr-2" />
|
||||||
Manage PR Comments
|
Manage PR Comments
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => onAutoAddressPRComments(worktree, prInfo)}
|
||||||
const prInfo: PRInfo = {
|
|
||||||
number: worktree.pr!.number,
|
|
||||||
title: worktree.pr!.title,
|
|
||||||
url: worktree.pr!.url,
|
|
||||||
state: worktree.pr!.state,
|
|
||||||
author: '',
|
|
||||||
body: '',
|
|
||||||
comments: [],
|
|
||||||
reviewComments: [],
|
|
||||||
};
|
|
||||||
onAutoAddressPRComments(worktree, prInfo);
|
|
||||||
}}
|
|
||||||
className="text-xs text-blue-500 focus:text-blue-600"
|
className="text-xs text-blue-500 focus:text-blue-600"
|
||||||
>
|
>
|
||||||
<Zap className="w-3.5 h-3.5 mr-2" />
|
<Zap className="w-3.5 h-3.5 mr-2" />
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export function WorktreeDropdownItem({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dev server indicator - only shown when port is confirmed detected */}
|
{/* Dev server indicator - hidden when URL detection explicitly failed */}
|
||||||
{devServerRunning && devServerInfo?.urlDetected !== false && (
|
{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"
|
||||||
|
|||||||
@@ -138,6 +138,14 @@ export interface WorktreeDropdownProps {
|
|||||||
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
|
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
|
||||||
/** Callback to open the script editor UI */
|
/** Callback to open the script editor UI */
|
||||||
onEditScripts?: () => void;
|
onEditScripts?: () => void;
|
||||||
|
/** Whether sync is in progress */
|
||||||
|
isSyncing?: boolean;
|
||||||
|
/** Sync (pull + push) callback */
|
||||||
|
onSync?: (worktree: WorktreeInfo) => void;
|
||||||
|
/** Sync with a specific remote */
|
||||||
|
onSyncWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
|
/** Set tracking branch to a specific remote */
|
||||||
|
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -230,6 +238,10 @@ export function WorktreeDropdown({
|
|||||||
terminalScripts,
|
terminalScripts,
|
||||||
onRunTerminalScript,
|
onRunTerminalScript,
|
||||||
onEditScripts,
|
onEditScripts,
|
||||||
|
isSyncing = false,
|
||||||
|
onSync,
|
||||||
|
onSyncWithRemote,
|
||||||
|
onSetTracking,
|
||||||
}: 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));
|
||||||
@@ -549,6 +561,10 @@ export function WorktreeDropdown({
|
|||||||
terminalScripts={terminalScripts}
|
terminalScripts={terminalScripts}
|
||||||
onRunTerminalScript={onRunTerminalScript}
|
onRunTerminalScript={onRunTerminalScript}
|
||||||
onEditScripts={onEditScripts}
|
onEditScripts={onEditScripts}
|
||||||
|
isSyncing={isSyncing}
|
||||||
|
onSync={onSync}
|
||||||
|
onSyncWithRemote={onSyncWithRemote}
|
||||||
|
onSetTracking={onSetTracking}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,6 +108,14 @@ interface WorktreeTabProps {
|
|||||||
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
|
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
|
||||||
/** Callback to open the script editor UI */
|
/** Callback to open the script editor UI */
|
||||||
onEditScripts?: () => void;
|
onEditScripts?: () => void;
|
||||||
|
/** Whether sync is in progress */
|
||||||
|
isSyncing?: boolean;
|
||||||
|
/** Sync (pull + push) callback */
|
||||||
|
onSync?: (worktree: WorktreeInfo) => void;
|
||||||
|
/** Sync with a specific remote */
|
||||||
|
onSyncWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
|
/** Set tracking branch to a specific remote */
|
||||||
|
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorktreeTab({
|
export function WorktreeTab({
|
||||||
@@ -181,6 +189,10 @@ export function WorktreeTab({
|
|||||||
terminalScripts,
|
terminalScripts,
|
||||||
onRunTerminalScript,
|
onRunTerminalScript,
|
||||||
onEditScripts,
|
onEditScripts,
|
||||||
|
isSyncing = false,
|
||||||
|
onSync,
|
||||||
|
onSyncWithRemote,
|
||||||
|
onSetTracking,
|
||||||
}: 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({
|
||||||
@@ -550,6 +562,10 @@ export function WorktreeTab({
|
|||||||
terminalScripts={terminalScripts}
|
terminalScripts={terminalScripts}
|
||||||
onRunTerminalScript={onRunTerminalScript}
|
onRunTerminalScript={onRunTerminalScript}
|
||||||
onEditScripts={onEditScripts}
|
onEditScripts={onEditScripts}
|
||||||
|
isSyncing={isSyncing}
|
||||||
|
onSync={onSync}
|
||||||
|
onSyncWithRemote={onSyncWithRemote}
|
||||||
|
onSetTracking={onSetTracking}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -30,6 +35,26 @@ function buildDevServerBrowserUrl(serverUrl: string): string | 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());
|
||||||
@@ -37,6 +62,120 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
// Track which worktrees have had their url-detected toast shown to prevent re-triggering
|
// Track which worktrees have had their url-detected toast shown to prevent re-triggering
|
||||||
const toastShownForRef = useRef<Set<string>>(new Set());
|
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();
|
||||||
@@ -56,19 +195,132 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
// so we don't re-trigger on initial load
|
// so we don't re-trigger on initial load
|
||||||
if (server.urlDetected !== false) {
|
if (server.urlDetected !== false) {
|
||||||
toastShownForRef.current.add(key);
|
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]);
|
||||||
|
|
||||||
|
// 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
|
// Subscribe to all dev server lifecycle events for reactive state updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
@@ -78,10 +330,24 @@ 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
|
// Avoid updating if already detected with same url/port
|
||||||
if (existing.urlDetected && existing.url === url && existing.port === port) return prev;
|
if (existing.urlDetected && existing.url === url && existing.port === port) return prev;
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
@@ -99,25 +365,15 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
// Only show toast on the transition from undetected → detected (not on re-renders/polls)
|
// Only show toast on the transition from undetected → detected (not on re-renders/polls)
|
||||||
if (!toastShownForRef.current.has(key)) {
|
if (!toastShownForRef.current.has(key)) {
|
||||||
toastShownForRef.current.add(key);
|
toastShownForRef.current.add(key);
|
||||||
const browserUrl = buildDevServerBrowserUrl(url);
|
showUrlDetectedToast(url, port);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === 'dev-server:stopped') {
|
} else if (event.type === 'dev-server:stopped') {
|
||||||
// Reactively remove the server from state when it stops
|
// Reactively remove the server from state when it stops
|
||||||
const { worktreePath } = event.payload;
|
const { worktreePath } = event.payload;
|
||||||
const key = normalizePath(worktreePath);
|
const key = normalizePath(worktreePath);
|
||||||
|
// Clear any pending port detection timeout
|
||||||
|
clearPortDetectionTimer(key);
|
||||||
setRunningDevServers((prev) => {
|
setRunningDevServers((prev) => {
|
||||||
if (!prev.has(key)) return prev;
|
if (!prev.has(key)) return prev;
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
@@ -143,10 +399,22 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
});
|
});
|
||||||
return next;
|
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(
|
||||||
@@ -186,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(
|
||||||
@@ -214,6 +490,8 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const key = normalizePath(targetPath);
|
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(key);
|
next.delete(key);
|
||||||
@@ -230,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(
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
useSwitchBranch,
|
useSwitchBranch,
|
||||||
usePullWorktree,
|
usePullWorktree,
|
||||||
usePushWorktree,
|
usePushWorktree,
|
||||||
|
useSyncWorktree,
|
||||||
|
useSetTracking,
|
||||||
useOpenInEditor,
|
useOpenInEditor,
|
||||||
} from '@/hooks/mutations';
|
} from '@/hooks/mutations';
|
||||||
import type { WorktreeInfo } from '../types';
|
import type { WorktreeInfo } from '../types';
|
||||||
@@ -51,6 +53,8 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
|
|||||||
});
|
});
|
||||||
const pullMutation = usePullWorktree();
|
const pullMutation = usePullWorktree();
|
||||||
const pushMutation = usePushWorktree();
|
const pushMutation = usePushWorktree();
|
||||||
|
const syncMutation = useSyncWorktree();
|
||||||
|
const setTrackingMutation = useSetTracking();
|
||||||
const openInEditorMutation = useOpenInEditor();
|
const openInEditorMutation = useOpenInEditor();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,6 +154,28 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
|
|||||||
[pushMutation]
|
[pushMutation]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSync = useCallback(
|
||||||
|
async (worktree: WorktreeInfo, remote?: string) => {
|
||||||
|
if (syncMutation.isPending) return;
|
||||||
|
syncMutation.mutate({
|
||||||
|
worktreePath: worktree.path,
|
||||||
|
remote,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[syncMutation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSetTracking = useCallback(
|
||||||
|
async (worktree: WorktreeInfo, remote: string) => {
|
||||||
|
if (setTrackingMutation.isPending) return;
|
||||||
|
setTrackingMutation.mutate({
|
||||||
|
worktreePath: worktree.path,
|
||||||
|
remote,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setTrackingMutation]
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpenInIntegratedTerminal = useCallback(
|
const handleOpenInIntegratedTerminal = useCallback(
|
||||||
(worktree: WorktreeInfo, mode?: 'tab' | 'split') => {
|
(worktree: WorktreeInfo, mode?: 'tab' | 'split') => {
|
||||||
// Navigate to the terminal view with the worktree path and branch name
|
// Navigate to the terminal view with the worktree path and branch name
|
||||||
@@ -215,12 +241,15 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
|
|||||||
return {
|
return {
|
||||||
isPulling: pullMutation.isPending,
|
isPulling: pullMutation.isPending,
|
||||||
isPushing: pushMutation.isPending,
|
isPushing: pushMutation.isPending,
|
||||||
|
isSyncing: syncMutation.isPending,
|
||||||
isSwitching: switchBranchMutation.isPending,
|
isSwitching: switchBranchMutation.isPending,
|
||||||
isActivating,
|
isActivating,
|
||||||
setIsActivating,
|
setIsActivating,
|
||||||
handleSwitchBranch,
|
handleSwitchBranch,
|
||||||
handlePull,
|
handlePull,
|
||||||
handlePush,
|
handlePush,
|
||||||
|
handleSync,
|
||||||
|
handleSetTracking,
|
||||||
handleOpenInIntegratedTerminal,
|
handleOpenInIntegratedTerminal,
|
||||||
handleRunTerminalScript,
|
handleRunTerminalScript,
|
||||||
handleOpenInEditor,
|
handleOpenInEditor,
|
||||||
|
|||||||
@@ -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,19 +98,32 @@ 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.
|
// Defer feature query invalidation so the store update and client-side
|
||||||
// Without this, feature cards that remount after the worktree switch may have stale
|
// re-filtering happen in the current render cycle first. The features
|
||||||
// or missing planSpec/task data, causing todo lists to appear empty until the next
|
// list is the same regardless of worktree (filtering is client-side),
|
||||||
// polling cycle or user interaction triggers a re-render.
|
// so the board updates instantly. The deferred invalidation ensures
|
||||||
queryClient.invalidateQueries({
|
// feature card details (planSpec, todo lists) are refreshed in the
|
||||||
queryKey: queryKeys.features.all(projectPath),
|
// background without blocking the worktree switch.
|
||||||
});
|
setTimeout(() => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(projectPath),
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
},
|
},
|
||||||
[projectPath, setCurrentWorktree, queryClient]
|
[projectPath, setCurrentWorktree, queryClient, currentWorktreePath]
|
||||||
);
|
);
|
||||||
|
|
||||||
// fetchWorktrees for backward compatibility - now just triggers a refetch
|
// fetchWorktrees for backward compatibility - now just triggers a refetch
|
||||||
@@ -118,7 +142,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);
|
||||||
|
|||||||
@@ -113,11 +113,14 @@ export function WorktreePanel({
|
|||||||
const {
|
const {
|
||||||
isPulling,
|
isPulling,
|
||||||
isPushing,
|
isPushing,
|
||||||
|
isSyncing,
|
||||||
isSwitching,
|
isSwitching,
|
||||||
isActivating,
|
isActivating,
|
||||||
handleSwitchBranch,
|
handleSwitchBranch,
|
||||||
handlePull: _handlePull,
|
handlePull: _handlePull,
|
||||||
handlePush,
|
handlePush,
|
||||||
|
handleSync,
|
||||||
|
handleSetTracking,
|
||||||
handleOpenInIntegratedTerminal,
|
handleOpenInIntegratedTerminal,
|
||||||
handleRunTerminalScript,
|
handleRunTerminalScript,
|
||||||
handleOpenInEditor,
|
handleOpenInEditor,
|
||||||
@@ -659,6 +662,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);
|
||||||
@@ -816,6 +831,30 @@ export function WorktreePanel({
|
|||||||
[handlePush, fetchBranches, fetchWorktrees]
|
[handlePush, fetchBranches, fetchWorktrees]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle sync (pull + push) with optional remote selection
|
||||||
|
const handleSyncWithRemoteSelection = useCallback(
|
||||||
|
(worktree: WorktreeInfo) => {
|
||||||
|
handleSync(worktree);
|
||||||
|
},
|
||||||
|
[handleSync]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle sync with a specific remote selected from the submenu
|
||||||
|
const handleSyncWithSpecificRemote = useCallback(
|
||||||
|
(worktree: WorktreeInfo, remote: string) => {
|
||||||
|
handleSync(worktree, remote);
|
||||||
|
},
|
||||||
|
[handleSync]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle set tracking branch for a specific remote
|
||||||
|
const handleSetTrackingForRemote = useCallback(
|
||||||
|
(worktree: WorktreeInfo, remote: string) => {
|
||||||
|
handleSetTracking(worktree, remote);
|
||||||
|
},
|
||||||
|
[handleSetTracking]
|
||||||
|
);
|
||||||
|
|
||||||
// Handle confirming the push to remote dialog
|
// Handle confirming the push to remote dialog
|
||||||
const handleConfirmPushToRemote = useCallback(
|
const handleConfirmPushToRemote = useCallback(
|
||||||
async (worktree: WorktreeInfo, remote: string) => {
|
async (worktree: WorktreeInfo, remote: string) => {
|
||||||
@@ -924,6 +963,10 @@ export function WorktreePanel({
|
|||||||
onPushNewBranch={handlePushNewBranch}
|
onPushNewBranch={handlePushNewBranch}
|
||||||
onPullWithRemote={handlePullWithSpecificRemote}
|
onPullWithRemote={handlePullWithSpecificRemote}
|
||||||
onPushWithRemote={handlePushWithSpecificRemote}
|
onPushWithRemote={handlePushWithSpecificRemote}
|
||||||
|
isSyncing={isSyncing}
|
||||||
|
onSync={handleSyncWithRemoteSelection}
|
||||||
|
onSyncWithRemote={handleSyncWithSpecificRemote}
|
||||||
|
onSetTracking={handleSetTrackingForRemote}
|
||||||
onOpenInEditor={handleOpenInEditor}
|
onOpenInEditor={handleOpenInEditor}
|
||||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||||
@@ -937,7 +980,7 @@ export function WorktreePanel({
|
|||||||
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}
|
||||||
@@ -1167,6 +1210,10 @@ export function WorktreePanel({
|
|||||||
onPushNewBranch={handlePushNewBranch}
|
onPushNewBranch={handlePushNewBranch}
|
||||||
onPullWithRemote={handlePullWithSpecificRemote}
|
onPullWithRemote={handlePullWithSpecificRemote}
|
||||||
onPushWithRemote={handlePushWithSpecificRemote}
|
onPushWithRemote={handlePushWithSpecificRemote}
|
||||||
|
isSyncing={isSyncing}
|
||||||
|
onSync={handleSyncWithRemoteSelection}
|
||||||
|
onSyncWithRemote={handleSyncWithSpecificRemote}
|
||||||
|
onSetTracking={handleSetTrackingForRemote}
|
||||||
remotesCache={remotesCache}
|
remotesCache={remotesCache}
|
||||||
onOpenInEditor={handleOpenInEditor}
|
onOpenInEditor={handleOpenInEditor}
|
||||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||||
@@ -1181,7 +1228,7 @@ export function WorktreePanel({
|
|||||||
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}
|
||||||
@@ -1274,6 +1321,10 @@ export function WorktreePanel({
|
|||||||
onPushNewBranch={handlePushNewBranch}
|
onPushNewBranch={handlePushNewBranch}
|
||||||
onPullWithRemote={handlePullWithSpecificRemote}
|
onPullWithRemote={handlePullWithSpecificRemote}
|
||||||
onPushWithRemote={handlePushWithSpecificRemote}
|
onPushWithRemote={handlePushWithSpecificRemote}
|
||||||
|
isSyncing={isSyncing}
|
||||||
|
onSync={handleSyncWithRemoteSelection}
|
||||||
|
onSyncWithRemote={handleSyncWithSpecificRemote}
|
||||||
|
onSetTracking={handleSetTrackingForRemote}
|
||||||
remotes={remotesCache[mainWorktree.path]}
|
remotes={remotesCache[mainWorktree.path]}
|
||||||
onOpenInEditor={handleOpenInEditor}
|
onOpenInEditor={handleOpenInEditor}
|
||||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||||
@@ -1288,7 +1339,7 @@ export function WorktreePanel({
|
|||||||
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}
|
||||||
@@ -1361,6 +1412,10 @@ export function WorktreePanel({
|
|||||||
onPushNewBranch={handlePushNewBranch}
|
onPushNewBranch={handlePushNewBranch}
|
||||||
onPullWithRemote={handlePullWithSpecificRemote}
|
onPullWithRemote={handlePullWithSpecificRemote}
|
||||||
onPushWithRemote={handlePushWithSpecificRemote}
|
onPushWithRemote={handlePushWithSpecificRemote}
|
||||||
|
isSyncing={isSyncing}
|
||||||
|
onSync={handleSyncWithRemoteSelection}
|
||||||
|
onSyncWithRemote={handleSyncWithSpecificRemote}
|
||||||
|
onSetTracking={handleSetTrackingForRemote}
|
||||||
remotes={remotesCache[worktree.path]}
|
remotes={remotesCache[worktree.path]}
|
||||||
onOpenInEditor={handleOpenInEditor}
|
onOpenInEditor={handleOpenInEditor}
|
||||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||||
@@ -1375,7 +1430,7 @@ export function WorktreePanel({
|
|||||||
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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-vi
|
|||||||
import { ValidationDialog } from './github-issues-view/dialogs';
|
import { ValidationDialog } from './github-issues-view/dialogs';
|
||||||
import { AddFeatureDialog } from './board-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,
|
||||||
@@ -153,11 +154,17 @@ export function GitHubIssuesView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.filter(Boolean).join('\n');
|
return parts.join('\n');
|
||||||
},
|
},
|
||||||
[cachedValidations]
|
[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
|
// Open the Add Feature dialog with pre-filled data from a GitHub issue
|
||||||
const handleCreateFeature = useCallback((issue: GitHubIssue) => {
|
const handleCreateFeature = useCallback((issue: GitHubIssue) => {
|
||||||
setCreateFeatureIssue(issue);
|
setCreateFeatureIssue(issue);
|
||||||
@@ -178,7 +185,10 @@ export function GitHubIssuesView() {
|
|||||||
branchName: string;
|
branchName: string;
|
||||||
planningMode: string;
|
planningMode: string;
|
||||||
requirePlanApproval: boolean;
|
requirePlanApproval: boolean;
|
||||||
|
excludedPipelineSteps?: string[];
|
||||||
workMode: string;
|
workMode: string;
|
||||||
|
imagePaths?: Array<{ id: string; path: string; description?: string }>;
|
||||||
|
textFilePaths?: Array<{ id: string; path: string; description?: string }>;
|
||||||
}) => {
|
}) => {
|
||||||
if (!currentProject?.path) {
|
if (!currentProject?.path) {
|
||||||
toast.error('No project selected');
|
toast.error('No project selected');
|
||||||
@@ -203,6 +213,11 @@ export function GitHubIssuesView() {
|
|||||||
branchName: featureData.workMode === 'current' ? currentBranch : featureData.branchName,
|
branchName: featureData.workMode === 'current' ? currentBranch : featureData.branchName,
|
||||||
planningMode: featureData.planningMode,
|
planningMode: featureData.planningMode,
|
||||||
requirePlanApproval: featureData.requirePlanApproval,
|
requirePlanApproval: featureData.requirePlanApproval,
|
||||||
|
excludedPipelineSteps: featureData.excludedPipelineSteps,
|
||||||
|
...(featureData.imagePaths?.length ? { imagePaths: featureData.imagePaths } : {}),
|
||||||
|
...(featureData.textFilePaths?.length
|
||||||
|
? { textFilePaths: featureData.textFilePaths }
|
||||||
|
: {}),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -240,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.',
|
||||||
@@ -249,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()}`,
|
||||||
@@ -265,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(),
|
||||||
@@ -452,9 +472,7 @@ export function GitHubIssuesView() {
|
|||||||
isMaximized={false}
|
isMaximized={false}
|
||||||
projectPath={currentProject?.path}
|
projectPath={currentProject?.path}
|
||||||
prefilledTitle={createFeatureIssue?.title}
|
prefilledTitle={createFeatureIssue?.title}
|
||||||
prefilledDescription={
|
prefilledDescription={prefilledDescription}
|
||||||
createFeatureIssue ? buildIssueDescription(createFeatureIssue) : undefined
|
|
||||||
}
|
|
||||||
prefilledCategory="From GitHub"
|
prefilledCategory="From GitHub"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,14 @@ export function IssueDetailPanel({
|
|||||||
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30 gap-2">
|
<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 && (
|
{isMobile && (
|
||||||
<Button variant="ghost" size="sm" onClick={onClose} className="shrink-0 -ml-1">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClose}
|
||||||
|
className="shrink-0 -ml-1"
|
||||||
|
aria-label="Back"
|
||||||
|
title="Back"
|
||||||
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -104,7 +111,13 @@ 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" />
|
||||||
{!isMobile && 'View Result'}
|
{!isMobile && 'View Result'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -123,7 +136,13 @@ 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" />
|
||||||
{!isMobile && 'View (stale)'}
|
{!isMobile && 'View (stale)'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -140,6 +159,8 @@ 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" />
|
||||||
{!isMobile && 'Re-validate'}
|
{!isMobile && 'Re-validate'}
|
||||||
@@ -163,6 +184,8 @@ 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" />
|
||||||
{!isMobile && 'Validate with AI'}
|
{!isMobile && 'Validate with AI'}
|
||||||
@@ -181,7 +204,13 @@ export function IssueDetailPanel({
|
|||||||
Create Feature
|
Create Feature
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={() => onOpenInGitHub(issue.url)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onOpenInGitHub(issue.url)}
|
||||||
|
aria-label="Open in GitHub"
|
||||||
|
title="Open in GitHub"
|
||||||
|
>
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
{!isMobile && <span className="ml-1">Open in GitHub</span>}
|
{!isMobile && <span className="ml-1">Open in GitHub</span>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ import { getElectronAPI, type GitHubPR } from '@/lib/electron';
|
|||||||
import { useAppStore, type Feature } 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 { 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 { useCreateFeature } from '@/hooks/mutations/use-feature-mutations';
|
||||||
import { PRCommentResolutionDialog } from '@/components/dialogs/pr-comment-resolution-dialog';
|
import { PRCommentResolutionDialog } from '@/components/dialogs';
|
||||||
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -72,15 +73,15 @@ export function GitHubPRsView() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const featureId = `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
const featureId = `pr-${pr.number}-${generateUUID()}`;
|
||||||
const feature: Feature = {
|
const feature: Feature = {
|
||||||
id: featureId,
|
id: featureId,
|
||||||
title: `Address PR #${pr.number} Review Comments`,
|
title: `Address PR #${pr.number} Review Comments`,
|
||||||
category: 'bug-fix',
|
category: 'bug-fix',
|
||||||
description: `Read the review requests on PR #${pr.number} and address any feedback the best you can.`,
|
description: `Read the review requests on PR #${pr.number} and address any feedback the best you can.`,
|
||||||
steps: [],
|
steps: [],
|
||||||
status: 'in_progress',
|
status: 'backlog',
|
||||||
model: 'opus',
|
model: resolveModelString('opus'),
|
||||||
thinkingLevel: 'none',
|
thinkingLevel: 'none',
|
||||||
planningMode: 'skip',
|
planningMode: 'skip',
|
||||||
...(pr.headRefName ? { branchName: pr.headRefName } : {}),
|
...(pr.headRefName ? { branchName: pr.headRefName } : {}),
|
||||||
@@ -91,11 +92,26 @@ export function GitHubPRsView() {
|
|||||||
|
|
||||||
// Start the feature immediately after creation
|
// Start the feature immediately after creation
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
await api.features?.run(currentProject.path, featureId);
|
if (api.autoMode?.runFeature) {
|
||||||
|
try {
|
||||||
toast.success('Feature created and started', {
|
await api.autoMode.runFeature(currentProject.path, featureId);
|
||||||
description: `Addressing review comments on PR #${pr.number}`,
|
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) {
|
} catch (error) {
|
||||||
toast.error('Failed to create feature', {
|
toast.error('Failed to create feature', {
|
||||||
description: error instanceof Error ? error.message : 'An error occurred',
|
description: error instanceof Error ? error.message : 'An error occurred',
|
||||||
@@ -242,164 +258,177 @@ 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 gap-2">
|
return (
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{isMobile && (
|
{/* Detail Header */}
|
||||||
<Button
|
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30 gap-2">
|
||||||
variant="ghost"
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
size="sm"
|
{isMobile && (
|
||||||
onClick={() => setSelectedPR(null)}
|
<Button
|
||||||
className="shrink-0 -ml-1"
|
variant="ghost"
|
||||||
>
|
size="sm"
|
||||||
<ArrowLeft className="h-4 w-4" />
|
onClick={() => setSelectedPR(null)}
|
||||||
</Button>
|
className="shrink-0 -ml-1"
|
||||||
)}
|
>
|
||||||
{selectedPR.state === 'MERGED' ? (
|
<ArrowLeft className="h-4 w-4" />
|
||||||
<GitMerge className="h-4 w-4 text-purple-500 shrink-0" />
|
</Button>
|
||||||
) : (
|
|
||||||
<GitPullRequest className="h-4 w-4 text-green-500 shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium truncate">
|
|
||||||
#{selectedPR.number} {selectedPR.title}
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* PR Detail Content */}
|
|
||||||
<div className={cn('flex-1 overflow-auto', isMobile ? 'p-4' : '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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Review Comments CTA */}
|
{/* Meta info */}
|
||||||
<div className="mt-8 p-4 rounded-lg bg-blue-500/5 border border-blue-500/20">
|
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4 flex-wrap">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<span
|
||||||
<MessageSquare className="h-4 w-4 text-blue-500" />
|
className={cn(
|
||||||
<span className="text-sm font-medium">Review Comments</span>
|
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||||
</div>
|
selectedPR.state === 'MERGED'
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
? 'bg-purple-500/10 text-purple-500'
|
||||||
Manage review comments individually or let AI address all feedback automatically.
|
: selectedPR.isDraft
|
||||||
</p>
|
? 'bg-muted text-muted-foreground'
|
||||||
<div className={cn('flex gap-2', isMobile ? 'flex-col' : 'items-center')}>
|
: 'bg-green-500/10 text-green-500'
|
||||||
<Button variant="outline" onClick={() => setCommentDialogPR(selectedPR)}>
|
)}
|
||||||
<MessageSquare className="h-4 w-4 mr-2" />
|
>
|
||||||
Manage Review Comments
|
{selectedPR.state === 'MERGED'
|
||||||
</Button>
|
? 'Merged'
|
||||||
<Button variant="outline" onClick={() => handleAutoAddressComments(selectedPR)}>
|
: selectedPR.isDraft
|
||||||
<Zap className="h-4 w-4 mr-2" />
|
? 'Draft'
|
||||||
Address Review Comments
|
: 'Open'}
|
||||||
</Button>
|
</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>
|
||||||
|
);
|
||||||
{/* 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* PR Comment Resolution Dialog */}
|
{/* PR Comment Resolution Dialog */}
|
||||||
{commentDialogPR && (
|
{commentDialogPR && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, type KeyboardEvent } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef, type KeyboardEvent } from 'react';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +19,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useProjectSettings } from '@/hooks/queries';
|
import { useProjectSettings } from '@/hooks/queries';
|
||||||
import { useUpdateProjectSettings } from '@/hooks/mutations';
|
import { useUpdateProjectSettings } from '@/hooks/mutations';
|
||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { DEFAULT_TERMINAL_SCRIPTS } from './terminal-scripts-constants';
|
import { DEFAULT_TERMINAL_SCRIPTS } from './terminal-scripts-constants';
|
||||||
|
|
||||||
/** Preset dev server commands for quick selection */
|
/** Preset dev server commands for quick selection */
|
||||||
@@ -91,46 +92,69 @@ export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSection
|
|||||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
// Reset local state when project changes
|
// Track previous project path to detect project switches
|
||||||
useEffect(() => {
|
const prevProjectPathRef = useRef(project.path);
|
||||||
setDevCommand('');
|
// Track whether we've done the initial sync for the current project
|
||||||
setOriginalDevCommand('');
|
const isInitializedRef = useRef(false);
|
||||||
setTestCommand('');
|
|
||||||
setOriginalTestCommand('');
|
|
||||||
setScripts([]);
|
|
||||||
setOriginalScripts([]);
|
|
||||||
}, [project.path]);
|
|
||||||
|
|
||||||
// Sync commands state when project settings load
|
// Sync commands and scripts state when project settings load or project changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectSettings) {
|
const projectChanged = prevProjectPathRef.current !== project.path;
|
||||||
const dev = projectSettings.devCommand || '';
|
prevProjectPathRef.current = project.path;
|
||||||
const test = projectSettings.testCommand || '';
|
|
||||||
setDevCommand(dev);
|
|
||||||
setOriginalDevCommand(dev);
|
|
||||||
setTestCommand(test);
|
|
||||||
setOriginalTestCommand(test);
|
|
||||||
}
|
|
||||||
}, [projectSettings]);
|
|
||||||
|
|
||||||
// Sync scripts state when project settings load
|
// Always clear local state on project change to avoid flashing stale data
|
||||||
useEffect(() => {
|
if (projectChanged) {
|
||||||
if (projectSettings) {
|
isInitializedRef.current = false;
|
||||||
const configured = projectSettings.terminalScripts;
|
setDevCommand('');
|
||||||
const scriptList =
|
setOriginalDevCommand('');
|
||||||
configured && configured.length > 0
|
setTestCommand('');
|
||||||
? configured.map((s) => ({ id: s.id, name: s.name, command: s.command }))
|
setOriginalTestCommand('');
|
||||||
: DEFAULT_TERMINAL_SCRIPTS.map((s) => ({ ...s }));
|
setScripts([]);
|
||||||
setScripts(scriptList);
|
setOriginalScripts([]);
|
||||||
setOriginalScripts(JSON.parse(JSON.stringify(scriptList)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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]);
|
}, [projectSettings, project.path]);
|
||||||
|
|
||||||
// ── Change detection ──
|
// ── Change detection ──
|
||||||
const hasDevChanges = devCommand !== originalDevCommand;
|
const hasDevChanges = devCommand !== originalDevCommand;
|
||||||
const hasTestChanges = testCommand !== originalTestCommand;
|
const hasTestChanges = testCommand !== originalTestCommand;
|
||||||
const hasCommandChanges = hasDevChanges || hasTestChanges;
|
const hasCommandChanges = hasDevChanges || hasTestChanges;
|
||||||
const hasScriptChanges = JSON.stringify(scripts) !== JSON.stringify(originalScripts);
|
const hasScriptChanges = useMemo(
|
||||||
|
() => JSON.stringify(scripts) !== JSON.stringify(originalScripts),
|
||||||
|
[scripts, originalScripts]
|
||||||
|
);
|
||||||
const hasChanges = hasCommandChanges || hasScriptChanges;
|
const hasChanges = hasCommandChanges || hasScriptChanges;
|
||||||
const isSaving = updateSettingsMutation.isPending;
|
const isSaving = updateSettingsMutation.isPending;
|
||||||
|
|
||||||
@@ -158,7 +182,12 @@ export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSection
|
|||||||
setTestCommand(normalizedTestCommand);
|
setTestCommand(normalizedTestCommand);
|
||||||
setOriginalTestCommand(normalizedTestCommand);
|
setOriginalTestCommand(normalizedTestCommand);
|
||||||
setScripts(normalizedScripts);
|
setScripts(normalizedScripts);
|
||||||
setOriginalScripts(JSON.parse(JSON.stringify(normalizedScripts)));
|
setOriginalScripts(structuredClone(normalizedScripts));
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error('Failed to save settings', {
|
||||||
|
description: error instanceof Error ? error.message : 'An unexpected error occurred',
|
||||||
|
});
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -168,7 +197,7 @@ export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSection
|
|||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
setDevCommand(originalDevCommand);
|
setDevCommand(originalDevCommand);
|
||||||
setTestCommand(originalTestCommand);
|
setTestCommand(originalTestCommand);
|
||||||
setScripts(JSON.parse(JSON.stringify(originalScripts)));
|
setScripts(structuredClone(originalScripts));
|
||||||
}, [originalDevCommand, originalTestCommand, originalScripts]);
|
}, [originalDevCommand, originalTestCommand, originalScripts]);
|
||||||
|
|
||||||
// ── Command handlers ──
|
// ── Command handlers ──
|
||||||
@@ -258,6 +287,36 @@ export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSection
|
|||||||
setDragOverIndex(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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* ── Commands Card ── */}
|
{/* ── Commands Card ── */}
|
||||||
@@ -476,10 +535,14 @@ export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSection
|
|||||||
onDrop={(e) => handleDrop(e)}
|
onDrop={(e) => handleDrop(e)}
|
||||||
onDragEnd={(e) => handleDragEnd(e)}
|
onDragEnd={(e) => handleDragEnd(e)}
|
||||||
>
|
>
|
||||||
{/* Drag handle */}
|
{/* Drag handle - keyboard accessible */}
|
||||||
<div
|
<div
|
||||||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground shrink-0 p-0.5"
|
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"
|
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" />
|
<GripVertical className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import type { EventHook, EventHookTrigger } from '@automaker/types';
|
|||||||
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
|
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
|
||||||
import { EventHookDialog } from './event-hook-dialog';
|
import { EventHookDialog } from './event-hook-dialog';
|
||||||
import { EventHistoryView } from './event-history-view';
|
import { EventHistoryView } from './event-history-view';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
|
||||||
|
const logger = createLogger('EventHooks');
|
||||||
|
|
||||||
export function EventHooksSection() {
|
export function EventHooksSection() {
|
||||||
const { eventHooks, setEventHooks } = useAppStore();
|
const { eventHooks, setEventHooks } = useAppStore();
|
||||||
@@ -26,24 +30,39 @@ export function EventHooksSection() {
|
|||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteHook = (hookId: string) => {
|
const handleDeleteHook = async (hookId: string) => {
|
||||||
setEventHooks(eventHooks.filter((h) => h.id !== hookId));
|
try {
|
||||||
};
|
await setEventHooks(eventHooks.filter((h) => h.id !== hookId));
|
||||||
|
} catch (error) {
|
||||||
const handleToggleHook = (hookId: string, enabled: boolean) => {
|
logger.error('Failed to delete event hook:', error);
|
||||||
setEventHooks(eventHooks.map((h) => (h.id === hookId ? { ...h, enabled } : h)));
|
toast.error('Failed to delete event hook');
|
||||||
};
|
}
|
||||||
|
};
|
||||||
const handleSaveHook = (hook: EventHook) => {
|
|
||||||
if (editingHook) {
|
const handleToggleHook = async (hookId: string, enabled: boolean) => {
|
||||||
// Update existing
|
try {
|
||||||
setEventHooks(eventHooks.map((h) => (h.id === hook.id ? hook : h)));
|
await setEventHooks(eventHooks.map((h) => (h.id === hookId ? { ...h, enabled } : h)));
|
||||||
} else {
|
} catch (error) {
|
||||||
// Add new
|
logger.error('Failed to toggle event hook:', error);
|
||||||
setEventHooks([...eventHooks, hook]);
|
toast.error('Failed to update event hook');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveHook = async (hook: EventHook) => {
|
||||||
|
try {
|
||||||
|
if (editingHook) {
|
||||||
|
// Update existing
|
||||||
|
await setEventHooks(eventHooks.map((h) => (h.id === hook.id ? hook : h)));
|
||||||
|
} else {
|
||||||
|
// Add new
|
||||||
|
await setEventHooks([...eventHooks, hook]);
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
setEditingHook(null);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to save event hook:', error);
|
||||||
|
toast.error('Failed to save event hook');
|
||||||
}
|
}
|
||||||
setDialogOpen(false);
|
|
||||||
setEditingHook(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Group hooks by trigger type for better organization
|
// Group hooks by trigger type for better organization
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -276,6 +279,8 @@ export function TerminalView({
|
|||||||
setTerminalLineHeight,
|
setTerminalLineHeight,
|
||||||
setTerminalScrollbackLines,
|
setTerminalScrollbackLines,
|
||||||
setTerminalScreenReaderMode,
|
setTerminalScreenReaderMode,
|
||||||
|
setTerminalBackgroundColor,
|
||||||
|
setTerminalForegroundColor,
|
||||||
updateTerminalPanelSizes,
|
updateTerminalPanelSizes,
|
||||||
currentWorktreeByProject,
|
currentWorktreeByProject,
|
||||||
worktreesByProject,
|
worktreesByProject,
|
||||||
@@ -588,7 +593,7 @@ export function TerminalView({
|
|||||||
|
|
||||||
// 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
|
||||||
@@ -1162,6 +1167,18 @@ export function TerminalView({
|
|||||||
// 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(() => ({}));
|
||||||
@@ -1174,6 +1191,17 @@ export function TerminalView({
|
|||||||
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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1208,6 +1236,22 @@ export function TerminalView({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -1958,6 +2002,119 @@ export function TerminalView({
|
|||||||
/>
|
/>
|
||||||
</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">
|
||||||
@@ -2025,6 +2182,13 @@ export function TerminalView({
|
|||||||
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!)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -202,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);
|
||||||
@@ -679,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;
|
||||||
@@ -687,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({
|
||||||
@@ -1484,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(() => {
|
||||||
@@ -1925,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}
|
||||||
@@ -2395,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}
|
||||||
@@ -2456,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}`,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export {
|
|||||||
useCommitWorktree,
|
useCommitWorktree,
|
||||||
usePushWorktree,
|
usePushWorktree,
|
||||||
usePullWorktree,
|
usePullWorktree,
|
||||||
|
useSyncWorktree,
|
||||||
|
useSetTracking,
|
||||||
useCreatePullRequest,
|
useCreatePullRequest,
|
||||||
useMergeWorktree,
|
useMergeWorktree,
|
||||||
useSwitchBranch,
|
useSwitchBranch,
|
||||||
|
|||||||
@@ -197,6 +197,76 @@ export function usePullWorktree() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync worktree branch (pull then push)
|
||||||
|
*
|
||||||
|
* @returns Mutation for syncing changes
|
||||||
|
*/
|
||||||
|
export function useSyncWorktree() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ worktreePath, remote }: { worktreePath: string; remote?: string }) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.worktree) throw new Error('Worktree API not available');
|
||||||
|
const result = await api.worktree.sync(worktreePath, remote);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to sync');
|
||||||
|
}
|
||||||
|
return result.result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
|
||||||
|
toast.success('Branch synced with remote');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to sync', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set upstream tracking branch
|
||||||
|
*
|
||||||
|
* @returns Mutation for setting tracking branch
|
||||||
|
*/
|
||||||
|
export function useSetTracking() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
worktreePath,
|
||||||
|
remote,
|
||||||
|
branch,
|
||||||
|
}: {
|
||||||
|
worktreePath: string;
|
||||||
|
remote: string;
|
||||||
|
branch?: string;
|
||||||
|
}) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.worktree) throw new Error('Worktree API not available');
|
||||||
|
const result = await api.worktree.setTracking(worktreePath, remote, branch);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to set tracking branch');
|
||||||
|
}
|
||||||
|
return result.result;
|
||||||
|
},
|
||||||
|
onSuccess: (result) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
|
||||||
|
toast.success('Tracking branch set', {
|
||||||
|
description: result?.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to set tracking branch', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a pull request from a worktree
|
* Create a pull request from a worktree
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -8,12 +8,21 @@ import { getHttpApiClient } from '@/lib/http-api-client';
|
|||||||
* before the user opens feature dialogs.
|
* before the user opens feature dialogs.
|
||||||
*/
|
*/
|
||||||
export function useCursorStatusInit() {
|
export function useCursorStatusInit() {
|
||||||
const { setCursorCliStatus, cursorCliStatus } = useSetupStore();
|
// Use individual selectors instead of bare useSetupStore() to prevent
|
||||||
|
// re-rendering on every setup store mutation during initialization.
|
||||||
|
const setCursorCliStatus = useSetupStore((s) => s.setCursorCliStatus);
|
||||||
const initialized = useRef(false);
|
const initialized = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only initialize once per session
|
// Only initialize once per session
|
||||||
if (initialized.current || cursorCliStatus !== null) {
|
if (initialized.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Check current status at call time rather than via dependency to avoid
|
||||||
|
// re-renders when other setup store fields change during initialization.
|
||||||
|
const currentStatus = useSetupStore.getState().cursorCliStatus;
|
||||||
|
if (currentStatus !== null) {
|
||||||
|
initialized.current = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
initialized.current = true;
|
initialized.current = true;
|
||||||
@@ -42,5 +51,5 @@ export function useCursorStatusInit() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
initCursorStatus();
|
initCursorStatus();
|
||||||
}, [setCursorCliStatus, cursorCliStatus]);
|
}, [setCursorCliStatus]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,17 +17,16 @@ const logger = createLogger('ProviderAuthInit');
|
|||||||
* without needing to visit the settings page first.
|
* without needing to visit the settings page first.
|
||||||
*/
|
*/
|
||||||
export function useProviderAuthInit() {
|
export function useProviderAuthInit() {
|
||||||
const {
|
// IMPORTANT: Use individual selectors instead of bare useSetupStore() to prevent
|
||||||
setClaudeAuthStatus,
|
// re-rendering on every setup store mutation. The bare call subscribes to the ENTIRE
|
||||||
setCodexAuthStatus,
|
// store, which during initialization causes cascading re-renders as multiple status
|
||||||
setZaiAuthStatus,
|
// setters fire in rapid succession. With enough rapid mutations, React hits the
|
||||||
setGeminiCliStatus,
|
// maximum update depth limit (error #185).
|
||||||
setGeminiAuthStatus,
|
const setClaudeAuthStatus = useSetupStore((s) => s.setClaudeAuthStatus);
|
||||||
claudeAuthStatus,
|
const setCodexAuthStatus = useSetupStore((s) => s.setCodexAuthStatus);
|
||||||
codexAuthStatus,
|
const setZaiAuthStatus = useSetupStore((s) => s.setZaiAuthStatus);
|
||||||
zaiAuthStatus,
|
const setGeminiCliStatus = useSetupStore((s) => s.setGeminiCliStatus);
|
||||||
geminiAuthStatus,
|
const setGeminiAuthStatus = useSetupStore((s) => s.setGeminiAuthStatus);
|
||||||
} = useSetupStore();
|
|
||||||
const initialized = useRef(false);
|
const initialized = useRef(false);
|
||||||
|
|
||||||
const refreshStatuses = useCallback(async () => {
|
const refreshStatuses = useCallback(async () => {
|
||||||
@@ -219,5 +218,9 @@ export function useProviderAuthInit() {
|
|||||||
// Always call refreshStatuses() to background re-validate on app restart,
|
// Always call refreshStatuses() to background re-validate on app restart,
|
||||||
// even when statuses are pre-populated from persisted storage (cache case).
|
// even when statuses are pre-populated from persisted storage (cache case).
|
||||||
void refreshStatuses();
|
void refreshStatuses();
|
||||||
}, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus, geminiAuthStatus]);
|
// Only depend on the callback ref. The status values were previously included
|
||||||
|
// but they are outputs of refreshStatuses(), not inputs — including them caused
|
||||||
|
// cascading re-renders during initialization that triggered React error #185
|
||||||
|
// (maximum update depth exceeded) on first run.
|
||||||
|
}, [refreshStatuses]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { useEffect, useState, useRef } from 'react';
|
|||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client';
|
import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client';
|
||||||
import { getItem, setItem } from '@/lib/storage';
|
import { getItem, setItem } from '@/lib/storage';
|
||||||
|
import { sanitizeWorktreeByProject } from '@/lib/settings-utils';
|
||||||
import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store';
|
import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import {
|
import {
|
||||||
@@ -213,6 +214,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 +364,36 @@ export function mergeSettings(
|
|||||||
merged.claudeCompatibleProviders = localSettings.claudeCompatibleProviders;
|
merged.claudeCompatibleProviders = localSettings.claudeCompatibleProviders;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Event hooks - preserve from localStorage if server is empty
|
||||||
|
if (
|
||||||
|
(!serverSettings.eventHooks || serverSettings.eventHooks.length === 0) &&
|
||||||
|
localSettings.eventHooks &&
|
||||||
|
localSettings.eventHooks.length > 0
|
||||||
|
) {
|
||||||
|
merged.eventHooks = localSettings.eventHooks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 +765,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',
|
||||||
@@ -753,7 +795,14 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
|||||||
projectHistory: settings.projectHistory ?? [],
|
projectHistory: settings.projectHistory ?? [],
|
||||||
projectHistoryIndex: settings.projectHistoryIndex ?? -1,
|
projectHistoryIndex: settings.projectHistoryIndex ?? -1,
|
||||||
lastSelectedSessionByProject: settings.lastSelectedSessionByProject ?? {},
|
lastSelectedSessionByProject: settings.lastSelectedSessionByProject ?? {},
|
||||||
currentWorktreeByProject: settings.currentWorktreeByProject ?? {},
|
// Sanitize currentWorktreeByProject: only restore entries where path is null
|
||||||
|
// (main branch). Non-null paths point to worktree directories that may have
|
||||||
|
// been deleted while the app was closed. Restoring a stale path causes the
|
||||||
|
// board to render an invalid worktree selection, triggering a crash loop
|
||||||
|
// (error boundary reloads → restores same bad path → crash again).
|
||||||
|
// The use-worktrees validation effect will re-discover valid worktrees
|
||||||
|
// from the server once they load.
|
||||||
|
currentWorktreeByProject: sanitizeWorktreeByProject(settings.currentWorktreeByProject),
|
||||||
// UI State
|
// UI State
|
||||||
worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false,
|
worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false,
|
||||||
lastProjectDir: settings.lastProjectDir ?? '',
|
lastProjectDir: settings.lastProjectDir ?? '',
|
||||||
@@ -763,11 +812,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 +890,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 +926,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useAppStore, type ThemeMode, THEME_STORAGE_KEY } from '@/store/app-stor
|
|||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { useAuthStore } from '@/store/auth-store';
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration';
|
import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration';
|
||||||
|
import { sanitizeWorktreeByProject } from '@/lib/settings-utils';
|
||||||
import {
|
import {
|
||||||
DEFAULT_OPENCODE_MODEL,
|
DEFAULT_OPENCODE_MODEL,
|
||||||
DEFAULT_GEMINI_MODEL,
|
DEFAULT_GEMINI_MODEL,
|
||||||
@@ -49,6 +50,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 +93,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 +109,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 +118,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 +154,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 +203,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];
|
||||||
}
|
}
|
||||||
@@ -557,6 +585,15 @@ export async function forceSyncSettingsToServer(): Promise<boolean> {
|
|||||||
updates[field] = setupState[field as keyof typeof setupState];
|
updates[field] = setupState[field as keyof typeof setupState];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update localStorage cache immediately so a page reload before the
|
||||||
|
// server response arrives still sees the latest state (e.g. after
|
||||||
|
// deleting a worktree, the stale worktree path won't survive in cache).
|
||||||
|
try {
|
||||||
|
setItem('automaker-settings-cache', JSON.stringify(updates));
|
||||||
|
} catch (storageError) {
|
||||||
|
logger.warn('Failed to update localStorage cache during force sync:', storageError);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await api.settings.updateGlobal(updates);
|
const result = await api.settings.updateGlobal(updates);
|
||||||
return result.success;
|
return result.success;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -730,6 +767,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 +784,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 +806,11 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
projectHistory: serverSettings.projectHistory,
|
projectHistory: serverSettings.projectHistory,
|
||||||
projectHistoryIndex: serverSettings.projectHistoryIndex,
|
projectHistoryIndex: serverSettings.projectHistoryIndex,
|
||||||
lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject,
|
lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject,
|
||||||
|
// Sanitize: only restore entries with path === null (main branch).
|
||||||
|
// Non-null paths may reference deleted worktrees, causing crash loops.
|
||||||
|
currentWorktreeByProject: sanitizeWorktreeByProject(
|
||||||
|
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 +826,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 +840,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,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -339,6 +339,8 @@ export interface PRReviewComment {
|
|||||||
side?: string;
|
side?: string;
|
||||||
/** The commit ID the comment was made on */
|
/** The commit ID the comment was made on */
|
||||||
commitId?: string;
|
commitId?: string;
|
||||||
|
/** Whether the comment author is a bot/app account */
|
||||||
|
isBot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitHubAPI {
|
export interface GitHubAPI {
|
||||||
@@ -2266,7 +2268,12 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
push: async (worktreePath: string, force?: boolean, remote?: string) => {
|
push: async (
|
||||||
|
worktreePath: string,
|
||||||
|
force?: boolean,
|
||||||
|
remote?: string,
|
||||||
|
_autoResolve?: boolean
|
||||||
|
) => {
|
||||||
const targetRemote = remote || 'origin';
|
const targetRemote = remote || 'origin';
|
||||||
console.log('[Mock] Pushing worktree:', { worktreePath, force, remote: targetRemote });
|
console.log('[Mock] Pushing worktree:', { worktreePath, force, remote: targetRemote });
|
||||||
return {
|
return {
|
||||||
@@ -2279,6 +2286,38 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
sync: async (worktreePath: string, remote?: string) => {
|
||||||
|
const targetRemote = remote || 'origin';
|
||||||
|
console.log('[Mock] Syncing worktree:', { worktreePath, remote: targetRemote });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
branch: 'feature-branch',
|
||||||
|
pulled: true,
|
||||||
|
pushed: true,
|
||||||
|
message: `Synced with ${targetRemote}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
setTracking: async (worktreePath: string, remote: string, branch?: string) => {
|
||||||
|
const targetBranch = branch || 'feature-branch';
|
||||||
|
console.log('[Mock] Setting tracking branch:', {
|
||||||
|
worktreePath,
|
||||||
|
remote,
|
||||||
|
branch: targetBranch,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
branch: targetBranch,
|
||||||
|
remote,
|
||||||
|
upstream: `${remote}/${targetBranch}`,
|
||||||
|
message: `Set tracking branch to ${remote}/${targetBranch}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
createPR: async (worktreePath: string, options?: CreatePROptions) => {
|
createPR: async (worktreePath: string, options?: CreatePROptions) => {
|
||||||
console.log('[Mock] Creating PR:', { worktreePath, options });
|
console.log('[Mock] Creating PR:', { worktreePath, options });
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2208,8 +2208,12 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.post('/api/worktree/generate-commit-message', { worktreePath }),
|
this.post('/api/worktree/generate-commit-message', { worktreePath }),
|
||||||
generatePRDescription: (worktreePath: string, baseBranch?: string) =>
|
generatePRDescription: (worktreePath: string, baseBranch?: string) =>
|
||||||
this.post('/api/worktree/generate-pr-description', { worktreePath, baseBranch }),
|
this.post('/api/worktree/generate-pr-description', { worktreePath, baseBranch }),
|
||||||
push: (worktreePath: string, force?: boolean, remote?: string) =>
|
push: (worktreePath: string, force?: boolean, remote?: string, autoResolve?: boolean) =>
|
||||||
this.post('/api/worktree/push', { worktreePath, force, remote }),
|
this.post('/api/worktree/push', { worktreePath, force, remote, autoResolve }),
|
||||||
|
sync: (worktreePath: string, remote?: string) =>
|
||||||
|
this.post('/api/worktree/sync', { worktreePath, remote }),
|
||||||
|
setTracking: (worktreePath: string, remote: string, branch?: string) =>
|
||||||
|
this.post('/api/worktree/set-tracking', { worktreePath, remote, branch }),
|
||||||
createPR: (worktreePath: string, options?: CreatePROptions) =>
|
createPR: (worktreePath: string, options?: CreatePROptions) =>
|
||||||
this.post('/api/worktree/create-pr', { worktreePath, ...options }),
|
this.post('/api/worktree/create-pr', { worktreePath, ...options }),
|
||||||
getDiffs: (projectPath: string, featureId: string) =>
|
getDiffs: (projectPath: string, featureId: string) =>
|
||||||
|
|||||||
27
apps/ui/src/lib/settings-utils.ts
Normal file
27
apps/ui/src/lib/settings-utils.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Shared settings utility functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop currentWorktreeByProject entries with non-null paths.
|
||||||
|
* Non-null paths reference worktree directories that may have been deleted,
|
||||||
|
* and restoring them causes crash loops (board renders invalid worktree
|
||||||
|
* -> error boundary reloads -> restores same stale path).
|
||||||
|
*/
|
||||||
|
export function sanitizeWorktreeByProject(
|
||||||
|
raw: Record<string, { path: string | null; branch: string }> | undefined
|
||||||
|
): Record<string, { path: string | null; branch: string }> {
|
||||||
|
if (!raw) return {};
|
||||||
|
const sanitized: Record<string, { path: string | null; branch: string }> = {};
|
||||||
|
for (const [projectPath, worktree] of Object.entries(raw)) {
|
||||||
|
if (
|
||||||
|
typeof worktree === 'object' &&
|
||||||
|
worktree !== null &&
|
||||||
|
'path' in worktree &&
|
||||||
|
worktree.path === null
|
||||||
|
) {
|
||||||
|
sanitized[projectPath] = worktree;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -583,6 +594,21 @@ function RootLayoutContent() {
|
|||||||
logger.info(
|
logger.info(
|
||||||
'[FAST_HYDRATE] Background reconcile: cache updated (store untouched)'
|
'[FAST_HYDRATE] Background reconcile: cache updated (store untouched)'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Selectively reconcile event hooks from server.
|
||||||
|
// Unlike projects/theme, eventHooks aren't rendered on the main view,
|
||||||
|
// so updating them won't cause a visible re-render flash.
|
||||||
|
const serverHooks = (finalSettings as GlobalSettings).eventHooks ?? [];
|
||||||
|
const currentHooks = useAppStore.getState().eventHooks;
|
||||||
|
if (
|
||||||
|
JSON.stringify(serverHooks) !== JSON.stringify(currentHooks) &&
|
||||||
|
serverHooks.length > 0
|
||||||
|
) {
|
||||||
|
logger.info(
|
||||||
|
`[FAST_HYDRATE] Reconciling eventHooks from server (server=${serverHooks.length}, store=${currentHooks.length})`
|
||||||
|
);
|
||||||
|
useAppStore.setState({ eventHooks: serverHooks });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug('[FAST_HYDRATE] Failed to update cache:', e);
|
logger.debug('[FAST_HYDRATE] Failed to update cache:', e);
|
||||||
}
|
}
|
||||||
@@ -724,33 +750,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -877,6 +878,13 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
set((state) => ({
|
set((state) => ({
|
||||||
features: state.features.map((f) => (f.id === id ? { ...f, ...updates } : f)),
|
features: state.features.map((f) => (f.id === id ? { ...f, ...updates } : f)),
|
||||||
})),
|
})),
|
||||||
|
batchUpdateFeatures: (ids, updates) => {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const idSet = new Set(ids);
|
||||||
|
set((state) => ({
|
||||||
|
features: state.features.map((f) => (idSet.has(f.id) ? { ...f, ...updates } : f)),
|
||||||
|
}));
|
||||||
|
},
|
||||||
addFeature: (feature) => {
|
addFeature: (feature) => {
|
||||||
const id = feature.id ?? `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
const id = feature.id ?? `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
const newFeature = { ...feature, id } as Feature;
|
const newFeature = { ...feature, id } as Feature;
|
||||||
@@ -991,7 +999,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 +1117,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 +1127,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 +1137,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 +1147,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 +1157,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 +1230,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 +1242,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 +1252,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 +1287,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 +1296,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 +1305,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 +1314,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 +1323,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 +1383,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 +1392,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,14 +1415,22 @@ 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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Event Hook actions
|
// Event Hook actions
|
||||||
setEventHooks: (hooks) => set({ eventHooks: hooks }),
|
setEventHooks: async (hooks) => {
|
||||||
|
set({ eventHooks: hooks });
|
||||||
|
try {
|
||||||
|
const httpApi = getHttpApiClient();
|
||||||
|
await httpApi.settings.updateGlobal({ eventHooks: hooks });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to sync event hooks:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Claude-Compatible Provider actions (new system)
|
// Claude-Compatible Provider actions (new system)
|
||||||
addClaudeCompatibleProvider: async (provider) => {
|
addClaudeCompatibleProvider: async (provider) => {
|
||||||
@@ -1423,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) {
|
||||||
@@ -1438,7 +1454,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 +1467,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 +1478,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 +1491,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 +1506,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 +1519,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 +1532,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 +1544,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 +1553,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 +1963,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 +2367,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 +2378,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
|
||||||
@@ -437,6 +440,8 @@ export interface AppActions {
|
|||||||
// Feature actions
|
// Feature actions
|
||||||
setFeatures: (features: Feature[]) => void;
|
setFeatures: (features: Feature[]) => void;
|
||||||
updateFeature: (id: string, updates: Partial<Feature>) => void;
|
updateFeature: (id: string, updates: Partial<Feature>) => void;
|
||||||
|
/** Apply the same updates to multiple features in a single store mutation. */
|
||||||
|
batchUpdateFeatures: (ids: string[], updates: Partial<Feature>) => void;
|
||||||
addFeature: (feature: Omit<Feature, 'id'> & Partial<Pick<Feature, 'id'>>) => Feature;
|
addFeature: (feature: Omit<Feature, 'id'> & Partial<Pick<Feature, 'id'>>) => Feature;
|
||||||
removeFeature: (id: string) => void;
|
removeFeature: (id: string) => void;
|
||||||
moveFeature: (id: string, newStatus: Feature['status']) => void;
|
moveFeature: (id: string, newStatus: Feature['status']) => void;
|
||||||
@@ -564,6 +569,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;
|
||||||
@@ -630,7 +636,7 @@ export interface AppActions {
|
|||||||
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
|
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
|
||||||
|
|
||||||
// Event Hook actions
|
// Event Hook actions
|
||||||
setEventHooks: (hooks: EventHook[]) => void;
|
setEventHooks: (hooks: EventHook[]) => Promise<void>;
|
||||||
|
|
||||||
// Claude-Compatible Provider actions (new system)
|
// Claude-Compatible Provider actions (new system)
|
||||||
addClaudeCompatibleProvider: (provider: ClaudeCompatibleProvider) => Promise<void>;
|
addClaudeCompatibleProvider: (provider: ClaudeCompatibleProvider) => Promise<void>;
|
||||||
@@ -708,6 +714,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.
|
||||||
|
|||||||
59
apps/ui/src/types/electron.d.ts
vendored
59
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;
|
||||||
}>;
|
}>;
|
||||||
@@ -966,18 +980,61 @@ export interface WorktreeAPI {
|
|||||||
push: (
|
push: (
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
force?: boolean,
|
force?: boolean,
|
||||||
remote?: string
|
remote?: string,
|
||||||
|
autoResolve?: boolean
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
result?: {
|
result?: {
|
||||||
branch: string;
|
branch: string;
|
||||||
pushed: boolean;
|
pushed: boolean;
|
||||||
|
diverged?: boolean;
|
||||||
|
autoResolved?: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
|
diverged?: boolean;
|
||||||
|
hasConflicts?: boolean;
|
||||||
|
conflictFiles?: string[];
|
||||||
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
|
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
// Sync a worktree branch (pull then push)
|
||||||
|
sync: (
|
||||||
|
worktreePath: string,
|
||||||
|
remote?: string
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
result?: {
|
||||||
|
branch: string;
|
||||||
|
pulled: boolean;
|
||||||
|
pushed: boolean;
|
||||||
|
isFastForward?: boolean;
|
||||||
|
isMerge?: boolean;
|
||||||
|
autoResolved?: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
hasConflicts?: boolean;
|
||||||
|
conflictFiles?: string[];
|
||||||
|
conflictSource?: 'pull' | 'stash';
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Set the upstream tracking branch
|
||||||
|
setTracking: (
|
||||||
|
worktreePath: string,
|
||||||
|
remote: string,
|
||||||
|
branch?: string
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
result?: {
|
||||||
|
branch: string;
|
||||||
|
remote: string;
|
||||||
|
upstream: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
// Create a pull request from a worktree
|
// Create a pull request from a worktree
|
||||||
createPR: (
|
createPR: (
|
||||||
worktreePath: string,
|
worktreePath: 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