1 Commits

Author SHA1 Message Date
gsxdsm
aa345a50ac feat: Add PR review comments and resolution endpoints, improve prompt handling 2026-02-20 16:08:15 -08:00
78 changed files with 1323 additions and 4725 deletions

View File

@@ -209,10 +209,9 @@ 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
# When VITE_SERVER_URL is empty, the UI uses relative URLs (e.g., /api/...) which nginx proxies # VITE_SERVER_URL tells the UI where to find the API server
# to the server container. This avoids CORS issues entirely in Docker Compose setups. # Use ARG to allow overriding at build time: --build-arg VITE_SERVER_URL=http://api.example.com
# Override at build time if needed: --build-arg VITE_SERVER_URL=http://api.example.com ARG VITE_SERVER_URL=http://localhost:3008
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

View File

@@ -267,26 +267,6 @@ 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) => {
@@ -297,26 +277,36 @@ 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(',') const allowedOrigins = process.env.CORS_ORIGIN?.split(',').map((o) => o.trim());
.map((o) => o.trim()) if (allowedOrigins && allowedOrigins.length > 0 && allowedOrigins[0] !== '*') {
.filter(Boolean);
if (allowedOrigins && allowedOrigins.length > 0) {
if (allowedOrigins.includes('*')) {
callback(null, true);
return;
}
if (allowedOrigins.includes(origin)) { if (allowedOrigins.includes(origin)) {
callback(null, origin); callback(null, origin);
return; } else {
callback(new Error('Not allowed by CORS'));
} }
// Fall through to local network check below return;
} }
// Allow all localhost/loopback/private network origins (any port) // For local development, allow all localhost/loopback origins (any port)
if (isLocalOrigin(origin)) { 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 {
// Ignore URL parsing errors
}
// Reject other origins by default for security // Reject other origins by default for security
callback(new Error('Not allowed by CORS')); callback(new Error('Not allowed by CORS'));

View File

@@ -1,37 +0,0 @@
/**
* 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);
}

View File

@@ -367,11 +367,6 @@ 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
@@ -408,7 +403,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: config.maxTurns ?? MAX_TURNS.maximum, maxTurns: MAX_TURNS.maximum,
cwd: config.cwd, cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.specGeneration], allowedTools: [...TOOL_PRESETS.specGeneration],
...claudeMdOptions, ...claudeMdOptions,
@@ -442,7 +437,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: config.maxTurns ?? MAX_TURNS.quick, maxTurns: MAX_TURNS.quick,
cwd: config.cwd, cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly], allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions, ...claudeMdOptions,
@@ -473,7 +468,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
return { return {
...getBaseOptions(), ...getBaseOptions(),
model: getModelForUseCase('suggestions', config.model), model: getModelForUseCase('suggestions', config.model),
maxTurns: config.maxTurns ?? MAX_TURNS.extended, maxTurns: MAX_TURNS.extended,
cwd: config.cwd, cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly], allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions, ...claudeMdOptions,
@@ -511,7 +506,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
return { return {
...getBaseOptions(), ...getBaseOptions(),
model: getModelForUseCase('chat', effectiveModel), model: getModelForUseCase('chat', effectiveModel),
maxTurns: config.maxTurns ?? MAX_TURNS.standard, maxTurns: MAX_TURNS.standard,
cwd: config.cwd, cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.chat], allowedTools: [...TOOL_PRESETS.chat],
...claudeMdOptions, ...claudeMdOptions,
@@ -546,7 +541,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
return { return {
...getBaseOptions(), ...getBaseOptions(),
model: getModelForUseCase('auto', config.model), model: getModelForUseCase('auto', config.model),
maxTurns: config.maxTurns ?? MAX_TURNS.maximum, maxTurns: MAX_TURNS.maximum,
cwd: config.cwd, cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.fullAccess], allowedTools: [...TOOL_PRESETS.fullAccess],
...claudeMdOptions, ...claudeMdOptions,

View File

@@ -33,16 +33,9 @@ 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.
* Falls back to global settings and defaults to true when unset. * Returns false if settings service is not available.
* 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
@@ -55,8 +48,8 @@ export async function getAutoLoadClaudeMdSetting(
logPrefix = '[SettingsHelper]' logPrefix = '[SettingsHelper]'
): Promise<boolean> { ): Promise<boolean> {
if (!settingsService) { if (!settingsService) {
logger.info(`${logPrefix} SettingsService not available, autoLoadClaudeMd defaulting to true`); logger.info(`${logPrefix} SettingsService not available, autoLoadClaudeMd disabled`);
return true; return false;
} }
try { try {
@@ -71,7 +64,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 ?? true; const result = globalSettings.autoLoadClaudeMd ?? false;
logger.info(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`); logger.info(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`);
return result; return result;
} catch (error) { } catch (error) {
@@ -80,41 +73,6 @@ 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.

View File

@@ -180,7 +180,7 @@ export class ClaudeProvider extends BaseProvider {
model, model,
cwd, cwd,
systemPrompt, systemPrompt,
maxTurns = 1000, maxTurns = 100,
allowedTools, allowedTools,
abortController, abortController,
conversationHistory, conversationHistory,

View File

@@ -738,16 +738,6 @@ 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(

View File

@@ -24,9 +24,7 @@ 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)));
// Default content to empty string if undefined/null to prevent writing await secureFs.writeFile(filePath, content, 'utf-8');
// "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) {

View File

@@ -1,14 +1,38 @@
/** /**
* 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);
// Re-export shared utilities from the canonical location // Extended PATH to include common tool installation locations
export { extendedPath, execEnv, getErrorMessage, logError } from '../../../lib/exec-utils.js'; 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);
}

View File

@@ -5,26 +5,287 @@
* 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 { getErrorMessage, logError } from './common.js'; import { execAsync, execEnv, 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';
// Re-export types so existing callers continue to work export interface PRReviewComment {
export type { PRReviewComment, ListPRReviewCommentsResult }; id: string;
// Re-export service functions so existing callers continue to work author: string;
export { fetchPRReviewComments, fetchReviewThreadResolvedStatus }; 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;
}
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 {

View File

@@ -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 { getErrorMessage, logError } from './common.js'; import { execEnv, 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,6 +22,91 @@ 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 {

View File

@@ -4,8 +4,7 @@
* 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. Syncs the base branch from its remote tracking branch (fast-forward only) * 3. Only creates a new worktree if none exists for the branch
* 4. Only creates a new worktree if none exists for the branch
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
@@ -28,10 +27,6 @@ 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');
@@ -198,52 +193,6 @@ 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 {
@@ -277,19 +226,6 @@ 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 {
@@ -311,17 +247,6 @@ 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,
},
}
: {}),
}, },
}); });

View File

@@ -6,7 +6,7 @@
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { execFile } from 'child_process'; import { exec } 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 execFileAsync = promisify(execFile); const execAsync = promisify(exec);
/** 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,40 +33,21 @@ 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) => {
timerId = setTimeout( setTimeout(() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), timeoutMs);
() => 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;
try {
while (!done) { while (!done) {
const result = await Promise.race([iterator.next(), timeoutPromise]).catch(async (err) => { const result = await Promise.race([iterator.next(), timeoutPromise]);
// Capture the original error, then attempt to close the iterator.
// If iterator.return() throws, log it but rethrow the original error
// 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) { if (result.done) {
done = true; done = true;
} else { } else {
yield result.value; yield result.value;
} }
} }
} finally {
clearTimeout(timerId);
}
} }
/** /**
@@ -136,14 +117,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 execFileAsync('git', ['diff', '--cached'], { const { stdout: stagedDiff } = await execAsync('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 execFileAsync('git', ['diff'], { const { stdout: unstagedDiff } = await execAsync('git diff', {
cwd: worktreePath, cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, // 5MB buffer maxBuffer: 1024 * 1024 * 5, // 5MB buffer
}); });
@@ -241,7 +222,7 @@ export function createGenerateCommitMessageHandler(
const message = responseText.trim(); const message = responseText.trim();
if (!message) { if (!message || message.trim().length === 0) {
logger.warn('Received empty response from model'); logger.warn('Received empty response from model');
const response: GenerateCommitMessageErrorResponse = { const response: GenerateCommitMessageErrorResponse = {
success: false, success: false,

View File

@@ -38,8 +38,6 @@ 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;
@@ -101,22 +99,10 @@ 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: resolvedMaxTurns, maxTurns: sdkOptions?.maxTurns,
cwd: workDir, cwd: workDir,
allowedTools: sdkOptions?.allowedTools as string[] | undefined, allowedTools: sdkOptions?.allowedTools as string[] | undefined,
abortController, abortController,
@@ -293,17 +279,6 @@ 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 {
@@ -313,6 +288,10 @@ 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 };
} }
@@ -372,13 +351,8 @@ 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, taskMaxTurns) this.buildExecOpts(options, taskPrompt, Math.min(sdkOptions?.maxTurns ?? 100, 100))
); );
let taskOutput = '', let taskOutput = '',
taskStartDetected = false, taskStartDetected = false,
@@ -597,7 +571,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 ?? DEFAULT_MAX_TURNS) this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns ?? 100)
)) { )) {
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)
@@ -683,7 +657,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,
@@ -715,7 +689,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 ?? DEFAULT_MAX_TURNS) this.buildExecOpts(options, contPrompt, options.sdkOptions?.maxTurns)
)) { )) {
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) {

View File

@@ -28,7 +28,6 @@ import {
getSubagentsConfiguration, getSubagentsConfiguration,
getCustomSubagents, getCustomSubagents,
getProviderByModelId, getProviderByModelId,
getDefaultMaxTurnsSetting,
} from '../lib/settings-helpers.js'; } from '../lib/settings-helpers.js';
interface Message { interface Message {
@@ -438,9 +437,6 @@ 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,
@@ -449,7 +445,6 @@ 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,
}); });

View File

@@ -20,13 +20,8 @@ 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, createAutoModeOptions } from '../../lib/sdk-options.js'; import { validateWorkingDirectory } from '../../lib/sdk-options.js';
import { import { getPromptCustomization, getProviderByModelId } from '../../lib/settings-helpers.js';
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';
@@ -239,45 +234,6 @@ 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,
@@ -298,15 +254,6 @@ 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),
@@ -755,8 +702,6 @@ export class AutoModeServiceFacade {
} }
} }
const runningEntryForVerify = this.concurrencyManager.getRunningFeature(featureId);
if (runningEntryForVerify?.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature?.title, featureName: feature?.title,
@@ -767,7 +712,6 @@ export class AutoModeServiceFacade {
: `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`, : `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`,
projectPath: this.projectPath, projectPath: this.projectPath,
}); });
}
return allPassed; return allPassed;
} }
@@ -817,8 +761,6 @@ 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);
const runningEntryForCommit = this.concurrencyManager.getRunningFeature(featureId);
if (runningEntryForCommit?.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature?.title, featureName: feature?.title,
@@ -827,7 +769,6 @@ export class AutoModeServiceFacade {
message: `Changes committed: ${hash.trim().substring(0, 8)}`, message: `Changes committed: ${hash.trim().substring(0, 8)}`,
projectPath: this.projectPath, projectPath: this.projectPath,
}); });
}
return hash.trim(); return hash.trim();
} catch (error) { } catch (error) {

View File

@@ -1,426 +0,0 @@
/**
* 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.`
);
}
}

View File

@@ -19,10 +19,6 @@ 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.
@@ -92,8 +88,6 @@ 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;
@@ -108,8 +102,6 @@ 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
@@ -132,32 +124,6 @@ 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
@@ -287,12 +253,6 @@ 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) {
@@ -331,12 +291,6 @@ 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}`
@@ -706,7 +660,6 @@ 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,
@@ -716,7 +669,6 @@ 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
@@ -740,24 +692,18 @@ 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: serverInfo.port, // Use the detected port (may differ from allocated port if detectUrlFromOutput updated it) port,
exitCode, exitCode,
error: errorMessage, error: errorMessage,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
} }
this.allocatedPorts.delete(serverInfo.allocatedPort); this.allocatedPorts.delete(port);
this.runningServers.delete(worktreePath); this.runningServers.delete(worktreePath);
}; };
@@ -803,43 +749,6 @@ 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: {
@@ -885,12 +794,6 @@ 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 = '';
@@ -909,10 +812,8 @@ class DevServerService {
server.process.kill('SIGTERM'); server.process.kill('SIGTERM');
} }
// Free the originally-reserved port slot (allocatedPort is immutable and always // Free the port
// matches what was added to allocatedPorts in startDevServer; server.port may this.allocatedPorts.delete(server.port);
// 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 {
@@ -926,7 +827,6 @@ 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;
@@ -936,37 +836,14 @@ 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 {
@@ -976,33 +853,17 @@ 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 {
const server = this.runningServers.get(worktreePath); return this.runningServers.has(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 {
const server = this.runningServers.get(worktreePath); return 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;
} }
/** /**
@@ -1030,15 +891,6 @@ 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: {

View File

@@ -170,15 +170,13 @@ 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: isErrorTrigger ? payload.error || payload.message : undefined, error: payload.error || payload.message,
errorType: isErrorTrigger ? payload.errorType : undefined, errorType: payload.errorType,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
eventType: trigger, eventType: trigger,
}; };

View File

@@ -441,7 +441,6 @@ 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)`;
if (isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature.title, featureName: feature.title,
@@ -452,12 +451,10 @@ Please continue from where you left off and complete all remaining tasks. Use th
model: tempRunningFeature.model, model: tempRunningFeature.model,
provider: tempRunningFeature.provider, 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');
if (isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature?.title, featureName: feature?.title,
@@ -466,7 +463,6 @@ Please continue from where you left off and complete all remaining tasks. Use th
message: 'Feature stopped by user', message: 'Feature stopped by user',
projectPath, 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');

View File

@@ -1,103 +0,0 @@
/**
* 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 };
}

View File

@@ -226,8 +226,6 @@ 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);
const runningEntryForStep = this.concurrencyManager.getRunningFeature(featureId);
if (runningEntryForStep?.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature.title, featureName: feature.title,
@@ -236,7 +234,6 @@ export class PipelineOrchestrator {
message: 'Pipeline step no longer exists', message: 'Pipeline step no longer exists',
projectPath, projectPath,
}); });
}
return; return;
} }
@@ -275,8 +272,6 @@ export class PipelineOrchestrator {
); );
if (!pipelineService.isPipelineStatus(nextStatus)) { if (!pipelineService.isPipelineStatus(nextStatus)) {
await this.updateFeatureStatusFn(projectPath, featureId, nextStatus); await this.updateFeatureStatusFn(projectPath, featureId, nextStatus);
const runningEntryForExcluded = this.concurrencyManager.getRunningFeature(featureId);
if (runningEntryForExcluded?.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature.title, featureName: feature.title,
@@ -285,7 +280,6 @@ export class PipelineOrchestrator {
message: 'Pipeline completed (remaining steps excluded)', message: 'Pipeline completed (remaining steps excluded)',
projectPath, projectPath,
}); });
}
return; return;
} }
const nextStepId = pipelineService.getStepIdFromStatus(nextStatus); const nextStepId = pipelineService.getStepIdFromStatus(nextStatus);
@@ -300,8 +294,6 @@ 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);
const runningEntryForAllExcluded = this.concurrencyManager.getRunningFeature(featureId);
if (runningEntryForAllExcluded?.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature.title, featureName: feature.title,
@@ -310,7 +302,6 @@ export class PipelineOrchestrator {
message: 'Pipeline completed (all steps excluded)', message: 'Pipeline completed (all steps excluded)',
projectPath, projectPath,
}); });
}
return; return;
} }
@@ -379,7 +370,6 @@ 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}`);
if (runningEntry.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature.title, featureName: feature.title,
@@ -388,11 +378,9 @@ export class PipelineOrchestrator {
message: 'Pipeline resumed successfully', message: 'Pipeline resumed successfully',
projectPath, projectPath,
}); });
}
} catch (error) { } catch (error) {
const errorInfo = classifyError(error); const errorInfo = classifyError(error);
if (errorInfo.isAbort) { if (errorInfo.isAbort) {
if (runningEntry.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature.title, featureName: feature.title,
@@ -401,7 +389,6 @@ export class PipelineOrchestrator {
message: 'Pipeline stopped by user', message: 'Pipeline stopped by user',
projectPath, 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');
@@ -550,8 +537,6 @@ export class PipelineOrchestrator {
} }
logger.info(`Auto-merge successful for feature ${featureId}`); logger.info(`Auto-merge successful for feature ${featureId}`);
const runningEntryForMerge = this.concurrencyManager.getRunningFeature(featureId);
if (runningEntryForMerge?.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature.title, featureName: feature.title,
@@ -560,7 +545,6 @@ export class PipelineOrchestrator {
message: 'Pipeline completed and merged', message: 'Pipeline completed and merged',
projectPath, 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);

View File

@@ -1,431 +0,0 @@
/**
* 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;
}

View File

@@ -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 1000', async () => { it('should use maxTurns default of 100', 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: 1000, maxTurns: 100,
}), }),
}); });
}); });

View File

@@ -1,580 +0,0 @@
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');
});
});
});

View File

@@ -175,10 +175,7 @@ describe('execution-service.ts', () => {
} as unknown as TypedEventBus; } as unknown as TypedEventBus;
mockConcurrencyManager = { mockConcurrencyManager = {
acquire: vi.fn().mockImplementation(({ featureId, isAutoMode }) => ({ acquire: vi.fn().mockImplementation(({ featureId }) => createRunningFeature(featureId)),
...createRunningFeature(featureId),
isAutoMode: isAutoMode ?? false,
})),
release: vi.fn(), release: vi.fn(),
getRunningFeature: vi.fn(), getRunningFeature: vi.fn(),
isRunning: vi.fn(), isRunning: vi.fn(),
@@ -553,8 +550,8 @@ describe('execution-service.ts', () => {
expect(mockRunAgentFn).not.toHaveBeenCalled(); expect(mockRunAgentFn).not.toHaveBeenCalled();
}); });
it('emits feature_complete event on success when isAutoMode is true', async () => { it('emits feature_complete event on success', async () => {
await service.executeFeature('/test/project', 'feature-1', false, true); await service.executeFeature('/test/project', 'feature-1');
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
'auto_mode_feature_complete', 'auto_mode_feature_complete',
@@ -564,15 +561,6 @@ 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', () => {
@@ -1122,7 +1110,7 @@ describe('execution-service.ts', () => {
); );
}); });
it('handles abort signal without error event (emits feature_complete when isAutoMode=true)', async () => { it('handles abort signal without error event', 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);
@@ -1148,7 +1136,7 @@ describe('execution-service.ts', () => {
mockLoadContextFilesFn mockLoadContextFilesFn
); );
await svc.executeFeature('/test/project', 'feature-1', false, true); await svc.executeFeature('/test/project', 'feature-1');
// Should emit feature_complete with stopped by user // Should emit feature_complete with stopped by user
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
@@ -1167,47 +1155,6 @@ 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);
@@ -1392,8 +1339,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 (isAutoMode=true so event is emitted) // Should not throw
await service.executeFeature('/test/project', 'feature-1', false, true); await service.executeFeature('/test/project', 'feature-1');
// Feature should still complete successfully // Feature should still complete successfully
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(

View File

@@ -170,16 +170,14 @@ describe('PipelineOrchestrator', () => {
} as unknown as WorktreeResolver; } as unknown as WorktreeResolver;
mockConcurrencyManager = { mockConcurrencyManager = {
acquire: vi.fn().mockImplementation(({ featureId, isAutoMode }) => ({ acquire: vi.fn().mockReturnValue({
featureId, featureId: 'feature-1',
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;
@@ -543,18 +541,8 @@ describe('PipelineOrchestrator', () => {
); );
}); });
it('should emit auto_mode_feature_complete on success when isAutoMode is true', async () => { it('should emit auto_mode_feature_complete on success', 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);
@@ -565,19 +553,6 @@ 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,
@@ -648,24 +623,13 @@ describe('PipelineOrchestrator', () => {
expect(mockExecuteFeatureFn).toHaveBeenCalled(); expect(mockExecuteFeatureFn).toHaveBeenCalled();
}); });
it('should complete feature when step no longer exists and emit event when isAutoMode=true', async () => { it('should complete feature when step no longer exists', 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(
@@ -678,28 +642,6 @@ 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', () => {
@@ -724,7 +666,7 @@ describe('PipelineOrchestrator', () => {
expect(mockRunAgentFn).toHaveBeenCalled(); expect(mockRunAgentFn).toHaveBeenCalled();
}); });
it('should complete feature when all remaining steps excluded and emit event when isAutoMode=true', async () => { it('should complete feature when all remaining steps excluded', async () => {
const featureWithAllExcluded: Feature = { const featureWithAllExcluded: Feature = {
...testFeature, ...testFeature,
excludedPipelineSteps: ['step-1', 'step-2'], excludedPipelineSteps: ['step-1', 'step-2'],
@@ -732,16 +674,6 @@ 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',
@@ -1101,7 +1033,7 @@ describe('PipelineOrchestrator', () => {
); );
}); });
it('handles all steps excluded during resume and emits event when isAutoMode=true', async () => { it('handles all steps excluded during resume', async () => {
const featureWithAllExcluded: Feature = { const featureWithAllExcluded: Feature = {
...testFeature, ...testFeature,
excludedPipelineSteps: ['step-1', 'step-2'], excludedPipelineSteps: ['step-1', 'step-2'],
@@ -1109,16 +1041,6 @@ 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',

View File

@@ -117,8 +117,6 @@ 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',

View File

@@ -1,28 +1,9 @@
# 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;
} }

View File

@@ -21,7 +21,6 @@ import {
Maximize2, Maximize2,
Check, Check,
Undo2, Undo2,
RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import { import {
Dialog, Dialog,
@@ -37,7 +36,8 @@ 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 { cn, modelSupportsThinking, generateUUID } from '@/lib/utils'; import { ScrollArea } from '@/components/ui/scroll-area';
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,8 +46,7 @@ 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 { resolveModelString } from '@automaker/model-resolver'; import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults';
// ============================================ // ============================================
// Types // Types
@@ -76,7 +75,7 @@ interface PRCommentResolutionDialogProps {
/** Generate a feature ID */ /** Generate a feature ID */
function generateFeatureId(): string { function generateFeatureId(): string {
return generateUUID(); return `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`;
} }
/** Format a date string for display */ /** Format a date string for display */
@@ -248,22 +247,39 @@ function CommentRow({
return ( return (
<div <div
className={cn( className={cn(
'flex items-start gap-3 p-3 rounded-lg border border-border transition-colors', 'flex items-start gap-3 p-3 rounded-lg border border-border transition-colors cursor-pointer',
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={needsExpansion ? () => setIsExpanded((prev) => !prev) : undefined} onClick={onToggle}
> >
<Checkbox <Checkbox
checked={isSelected} checked={isSelected}
onCheckedChange={() => onToggle()} onCheckedChange={() => onToggle()}
className="mt-0.5 shrink-0" className="mt-0.5"
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 ? (
@@ -287,12 +303,6 @@ 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
@@ -336,47 +346,27 @@ function CommentRow({
</button> </button>
)} )}
<div className="ml-auto shrink-0 flex items-center gap-1">
{/* Disclosure triangle - toggles expand/collapse */}
{needsExpansion ? (
<button
type="button"
onClick={handleExpandToggle}
className="text-muted-foreground hover:text-foreground transition-colors p-0.5 rounded hover:bg-muted"
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="w-4 h-4" />
)}
{/* Expand detail button */} {/* Expand detail button */}
<button <button
type="button" type="button"
onClick={handleExpandDetail} onClick={handleExpandDetail}
className="text-muted-foreground hover:text-foreground transition-colors p-0.5 rounded hover:bg-muted" className="ml-auto shrink-0 text-muted-foreground hover:text-foreground transition-colors p-0.5 rounded hover:bg-muted"
title="View full comment details" title="View full comment details"
> >
<Maximize2 className="h-3.5 w-3.5" /> <Maximize2 className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
</div> </div>
</div>
{/* Comment body - collapsible, rendered as markdown */} {/* Comment body - collapsible, rendered as markdown */}
{isExpanded ? ( {isExpanded ? (
<div onClick={(e) => e.stopPropagation()}> <div className="pl-5" 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="line-clamp-2"> <div className="pl-5 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>
@@ -384,7 +374,7 @@ function CommentRow({
)} )}
{/* Date row */} {/* Date row */}
<div className="flex items-center mt-1"> <div className="flex items-center mt-1 pl-5">
<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>
@@ -423,7 +413,7 @@ function CommentDetailDialog({ comment, open, onOpenChange }: CommentDetailDialo
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex-1 min-h-0 overflow-y-auto -mx-6 px-6"> <ScrollArea className="flex-1 min-h-0 h-full -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">
@@ -449,11 +439,6 @@ 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
@@ -510,7 +495,7 @@ function CommentDetailDialog({ comment, open, onOpenChange }: CommentDetailDialo
</div> </div>
)} )}
</div> </div>
</div> </ScrollArea>
<DialogFooter className="mt-4"> <DialogFooter className="mt-4">
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
@@ -580,15 +565,22 @@ 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 =
@@ -610,23 +602,10 @@ 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
@@ -656,8 +635,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 && comments.every((c) => selectedIds.has(c.id)); const allSelected = comments.length > 0 && selectedIds.size === comments.length;
const someSelected = selectedIds.size > 0 && !allSelected; const someSelected = selectedIds.size > 0 && selectedIds.size < comments.length;
const noneSelected = selectedIds.size === 0; const noneSelected = selectedIds.size === 0;
// ============================================ // ============================================
@@ -679,24 +658,7 @@ 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;
const threadId = comment.threadId; resolveThread.mutate({ threadId: comment.threadId, resolve });
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]
); );
@@ -741,7 +703,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 = resolveModelString(modelEntry.model); const selectedModel = modelEntry.model;
const normalizedThinking = modelSupportsThinking(selectedModel) const normalizedThinking = modelSupportsThinking(selectedModel)
? modelEntry.thinkingLevel || 'none' ? modelEntry.thinkingLevel || 'none'
: 'none'; : 'none';
@@ -848,7 +810,6 @@ export function PRCommentResolutionDialog({
setShowResolved(false); setShowResolved(false);
setCreationErrors([]); setCreationErrors([]);
setDetailComment(null); setDetailComment(null);
setResolvingThreads(new Set());
setModelEntry(effectiveDefaultFeatureModel); setModelEntry(effectiveDefaultFeatureModel);
} }
onOpenChange(newOpen); onOpenChange(newOpen);
@@ -864,22 +825,10 @@ 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>
<div className="flex items-center justify-between pr-10">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-blue-500" /> <MessageSquare className="h-5 w-5 text-blue-500" />
Manage PR Review Comments Manage PR Review Comments
</DialogTitle> </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>
@@ -914,7 +863,7 @@ export function PRCommentResolutionDialog({
{!loading && !error && allComments.length > 0 && ( {!loading && !error && allComments.length > 0 && (
<> <>
{/* Controls Bar */} {/* Controls Bar */}
<div className="flex flex-wrap items-center justify-between gap-2 px-1"> <div className="flex items-center justify-between gap-4 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
@@ -935,7 +884,7 @@ export function PRCommentResolutionDialog({
</Label> </Label>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex items-center gap-3">
{/* Show/Hide Resolved Filter Toggle - always visible */} {/* Show/Hide Resolved Filter Toggle - always visible */}
<Button <Button
variant="ghost" variant="ghost"
@@ -990,7 +939,7 @@ export function PRCommentResolutionDialog({
</Button> </Button>
{/* Mode Toggle */} {/* Mode Toggle */}
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2">
<Label <Label
className={cn( className={cn(
'text-xs cursor-pointer', 'text-xs cursor-pointer',
@@ -1059,9 +1008,7 @@ export function PRCommentResolutionDialog({
onToggle={() => handleToggleComment(comment.id)} onToggle={() => handleToggleComment(comment.id)}
onExpandDetail={() => setDetailComment(comment)} onExpandDetail={() => setDetailComment(comment)}
onResolve={handleResolveComment} onResolve={handleResolveComment}
isResolvingThread={ isResolvingThread={resolveThread.isPending}
!!comment.threadId && resolvingThreads.has(comment.threadId)
}
/> />
))} ))}
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, startTransition } from 'react'; import { useState, useCallback, useEffect } 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,17 +112,9 @@ 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
} }
// Wrap in startTransition to let React batch the project switch and
// navigation into a single low-priority update. Without this, the two
// synchronous calls fire separate renders where currentProject points
// to the new project but per-project state (worktrees, features) is
// still stale, causing a cascade of effects and store mutations that
// can trigger React error #185 (maximum update depth exceeded).
startTransition(() => {
setCurrentProject(project); setCurrentProject(project);
// Navigate to board view when switching projects // Navigate to board view when switching projects
navigate({ to: '/board' }); navigate({ to: '/board' });
});
}, },
[setCurrentProject, navigate] [setCurrentProject, navigate]
); );

View File

@@ -108,9 +108,7 @@ export function useProjectPicker({
setIsProjectPickerOpen(false); setIsProjectPickerOpen(false);
} else if (event.key === 'Enter') { } else if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
selectHighlightedProject().catch(() => { selectHighlightedProject();
/* 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));

View File

@@ -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, pathsEqual } from '@/lib/utils'; import { cn } 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,7 +93,6 @@ 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>;
} }
@@ -102,7 +101,6 @@ export function SessionManager({
currentSessionId, currentSessionId,
onSelectSession, onSelectSession,
projectPath, projectPath,
workingDirectory,
isCurrentSessionThinking = false, isCurrentSessionThinking = false,
onQuickCreateRef, onQuickCreateRef,
}: SessionManagerProps) { }: SessionManagerProps) {
@@ -155,7 +153,6 @@ 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)
@@ -180,9 +177,6 @@ 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();
@@ -190,7 +184,7 @@ export function SessionManager({
const sessionName = newSessionName.trim() || generateRandomSessionName(); const sessionName = newSessionName.trim() || generateRandomSessionName();
const result = await api.sessions.create(sessionName, projectPath, effectiveWorkingDirectory); const result = await api.sessions.create(sessionName, projectPath, projectPath);
if (result.success && result.session?.id) { if (result.success && result.session?.id) {
setNewSessionName(''); setNewSessionName('');
@@ -201,19 +195,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 = useCallback(async () => { const handleQuickCreateSession = 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, effectiveWorkingDirectory); const result = await api.sessions.create(sessionName, projectPath, projectPath);
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(() => {
@@ -225,7 +219,7 @@ export function SessionManager({
onQuickCreateRef.current = null; onQuickCreateRef.current = null;
} }
}; };
}, [onQuickCreateRef, handleQuickCreateSession]); }, [onQuickCreateRef, projectPath]);
// Rename session // Rename session
const handleRenameSession = async (sessionId: string) => { const handleRenameSession = async (sessionId: string) => {
@@ -298,16 +292,10 @@ export function SessionManager({
const result = await api.sessions.delete(sessionId); const result = await api.sessions.delete(sessionId);
if (result.success) { if (result.success) {
const refetchResult = await invalidateSessions(); await invalidateSessions();
if (currentSessionId === sessionId) { if (currentSessionId === sessionId) {
// Switch to another session using fresh data, excluding the deleted session // Switch to another session or create a new one
// Filter to sessions within the same worktree to avoid jumping to a different worktree const activeSessionsList = sessions.filter((s) => !s.isArchived);
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);
} }
@@ -330,16 +318,8 @@ export function SessionManager({
setIsDeleteAllArchivedDialogOpen(false); setIsDeleteAllArchivedDialogOpen(false);
}; };
// Filter sessions by current working directory (worktree scoping) const activeSessions = sessions.filter((s) => !s.isArchived);
const scopedSessions = sessions.filter((s) => { const archivedSessions = sessions.filter((s) => s.isArchived);
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 (

View File

@@ -1,205 +0,0 @@
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 &amp; 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;
}
}

View File

@@ -20,13 +20,9 @@ import { AgentInputArea } from './agent-view/input-area';
const LG_BREAKPOINT = 1024; const LG_BREAKPOINT = 1024;
export function AgentView() { export function AgentView() {
const { currentProject, getCurrentWorktree } = useAppStore(); const { currentProject } = 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);
@@ -56,10 +52,9 @@ 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 - scoped to current worktree // Session management hook
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)
@@ -76,7 +71,7 @@ export function AgentView() {
clearServerQueue, clearServerQueue,
} = useElectronAgent({ } = useElectronAgent({
sessionId: currentSessionId || '', sessionId: currentSessionId || '',
workingDirectory: effectiveWorkingDirectory, workingDirectory: currentProject?.path,
model: modelSelection.model, model: modelSelection.model,
thinkingLevel: modelSelection.thinkingLevel, thinkingLevel: modelSelection.thinkingLevel,
onToolUse: (toolName) => { onToolUse: (toolName) => {
@@ -234,7 +229,6 @@ 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}
/> />
@@ -254,7 +248,6 @@ export function AgentView() {
showSessionManager={showSessionManager} showSessionManager={showSessionManager}
onToggleSessionManager={() => setShowSessionManager(!showSessionManager)} onToggleSessionManager={() => setShowSessionManager(!showSessionManager)}
onClearChat={handleClearChat} onClearChat={handleClearChat}
worktreeBranch={currentWorktree?.branch}
/> />
{/* Messages */} {/* Messages */}

View File

@@ -1,4 +1,4 @@
import { Bot, PanelLeftClose, PanelLeft, Wrench, Trash2, GitBranch } from 'lucide-react'; import { Bot, PanelLeftClose, PanelLeft, Wrench, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
interface AgentHeaderProps { interface AgentHeaderProps {
@@ -11,7 +11,6 @@ interface AgentHeaderProps {
showSessionManager: boolean; showSessionManager: boolean;
onToggleSessionManager: () => void; onToggleSessionManager: () => void;
onClearChat: () => void; onClearChat: () => void;
worktreeBranch?: string;
} }
export function AgentHeader({ export function AgentHeader({
@@ -24,7 +23,6 @@ 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">
@@ -34,18 +32,10 @@ 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>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
<span>
{projectName} {projectName}
{currentSessionId && !isConnected && ' - Connecting...'} {currentSessionId && !isConnected && ' - Connecting...'}
</span> </p>
{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>

View File

@@ -6,7 +6,6 @@ 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 {
@@ -14,56 +13,49 @@ interface UseAgentSessionResult {
handleSelectSession: (sessionId: string | null) => void; handleSelectSession: (sessionId: string | null) => void;
} }
export function useAgentSession({ export function useAgentSession({ projectPath }: UseAgentSessionOptions): UseAgentSessionResult {
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 worktree/project // Persist the selection for this project
if (persistenceKey) { if (projectPath) {
setLastSelectedSession(persistenceKey, sessionId); setLastSelectedSession(projectPath, sessionId);
} }
}, },
[persistenceKey, setLastSelectedSession] [projectPath, setLastSelectedSession]
); );
// Restore last selected session when switching to Agent view or when worktree changes // Restore last selected session when switching to Agent view or when project changes
useEffect(() => { useEffect(() => {
if (!persistenceKey) { if (!projectPath) {
// No project, reset // No project, reset
setCurrentSessionId(null); setCurrentSessionId(null);
initialSessionLoadedRef.current = false; initialSessionLoadedRef.current = false;
return; return;
} }
// Only restore once per persistence key // Only restore once per project
if (initialSessionLoadedRef.current) return; if (initialSessionLoadedRef.current) return;
initialSessionLoadedRef.current = true; initialSessionLoadedRef.current = true;
const lastSessionId = getLastSelectedSession(persistenceKey); const lastSessionId = getLastSelectedSession(projectPath);
if (lastSessionId) { if (lastSessionId) {
logger.info('Restoring last selected session:', lastSessionId); logger.info('Restoring last selected session:', lastSessionId);
setCurrentSessionId(lastSessionId); setCurrentSessionId(lastSessionId);
} }
}, [persistenceKey, getLastSelectedSession]); }, [projectPath, getLastSelectedSession]);
// Reset when worktree/project changes - clear current session and allow restore // Reset initialSessionLoadedRef when project changes
useEffect(() => { useEffect(() => {
initialSessionLoadedRef.current = false; initialSessionLoadedRef.current = false;
setCurrentSessionId(null); }, [projectPath]);
}, [persistenceKey]);
return { return {
currentSessionId, currentSessionId,

View File

@@ -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'; } from '@/components/dialogs/pr-comment-resolution-dialog';
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';
@@ -459,80 +459,35 @@ export function BoardView() {
prevWorktreePathRef.current = currentWorktreePath; prevWorktreePathRef.current = currentWorktreePath;
}, [currentWorktreePath, currentProject?.path, queryClient]); }, [currentWorktreePath, currentProject?.path, queryClient]);
// Select worktrees for the current project directly from the store. const worktreesByProject = useAppStore((s) => s.worktreesByProject);
// Using a project-scoped selector prevents re-renders when OTHER projects' const worktrees = useMemo(
// worktrees change (the old selector subscribed to the entire worktreesByProject () =>
// object, causing unnecessary re-renders that cascaded into selectedWorktree → currentProject
// useAutoMode → refreshStatus → setAutoModeRunning → store update → re-render loop ? (worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES)
// that could trigger React error #185 on initial project open).
const currentProjectPath = currentProject?.path;
const worktrees = useAppStore(
useCallback(
(s) =>
currentProjectPath
? (s.worktreesByProject[currentProjectPath] ?? EMPTY_WORKTREES)
: EMPTY_WORKTREES, : EMPTY_WORKTREES,
[currentProjectPath] [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
const result: WorktreeInfo = { return {
...found, ...found,
isCurrent: isCurrent:
found.isCurrent ?? found.isCurrent ??
(usedFallback (currentWorktreePath !== null ? pathsEqual(found.path, currentWorktreePath) : found.isMain),
? 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
@@ -1076,7 +1031,7 @@ export function BoardView() {
images: [], images: [],
imagePaths: [], imagePaths: [],
skipTests: defaultSkipTests, skipTests: defaultSkipTests,
model: resolveModelString('opus'), model: 'opus' as const,
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
@@ -1990,16 +1945,6 @@ export function BoardView() {
} }
defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)} defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)}
onDeleted={(deletedWorktree, _deletedBranch) => { onDeleted={(deletedWorktree, _deletedBranch) => {
// If the deleted worktree was currently selected, immediately reset to main
// to prevent the UI from trying to render a non-existent worktree view
if (
currentWorktreePath !== null &&
pathsEqual(currentWorktreePath, deletedWorktree.path)
) {
const mainBranch = worktrees.find((w) => w.isMain)?.branch || 'main';
setCurrentWorktree(currentProject.path, null, mainBranch);
}
// Reset features that were assigned to the deleted worktree (by branch) // Reset features that were assigned to the deleted worktree (by branch)
hookFeatures.forEach((feature) => { hookFeatures.forEach((feature) => {
// Match by branch name since worktreePath is no longer stored // Match by branch name since worktreePath is no longer stored

View File

@@ -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

View File

@@ -299,11 +299,7 @@ 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;
// Only strip the remote prefix if the resolved ref differs from the original const branchNameForApi = resolvedRef.includes('/')
// (indicating it was resolved from a full ref like "origin/main").
// This preserves local branch names that contain slashes (e.g. "release/1.0").
const branchNameForApi =
resolvedRef !== baseBranch && resolvedRef.includes('/')
? resolvedRef.substring(resolvedRef.indexOf('/') + 1) ? resolvedRef.substring(resolvedRef.indexOf('/') + 1)
: resolvedRef; : resolvedRef;
const result = await api.worktree.generatePRDescription(worktree.path, branchNameForApi); const result = await api.worktree.generatePRDescription(worktree.path, branchNameForApi);
@@ -348,11 +344,9 @@ 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;
// Only strip the remote prefix if the resolved ref differs from the original // Strip the remote prefix from the resolved ref for the API call
// (indicating it was resolved from a full ref like "origin/main"). // (e.g. "origin/main" → "main") since the backend expects the branch name only
// This preserves local branch names that contain slashes (e.g. "release/1.0"). const baseBranchForApi = resolvedBaseBranch.includes('/')
const baseBranchForApi =
resolvedBaseBranch !== baseBranch && resolvedBaseBranch.includes('/')
? resolvedBaseBranch.substring(resolvedBaseBranch.indexOf('/') + 1) ? resolvedBaseBranch.substring(resolvedBaseBranch.indexOf('/') + 1)
: resolvedBaseBranch; : resolvedBaseBranch;

View File

@@ -284,33 +284,11 @@ export function CreateWorktreeDialog({
if (result.success && result.worktree) { if (result.success && result.worktree) {
const baseDesc = effectiveBaseBranch ? ` from ${effectiveBaseBranch}` : ''; const baseDesc = effectiveBaseBranch ? ` from ${effectiveBaseBranch}` : '';
const commitInfo = result.worktree.baseCommitHash
? ` (${result.worktree.baseCommitHash})`
: '';
// 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}"`, { toast.success(`Worktree created for branch "${result.worktree.branch}"`, {
description: result.worktree.isNew description: result.worktree.isNew
? `New branch created${baseDesc}${commitInfo}` ? `New branch created${baseDesc}`
: `Using existing branch${commitInfo}`, : 'Using existing branch',
}); });
}
onCreated({ path: result.worktree.path, branch: result.worktree.branch }); onCreated({ path: result.worktree.path, branch: result.worktree.branch });
onOpenChange(false); onOpenChange(false);
setBranchName(''); setBranchName('');
@@ -436,12 +414,6 @@ 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>
@@ -482,7 +454,7 @@ export function CreateWorktreeDialog({
{isLoading ? ( {isLoading ? (
<> <>
<Spinner size="sm" className="mr-2" /> <Spinner size="sm" className="mr-2" />
{baseBranch.trim() ? 'Syncing & Creating...' : 'Creating...'} {isRemoteBaseBranch ? 'Fetching & Creating...' : 'Creating...'}
</> </>
) : ( ) : (
<> <>

View File

@@ -217,15 +217,9 @@ export function useBoardActions({
const needsTitleGeneration = const needsTitleGeneration =
!titleWasGenerated && !featureData.title.trim() && featureData.description.trim(); !titleWasGenerated && !featureData.title.trim() && featureData.description.trim();
const { const initialStatus = featureData.initialStatus || 'backlog';
initialStatus: requestedStatus,
workMode: _workMode,
childDependencies,
...restFeatureData
} = featureData;
const initialStatus = requestedStatus || 'backlog';
const newFeatureData = { const newFeatureData = {
...restFeatureData, ...featureData,
title: titleWasGenerated ? titleForBranch : featureData.title, title: titleWasGenerated ? titleForBranch : featureData.title,
titleGenerating: needsTitleGeneration, titleGenerating: needsTitleGeneration,
status: initialStatus, status: initialStatus,
@@ -245,8 +239,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 (childDependencies && childDependencies.length > 0) { if (featureData.childDependencies && featureData.childDependencies.length > 0) {
for (const childId of childDependencies) { for (const childId of featureData.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 || [];
@@ -1167,15 +1161,10 @@ 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,
@@ -1223,8 +1212,6 @@ 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,

View File

@@ -1,4 +1,3 @@
import { useMemo } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
DropdownMenu, DropdownMenu,
@@ -252,28 +251,6 @@ 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>
@@ -394,59 +371,39 @@ export function WorktreeActionsDropdown({
? 'Dev Server Starting...' ? 'Dev Server Starting...'
: `Dev Server Running (:${devServerInfo?.port})`} : `Dev Server Running (:${devServerInfo?.port})`}
</DropdownMenuLabel> </DropdownMenuLabel>
{devServerInfo != null && {devServerInfo?.urlDetected !== false && (
devServerInfo.port != null &&
devServerInfo.urlDetected !== false && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onOpenDevServerUrl(worktree)} onClick={() => onOpenDevServerUrl(worktree)}
className="text-xs" className="text-xs"
aria-label={`Open dev server on port ${devServerInfo.port} in browser`} aria-label={`Open dev server on port ${devServerInfo?.port} in browser`}
> >
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" /> <Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
Open in Browser Open in Browser
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{/* Stop Dev Server - split button: click main area to stop, chevron for view logs */} <DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
<DropdownMenuSub> <ScrollText className="w-3.5 h-3.5 mr-2" />
<div className="flex items-center"> View Logs
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => onStopDevServer(worktree)} onClick={() => onStopDevServer(worktree)}
className="text-xs flex-1 pr-0 rounded-r-none text-destructive focus:text-destructive" className="text-xs text-destructive focus:text-destructive"
> >
<Square className="w-3.5 h-3.5 mr-2" /> <Square className="w-3.5 h-3.5 mr-2" />
Stop Dev Server Stop Dev Server
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
</div>
<DropdownMenuSubContent>{viewDevServerLogsItem}</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>
) : ( ) : (
<> <>
{/* Start Dev Server - split button: click main area to start, chevron for view logs */}
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem <DropdownMenuItem
onClick={() => onStartDevServer(worktree)} onClick={() => onStartDevServer(worktree)}
disabled={isStartingDevServer} disabled={isStartingDevServer}
className="text-xs flex-1 pr-0 rounded-r-none" className="text-xs"
> >
<Play <Play className={cn('w-3.5 h-3.5 mr-2', isStartingDevServer && 'animate-pulse')} />
className={cn('w-3.5 h-3.5 mr-2', isStartingDevServer && 'animate-pulse')}
/>
{isStartingDevServer ? 'Starting...' : 'Start Dev Server'} {isStartingDevServer ? 'Starting...' : 'Start Dev Server'}
</DropdownMenuItem> </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 />
</> </>
)} )}
@@ -635,7 +592,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 or no handler */} {/* Re-run Init Script - always shown for non-main worktrees, disabled when no init script configured */}
{!worktree.isMain && ( {!worktree.isMain && (
<> <>
<DropdownMenuItem <DropdownMenuItem
@@ -656,7 +613,6 @@ 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>
@@ -669,11 +625,7 @@ export function WorktreeActionsDropdown({
)} )}
{/* Divider before Edit Commands & Scripts */} {/* Divider before Edit Commands & Scripts */}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem onClick={() => onEditScripts?.()} className="text-xs">
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>
@@ -987,11 +939,11 @@ 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 */}
{/* View Changes split button - show submenu only when there are non-duplicate sub-actions */} {(worktree.hasChanges || onViewStashes) && (
{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"
@@ -999,11 +951,20 @@ export function WorktreeActionsDropdown({
<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>
{onStashChanges && ( {worktree.hasChanges && onStashChanges && (
<TooltipWrapper <TooltipWrapper
showTooltip={!isGitOpsAvailable} showTooltip={!isGitOpsAvailable}
tooltipContent={gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}
@@ -1032,17 +993,7 @@ 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}
@@ -1081,7 +1032,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 */}
{prInfo && worktree.pr && ( {showPRInfo && 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 */}
@@ -1093,16 +1044,7 @@ 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 <span className="ml-auto mr-1 text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded uppercase">
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>
@@ -1111,14 +1053,40 @@ export function WorktreeActionsDropdown({
</div> </div>
<DropdownMenuSubContent> <DropdownMenuSubContent>
<DropdownMenuItem <DropdownMenuItem
onClick={() => onAddressPRComments(worktree, prInfo)} onClick={() => {
// 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={() => onAutoAddressPRComments(worktree, prInfo)} onClick={() => {
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" />

View File

@@ -144,7 +144,7 @@ export function WorktreeDropdownItem({
</span> </span>
)} )}
{/* Dev server indicator - hidden when URL detection explicitly failed */} {/* Dev server indicator - only shown when port is confirmed detected */}
{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"

View File

@@ -7,11 +7,6 @@ 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;
} }
@@ -35,26 +30,6 @@ 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());
@@ -62,120 +37,6 @@ 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();
@@ -195,132 +56,19 @@ 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();
@@ -330,24 +78,10 @@ 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 the server isn't in our state yet (e.g., race condition on first load if (!existing) return prev;
// 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);
@@ -365,15 +99,25 @@ 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);
showUrlDetectedToast(url, port); 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,
});
} }
} }
} 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);
@@ -399,22 +143,10 @@ 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(
@@ -454,26 +186,18 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
}); });
return next; return next;
}); });
// Start port detection timeout toast.success('Dev server started, detecting port...');
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, startPortDetectionTimer] [isStartingDevServer, projectPath]
); );
const handleStopDevServer = useCallback( const handleStopDevServer = useCallback(
@@ -490,8 +214,6 @@ 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);
@@ -508,7 +230,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
toast.error('Failed to stop dev server'); toast.error('Failed to stop dev server');
} }
}, },
[projectPath, clearPortDetectionTimer] [projectPath]
); );
const handleOpenDevServerUrl = useCallback( const handleOpenDevServerUrl = useCallback(

View File

@@ -28,22 +28,11 @@ 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) {
// Compare serialized worktrees to skip no-op store updates
const json = JSON.stringify(worktrees);
if (json !== prevWorktreesJsonRef.current) {
prevWorktreesJsonRef.current = json;
setWorktreesInStore(projectPath, worktrees); setWorktreesInStore(projectPath, worktrees);
} }
}
}, [worktrees, projectPath, setWorktreesInStore]); }, [worktrees, projectPath, setWorktreesInStore]);
// Handle removed worktrees callback when data changes // Handle removed worktrees callback when data changes
@@ -98,17 +87,8 @@ 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. // Invalidate feature queries when switching worktrees to ensure fresh data.
@@ -119,7 +99,7 @@ export function useWorktrees({
queryKey: queryKeys.features.all(projectPath), queryKey: queryKeys.features.all(projectPath),
}); });
}, },
[projectPath, setCurrentWorktree, queryClient, currentWorktreePath] [projectPath, setCurrentWorktree, queryClient]
); );
// fetchWorktrees for backward compatibility - now just triggers a refetch // fetchWorktrees for backward compatibility - now just triggers a refetch
@@ -138,6 +118,7 @@ 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);

View File

@@ -659,18 +659,6 @@ 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);
@@ -949,7 +937,7 @@ export function WorktreePanel({
onResolveConflicts={onResolveConflicts} onResolveConflicts={onResolveConflicts}
onMerge={handleMerge} onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree} onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServerAndShowLogs} onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer} onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl} onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs} onViewDevServerLogs={handleViewDevServerLogs}
@@ -1193,7 +1181,7 @@ export function WorktreePanel({
onResolveConflicts={onResolveConflicts} onResolveConflicts={onResolveConflicts}
onMerge={handleMerge} onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree} onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServerAndShowLogs} onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer} onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl} onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs} onViewDevServerLogs={handleViewDevServerLogs}
@@ -1300,7 +1288,7 @@ export function WorktreePanel({
onResolveConflicts={onResolveConflicts} onResolveConflicts={onResolveConflicts}
onMerge={handleMerge} onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree} onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServerAndShowLogs} onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer} onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl} onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs} onViewDevServerLogs={handleViewDevServerLogs}
@@ -1387,7 +1375,7 @@ export function WorktreePanel({
onResolveConflicts={onResolveConflicts} onResolveConflicts={onResolveConflicts}
onMerge={handleMerge} onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree} onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServerAndShowLogs} onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer} onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl} onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs} onViewDevServerLogs={handleViewDevServerLogs}

View File

@@ -1,4 +1,4 @@
import { X, Circle, MoreHorizontal, Save } from 'lucide-react'; import { X, Circle, MoreHorizontal } 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,12 +14,6 @@ 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 */
@@ -80,9 +74,6 @@ 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;
@@ -137,26 +128,8 @@ export function EditorTabs({
); );
})} })}
{/* Tab actions: save button (mobile) + close-all dropdown */} {/* Tab actions dropdown (close all, etc.) */}
<div className="ml-auto shrink-0 flex items-center px-1 gap-0.5"> <div className="ml-auto shrink-0 flex items-center px-1">
{/* 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

View File

@@ -32,7 +32,6 @@ 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;
@@ -105,21 +104,6 @@ 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,
@@ -133,7 +117,6 @@ 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).
@@ -142,9 +125,7 @@ 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);
@@ -154,36 +135,16 @@ 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 (
<div className="flex flex-col gap-0.5">
<input <input
ref={inputRef} ref={inputRef}
value={value} value={value}
onChange={(e) => { onChange={(e) => setValue(e.target.value)}
setValue(e.target.value);
if (errorMessage) setErrorMessage(null);
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter' && value.trim()) {
handleSubmit(); if (submittedRef.current) return;
submittedRef.current = true;
onSubmit(value.trim());
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
onCancel(); onCancel();
} }
@@ -191,25 +152,80 @@ function InlineInput({
onBlur={() => { onBlur={() => {
// Prevent duplicate submission if onKeyDown already triggered onSubmit // Prevent duplicate submission if onKeyDown already triggered onSubmit
if (submittedRef.current) return; if (submittedRef.current) return;
const trimmed = value.trim(); if (value.trim()) {
if (trimmed && isValidFileName(trimmed)) {
submittedRef.current = true; submittedRef.current = true;
onSubmit(trimmed); onSubmit(value.trim());
} } else {
// If the name is empty or invalid, do NOT call onCancel — keep the onCancel();
// input open so the user can correct the value (mirrors handleSubmit).
// Optionally re-focus so the user can continue editing.
else {
inputRef.current?.focus();
} }
}} }}
placeholder={placeholder} placeholder={placeholder}
className={cn( className="text-sm bg-muted border border-border rounded px-1 py-0.5 w-full outline-none focus:border-primary"
'text-sm bg-muted border rounded px-1 py-0.5 w-full outline-none focus:border-primary',
errorMessage ? 'border-red-500' : 'border-border'
)}
/> />
{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>
); );
} }
@@ -260,11 +276,12 @@ 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;
@@ -392,6 +409,30 @@ 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
@@ -589,21 +630,9 @@ function TreeNode({
{/* Copy To... */} {/* Copy To... */}
{onCopyItem && ( {onCopyItem && (
<DropdownMenuItem <DropdownMenuItem
onClick={async (e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
try { setShowCopyPicker(true);
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"
> >
@@ -615,21 +644,9 @@ function TreeNode({
{/* Move To... */} {/* Move To... */}
{onMoveItem && ( {onMoveItem && (
<DropdownMenuItem <DropdownMenuItem
onClick={async (e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
try { setShowMovePicker(true);
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"
> >
@@ -758,15 +775,8 @@ export function FileTree({
onDragDropMove, onDragDropMove,
effectivePath, effectivePath,
}: FileTreeProps) { }: FileTreeProps) {
const { const { fileTree, showHiddenFiles, setShowHiddenFiles, gitStatusMap, setDragState, gitBranch } =
fileTree, useFileEditorStore();
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);
@@ -781,13 +791,10 @@ export function FileTree({
e.preventDefault(); e.preventDefault();
if (effectivePath) { if (effectivePath) {
e.dataTransfer.dropEffect = 'move'; e.dataTransfer.dropEffect = 'move';
// Skip redundant state update if already targeting the same path setDragState({ draggedPaths: [], dropTargetPath: effectivePath });
if (dragState.dropTargetPath !== effectivePath) {
setDragState({ ...dragState, dropTargetPath: effectivePath });
}
} }
}, },
[effectivePath, dragState, setDragState] [effectivePath, setDragState]
); );
const handleRootDrop = useCallback( const handleRootDrop = useCallback(
@@ -811,12 +818,16 @@ 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="px-2 py-1.5 border-b border-border"> <div className="flex items-center justify-between px-2 py-1.5 border-b border-border">
<div className="flex items-center justify-between"> <div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Explorer Explorer
</span> </span>
{gitBranch && (
<span className="text-[10px] text-primary font-medium px-1 py-0.5 bg-primary/10 rounded">
{gitBranch}
</span>
)}
</div> </div>
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<button <button
@@ -849,17 +860,6 @@ export function FileTree({
</button> </button>
</div> </div>
</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}
</span>
</div>
)}
</div>
{/* Tree content */} {/* Tree content */}
<div <div

View File

@@ -650,12 +650,6 @@ 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}`;
@@ -1034,9 +1028,6 @@ 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 */}
@@ -1329,6 +1320,24 @@ 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}

View File

@@ -18,7 +18,6 @@ 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,
@@ -154,17 +153,11 @@ export function GitHubIssuesView() {
} }
} }
return parts.join('\n'); return parts.filter(Boolean).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);
@@ -185,10 +178,7 @@ 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');
@@ -213,11 +203,6 @@ 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(),
}; };
@@ -255,7 +240,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 parts = [ const description = [
`**From GitHub Issue #${issue.number}**`, `**From GitHub Issue #${issue.number}**`,
'', '',
issue.body || 'No description provided.', issue.body || 'No description provided.',
@@ -264,18 +249,13 @@ export function GitHubIssuesView() {
'', '',
'**AI Validation Analysis:**', '**AI Validation Analysis:**',
validation.reasoning, validation.reasoning,
]; validation.suggestedFix ? `\n**Suggested Approach:**\n${validation.suggestedFix}` : '',
if (validation.suggestedFix) { validation.relatedFiles?.length
parts.push('', `**Suggested Approach:**`, validation.suggestedFix); ? `\n**Related Files:**\n${validation.relatedFiles.map((f) => `- \`${f}\``).join('\n')}`
} : '',
if (validation.relatedFiles?.length) { ]
parts.push( .filter(Boolean)
'', .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()}`,
@@ -285,7 +265,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: resolveModelString('opus'), model: 'opus',
thinkingLevel: 'none' as const, thinkingLevel: 'none' as const,
branchName: currentBranch, branchName: currentBranch,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@@ -472,7 +452,9 @@ 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"
/> />

View File

@@ -78,14 +78,7 @@ 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 <Button variant="ghost" size="sm" onClick={onClose} className="shrink-0 -ml-1">
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>
)} )}
@@ -111,13 +104,7 @@ export function IssueDetailPanel({
if (cached && !isStale) { if (cached && !isStale) {
return ( return (
<> <>
<Button <Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
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>
@@ -136,13 +123,7 @@ export function IssueDetailPanel({
if (cached && isStale) { if (cached && isStale) {
return ( return (
<> <>
<Button <Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
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>
@@ -159,8 +140,6 @@ 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'}
@@ -184,8 +163,6 @@ 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'}
@@ -204,13 +181,7 @@ export function IssueDetailPanel({
Create Feature Create Feature
</Button> </Button>
)} )}
<Button <Button variant="outline" size="sm" onClick={() => onOpenInGitHub(issue.url)}>
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>

View File

@@ -21,12 +21,11 @@ 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, generateUUID } from '@/lib/utils'; import { cn } 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'; import { PRCommentResolutionDialog } from '@/components/dialogs/pr-comment-resolution-dialog';
import { resolveModelString } from '@automaker/model-resolver';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
DropdownMenu, DropdownMenu,
@@ -73,15 +72,15 @@ export function GitHubPRsView() {
return; return;
} }
const featureId = `pr-${pr.number}-${generateUUID()}`; const featureId = `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`;
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: 'backlog', status: 'in_progress',
model: resolveModelString('opus'), model: 'opus',
thinkingLevel: 'none', thinkingLevel: 'none',
planningMode: 'skip', planningMode: 'skip',
...(pr.headRefName ? { branchName: pr.headRefName } : {}), ...(pr.headRefName ? { branchName: pr.headRefName } : {}),
@@ -92,26 +91,11 @@ export function GitHubPRsView() {
// Start the feature immediately after creation // Start the feature immediately after creation
const api = getElectronAPI(); const api = getElectronAPI();
if (api.autoMode?.runFeature) { await api.features?.run(currentProject.path, featureId);
try {
await api.autoMode.runFeature(currentProject.path, featureId);
toast.success('Feature created and started', { toast.success('Feature created and started', {
description: `Addressing review comments on PR #${pr.number}`, 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',
@@ -258,10 +242,7 @@ export function GitHubPRsView() {
</div> </div>
{/* PR Detail Panel */} {/* PR Detail Panel */}
{selectedPR && {selectedPR && (
(() => {
const reviewStatus = getReviewStatus(selectedPR);
return (
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
{/* Detail Header */} {/* Detail Header */}
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30 gap-2"> <div className="flex items-center justify-between p-3 border-b border-border bg-muted/30 gap-2">
@@ -292,11 +273,7 @@ export function GitHubPRsView() {
</div> </div>
<div className={cn('flex items-center gap-2 shrink-0', isMobile && 'gap-1')}> <div className={cn('flex items-center gap-2 shrink-0', isMobile && 'gap-1')}>
{!isMobile && ( {!isMobile && (
<Button <Button variant="outline" size="sm" onClick={() => setCommentDialogPR(selectedPR)}>
variant="outline"
size="sm"
onClick={() => setCommentDialogPR(selectedPR)}
>
<MessageSquare className="h-4 w-4 mr-1" /> <MessageSquare className="h-4 w-4 mr-1" />
Manage Comments Manage Comments
</Button> </Button>
@@ -334,21 +311,17 @@ export function GitHubPRsView() {
: 'bg-green-500/10 text-green-500' : 'bg-green-500/10 text-green-500'
)} )}
> >
{selectedPR.state === 'MERGED' {selectedPR.state === 'MERGED' ? 'Merged' : selectedPR.isDraft ? 'Draft' : 'Open'}
? 'Merged'
: selectedPR.isDraft
? 'Draft'
: 'Open'}
</span> </span>
{reviewStatus && ( {getReviewStatus(selectedPR) && (
<span <span
className={cn( className={cn(
'px-2 py-0.5 rounded-full text-xs font-medium', 'px-2 py-0.5 rounded-full text-xs font-medium',
reviewStatus.bg, getReviewStatus(selectedPR)!.bg,
reviewStatus.color getReviewStatus(selectedPR)!.color
)} )}
> >
{reviewStatus.label} {getReviewStatus(selectedPR)!.label}
</span> </span>
)} )}
<span> <span>
@@ -400,8 +373,7 @@ export function GitHubPRsView() {
<span className="text-sm font-medium">Review Comments</span> <span className="text-sm font-medium">Review Comments</span>
</div> </div>
<p className="text-sm text-muted-foreground mb-3"> <p className="text-sm text-muted-foreground mb-3">
Manage review comments individually or let AI address all feedback Manage review comments individually or let AI address all feedback automatically.
automatically.
</p> </p>
<div className={cn('flex gap-2', isMobile ? 'flex-col' : 'items-center')}> <div className={cn('flex gap-2', isMobile ? 'flex-col' : 'items-center')}>
<Button variant="outline" onClick={() => setCommentDialogPR(selectedPR)}> <Button variant="outline" onClick={() => setCommentDialogPR(selectedPR)}>
@@ -427,8 +399,7 @@ export function GitHubPRsView() {
</div> </div>
</div> </div>
</div> </div>
); )}
})()}
{/* PR Comment Resolution Dialog */} {/* PR Comment Resolution Dialog */}
{commentDialogPR && ( {commentDialogPR && (

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo, useRef, type KeyboardEvent } from 'react'; import { useState, useEffect, useCallback, 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,7 +19,6 @@ 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 */
@@ -92,69 +91,46 @@ 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);
// Track previous project path to detect project switches // Reset local state when project changes
const prevProjectPathRef = useRef(project.path);
// Track whether we've done the initial sync for the current project
const isInitializedRef = useRef(false);
// Sync commands and scripts state when project settings load or project changes
useEffect(() => { useEffect(() => {
const projectChanged = prevProjectPathRef.current !== project.path;
prevProjectPathRef.current = project.path;
// Always clear local state on project change to avoid flashing stale data
if (projectChanged) {
isInitializedRef.current = false;
setDevCommand(''); setDevCommand('');
setOriginalDevCommand(''); setOriginalDevCommand('');
setTestCommand(''); setTestCommand('');
setOriginalTestCommand(''); setOriginalTestCommand('');
setScripts([]); setScripts([]);
setOriginalScripts([]); setOriginalScripts([]);
} }, [project.path]);
// Apply project settings only when they are available // Sync commands state when project settings load
useEffect(() => {
if (projectSettings) { 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 dev = projectSettings.devCommand || '';
const test = projectSettings.testCommand || ''; const test = projectSettings.testCommand || '';
setDevCommand(dev); setDevCommand(dev);
setOriginalDevCommand(dev); setOriginalDevCommand(dev);
setTestCommand(test); setTestCommand(test);
setOriginalTestCommand(test); setOriginalTestCommand(test);
}
}, [projectSettings]);
// Scripts // Sync scripts state when project settings load
useEffect(() => {
if (projectSettings) {
const configured = projectSettings.terminalScripts; const configured = projectSettings.terminalScripts;
const scriptList = const scriptList =
configured && configured.length > 0 configured && configured.length > 0
? configured.map((s) => ({ id: s.id, name: s.name, command: s.command })) ? configured.map((s) => ({ id: s.id, name: s.name, command: s.command }))
: DEFAULT_TERMINAL_SCRIPTS.map((s) => ({ ...s })); : DEFAULT_TERMINAL_SCRIPTS.map((s) => ({ ...s }));
setScripts(scriptList); setScripts(scriptList);
setOriginalScripts(structuredClone(scriptList)); setOriginalScripts(JSON.parse(JSON.stringify(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 = useMemo( const hasScriptChanges = JSON.stringify(scripts) !== JSON.stringify(originalScripts);
() => JSON.stringify(scripts) !== JSON.stringify(originalScripts),
[scripts, originalScripts]
);
const hasChanges = hasCommandChanges || hasScriptChanges; const hasChanges = hasCommandChanges || hasScriptChanges;
const isSaving = updateSettingsMutation.isPending; const isSaving = updateSettingsMutation.isPending;
@@ -182,12 +158,7 @@ export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSection
setTestCommand(normalizedTestCommand); setTestCommand(normalizedTestCommand);
setOriginalTestCommand(normalizedTestCommand); setOriginalTestCommand(normalizedTestCommand);
setScripts(normalizedScripts); setScripts(normalizedScripts);
setOriginalScripts(structuredClone(normalizedScripts)); setOriginalScripts(JSON.parse(JSON.stringify(normalizedScripts)));
},
onError: (error) => {
toast.error('Failed to save settings', {
description: error instanceof Error ? error.message : 'An unexpected error occurred',
});
}, },
} }
); );
@@ -197,7 +168,7 @@ export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSection
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
setDevCommand(originalDevCommand); setDevCommand(originalDevCommand);
setTestCommand(originalTestCommand); setTestCommand(originalTestCommand);
setScripts(structuredClone(originalScripts)); setScripts(JSON.parse(JSON.stringify(originalScripts)));
}, [originalDevCommand, originalTestCommand, originalScripts]); }, [originalDevCommand, originalTestCommand, originalScripts]);
// ── Command handlers ── // ── Command handlers ──
@@ -287,36 +258,6 @@ 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 ── */}
@@ -535,14 +476,10 @@ export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSection
onDrop={(e) => handleDrop(e)} onDrop={(e) => handleDrop(e)}
onDragEnd={(e) => handleDragEnd(e)} onDragEnd={(e) => handleDragEnd(e)}
> >
{/* Drag handle - keyboard accessible */} {/* Drag handle */}
<div <div
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground focus:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded shrink-0 p-0.5" className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground shrink-0 p-0.5"
title="Drag to reorder (or use Arrow keys)" title="Drag to reorder"
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>

View File

@@ -63,8 +63,6 @@ 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)
@@ -175,7 +173,6 @@ 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}
@@ -183,7 +180,6 @@ export function SettingsView() {
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval} onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
onEnableAiCommitMessagesChange={setEnableAiCommitMessages} onEnableAiCommitMessagesChange={setEnableAiCommitMessages}
onDefaultFeatureModelChange={setDefaultFeatureModel} onDefaultFeatureModelChange={setDefaultFeatureModel}
onDefaultMaxTurnsChange={setDefaultMaxTurns}
/> />
); );
case 'worktrees': case 'worktrees':

View File

@@ -1,7 +1,5 @@
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,
@@ -14,7 +12,6 @@ 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 {
@@ -37,7 +34,6 @@ 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;
@@ -45,7 +41,6 @@ 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({
@@ -56,7 +51,6 @@ export function FeatureDefaultsSection({
defaultRequirePlanApproval, defaultRequirePlanApproval,
enableAiCommitMessages, enableAiCommitMessages,
defaultFeatureModel, defaultFeatureModel,
defaultMaxTurns,
onDefaultSkipTestsChange, onDefaultSkipTestsChange,
onEnableDependencyBlockingChange, onEnableDependencyBlockingChange,
onSkipVerificationInAutoModeChange, onSkipVerificationInAutoModeChange,
@@ -64,16 +58,7 @@ 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(
@@ -119,55 +104,6 @@ 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

View File

@@ -16,9 +16,6 @@ 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';
@@ -41,8 +38,6 @@ export function TerminalSection() {
defaultTerminalId, defaultTerminalId,
setDefaultTerminalId, setDefaultTerminalId,
setOpenTerminalMode, setOpenTerminalMode,
setTerminalBackgroundColor,
setTerminalForegroundColor,
} = useAppStore(); } = useAppStore();
const { const {
@@ -53,8 +48,6 @@ export function TerminalSection() {
lineHeight, lineHeight,
defaultFontSize, defaultFontSize,
openTerminalMode, openTerminalMode,
customBackgroundColor,
customForegroundColor,
} = terminalState; } = terminalState;
// Get available external terminals // Get available external terminals
@@ -212,138 +205,6 @@ 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">

View File

@@ -16,9 +16,6 @@ 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';
@@ -279,8 +276,6 @@ export function TerminalView({
setTerminalLineHeight, setTerminalLineHeight,
setTerminalScrollbackLines, setTerminalScrollbackLines,
setTerminalScreenReaderMode, setTerminalScreenReaderMode,
setTerminalBackgroundColor,
setTerminalForegroundColor,
updateTerminalPanelSizes, updateTerminalPanelSizes,
currentWorktreeByProject, currentWorktreeByProject,
worktreesByProject, worktreesByProject,
@@ -593,7 +588,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}:${initialCommand || ''}`; const cwdKey = `${initialCwd}:${initialMode || 'default'}:${nonce || 0}`;
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
@@ -1167,18 +1162,6 @@ 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(() => ({}));
@@ -1191,17 +1174,6 @@ 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;
});
} }
}; };
@@ -1236,22 +1208,6 @@ 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
@@ -2002,119 +1958,6 @@ 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">
@@ -2182,13 +2025,6 @@ 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!)}
/> />

View File

@@ -202,23 +202,14 @@ 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 { const { defaultRunScript, screenReaderMode, fontFamily, scrollbackLines, lineHeight } =
defaultRunScript, useAppStore(
screenReaderMode,
fontFamily,
scrollbackLines,
lineHeight,
customBackgroundColor,
customForegroundColor,
} = useAppStore(
useShallow((state) => ({ useShallow((state) => ({
defaultRunScript: state.terminalState.defaultRunScript, defaultRunScript: state.terminalState.defaultRunScript,
screenReaderMode: state.terminalState.screenReaderMode, screenReaderMode: state.terminalState.screenReaderMode,
fontFamily: state.terminalState.fontFamily, fontFamily: state.terminalState.fontFamily,
scrollbackLines: state.terminalState.scrollbackLines, scrollbackLines: state.terminalState.scrollbackLines,
lineHeight: state.terminalState.lineHeight, lineHeight: state.terminalState.lineHeight,
customBackgroundColor: state.terminalState.customBackgroundColor,
customForegroundColor: state.terminalState.customForegroundColor,
})) }))
); );
@@ -688,7 +679,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 baseTheme = getTerminalTheme(themeRef.current); const terminalTheme = 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;
@@ -696,18 +687,6 @@ 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({
@@ -1505,23 +1484,15 @@ export function TerminalPanel({
} }
}, [fontSize, isTerminalReady]); }, [fontSize, isTerminalReady]);
// Update terminal theme when app theme or custom colors change (including system preference) // Update terminal theme when app theme changes (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 baseTheme = getTerminalTheme(resolvedTheme); const terminalTheme = 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, customBackgroundColor, customForegroundColor, isTerminalReady]); }, [resolvedTheme, 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(() => {
@@ -1954,10 +1925,6 @@ 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}
@@ -2428,7 +2395,7 @@ export function TerminalPanel({
<div <div
ref={terminalRef} ref={terminalRef}
className="absolute inset-0" className="absolute inset-0"
style={{ backgroundColor: terminalBackgroundColor }} style={{ backgroundColor: currentTerminalTheme.background }}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove} onTouchMove={handleTouchMove}
@@ -2489,8 +2456,8 @@ export function TerminalPanel({
className="flex-1 overflow-auto" className="flex-1 overflow-auto"
style={ style={
{ {
backgroundColor: terminalBackgroundColor, backgroundColor: currentTerminalTheme.background,
color: terminalForegroundColor, color: currentTerminalTheme.foreground,
fontFamily: getTerminalFontFamily(fontFamily), fontFamily: getTerminalFontFamily(fontFamily),
fontSize: `${fontSize}px`, fontSize: `${fontSize}px`,
lineHeight: `${lineHeight || 1.0}`, lineHeight: `${lineHeight || 1.0}`,

View File

@@ -81,12 +81,12 @@ export function getTerminalFontFamily(fontValue: string | undefined): string {
return fontValue; return fontValue;
} }
// Dark theme (default) - true black background with white foreground // Dark theme (default)
const darkTheme: TerminalTheme = { const darkTheme: TerminalTheme = {
background: '#000000', background: '#0a0a0a',
foreground: '#ffffff', foreground: '#d4d4d4',
cursor: '#ffffff', cursor: '#d4d4d4',
cursorAccent: '#000000', cursorAccent: '#0a0a0a',
selectionBackground: '#264f78', selectionBackground: '#264f78',
black: '#1e1e1e', black: '#1e1e1e',
red: '#f44747', red: '#f44747',
@@ -626,29 +626,4 @@ 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;

View File

@@ -6,8 +6,8 @@
* automatic caching, deduplication, and background refetching. * automatic caching, deduplication, and background refetching.
*/ */
import { useMemo, useEffect, useRef } from 'react'; import { useMemo } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery } 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,34 +151,6 @@ 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[]> => {
@@ -194,11 +166,7 @@ export function useFeatures(projectPath: string | undefined) {
}, },
enabled: !!projectPath, enabled: !!projectPath,
initialData: () => persisted?.features, initialData: () => persisted?.features,
// Always treat localStorage cache as stale so React Query immediately initialDataUpdatedAt: () => persisted?.timestamp,
// 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,

View File

@@ -108,17 +108,10 @@ 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 (!hasWorktree) return null; if (!worktree) return null;
return worktreeIsMain ? null : worktreeBranch || null; return worktree.isMain ? null : worktree.branch || null;
}, [hasWorktree, worktreeIsMain, worktreeBranch]); }, [worktree]);
// Helper to look up project ID from path // Helper to look up project ID from path
const getProjectIdFromPath = useCallback( const getProjectIdFromPath = useCallback(
@@ -252,19 +245,10 @@ export function useAutoMode(worktree?: WorktreeInfo) {
} }
}, [branchName, currentProject, setAutoModeRunning]); }, [branchName, currentProject, setAutoModeRunning]);
// On mount (and when refreshStatus identity changes, e.g. project switch), // On mount, query backend for current auto loop status and sync UI state.
// 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(() => {
const timer = setTimeout(() => void refreshStatus(), 150); void refreshStatus();
return () => clearTimeout(timer);
}, [refreshStatus]); }, [refreshStatus]);
// Periodic polling fallback when WebSocket events are stale. // Periodic polling fallback when WebSocket events are stale.

View File

@@ -26,6 +26,7 @@ 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);
@@ -115,39 +116,30 @@ 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();
// snapshotProject is the store's current value at this point in time; const updatedProject = storeState.currentProject;
// it is distinct from updatedProjectData which is the new value we build below. if (updatedProject && updatedProject.path === projectPath) {
const snapshotProject = storeState.currentProject;
if (snapshotProject && snapshotProject.path === projectPath) {
const needsUpdate = const needsUpdate =
(activeClaudeApiProfileId !== undefined && (activeClaudeApiProfileId !== undefined &&
snapshotProject.activeClaudeApiProfileId !== activeClaudeApiProfileId) || updatedProject.activeClaudeApiProfileId !== activeClaudeApiProfileId) ||
(phaseModelOverrides !== undefined && (phaseModelOverrides !== undefined &&
JSON.stringify(snapshotProject.phaseModelOverrides) !== JSON.stringify(updatedProject.phaseModelOverrides) !==
JSON.stringify(phaseModelOverrides)); JSON.stringify(phaseModelOverrides));
if (needsUpdate) { if (needsUpdate) {
const updatedProjectData = { const updatedProjectData = {
...snapshotProject, ...updatedProject,
...(activeClaudeApiProfileId !== undefined && { activeClaudeApiProfileId }), ...(activeClaudeApiProfileId !== undefined && { activeClaudeApiProfileId }),
...(phaseModelOverrides !== undefined && { phaseModelOverrides }), ...(phaseModelOverrides !== undefined && { phaseModelOverrides }),
}; };
// Update both currentProject and projects array in a single setState call // Update currentProject
// to avoid two separate re-renders that can cascade during initialization setCurrentProject(updatedProjectData);
// 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 === snapshotProject.id ? updatedProjectData : p p.id === updatedProject.id ? updatedProjectData : p
); );
// NOTE: Intentionally bypasses setCurrentProject() to avoid a second useAppStore.setState({ projects: updatedProjects });
// 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,
});
} }
} }
}, [ }, [
@@ -167,5 +159,6 @@ export function useProjectSettingsLoader() {
setDefaultDeleteBranch, setDefaultDeleteBranch,
setAutoDismissInitScriptIndicator, setAutoDismissInitScriptIndicator,
setWorktreeCopyFiles, setWorktreeCopyFiles,
setCurrentProject,
]); ]);
} }

View File

@@ -213,12 +213,6 @@ 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);
@@ -363,27 +357,6 @@ export function mergeSettings(
merged.claudeCompatibleProviders = localSettings.claudeCompatibleProviders; merged.claudeCompatibleProviders = localSettings.claudeCompatibleProviders;
} }
// Preserve new settings fields from localStorage if server has defaults
// Use nullish coalescing to accept stored falsy values (e.g. false)
if (localSettings.enableAiCommitMessages != null && merged.enableAiCommitMessages == null) {
merged.enableAiCommitMessages = localSettings.enableAiCommitMessages;
}
if (localSettings.enableSkills != null && merged.enableSkills == null) {
merged.enableSkills = localSettings.enableSkills;
}
if (localSettings.skillsSources && (!merged.skillsSources || merged.skillsSources.length === 0)) {
merged.skillsSources = localSettings.skillsSources;
}
if (localSettings.enableSubagents != null && merged.enableSubagents == null) {
merged.enableSubagents = localSettings.enableSubagents;
}
if (
localSettings.subagentsSources &&
(!merged.subagentsSources || merged.subagentsSources.length === 0)
) {
merged.subagentsSources = localSettings.subagentsSources;
}
return merged; return merged;
} }
@@ -755,12 +728,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
opencodeDefaultModel: sanitizedOpencodeDefaultModel, opencodeDefaultModel: sanitizedOpencodeDefaultModel,
enabledDynamicModelIds: sanitizedDynamicModelIds, enabledDynamicModelIds: sanitizedDynamicModelIds,
disabledProviders: settings.disabledProviders ?? [], disabledProviders: settings.disabledProviders ?? [],
enableAiCommitMessages: settings.enableAiCommitMessages ?? true, autoLoadClaudeMd: settings.autoLoadClaudeMd ?? false,
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',
@@ -795,25 +763,11 @@ 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 settings (nested in terminalState) // Terminal font (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,
...(settings.terminalFontFamily && { fontFamily: 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,
}),
}, },
}), }),
}); });
@@ -873,11 +827,6 @@ 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,
@@ -909,8 +858,6 @@ 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,
}; };
} }

View File

@@ -49,8 +49,6 @@ 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',
@@ -92,14 +90,8 @@ 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',
@@ -108,7 +100,6 @@ const SETTINGS_FIELDS_TO_SYNC = [
'projectHistory', 'projectHistory',
'projectHistoryIndex', 'projectHistoryIndex',
'lastSelectedSessionByProject', 'lastSelectedSessionByProject',
'currentWorktreeByProject',
// Codex CLI Settings // Codex CLI Settings
'codexAutoLoadAgents', 'codexAutoLoadAgents',
'codexSandboxMode', 'codexSandboxMode',
@@ -117,8 +108,6 @@ 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',
@@ -153,12 +142,6 @@ 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;
@@ -202,16 +185,6 @@ 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];
} }
@@ -757,7 +730,6 @@ 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,
@@ -774,7 +746,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
copilotDefaultModel: sanitizedCopilotDefaultModel, copilotDefaultModel: sanitizedCopilotDefaultModel,
enabledDynamicModelIds: sanitizedDynamicModelIds, enabledDynamicModelIds: sanitizedDynamicModelIds,
disabledProviders: serverSettings.disabledProviders ?? [], disabledProviders: serverSettings.disabledProviders ?? [],
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? true, autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,
keyboardShortcuts: { keyboardShortcuts: {
...currentAppState.keyboardShortcuts, ...currentAppState.keyboardShortcuts,
...(serverSettings.keyboardShortcuts as unknown as Partial< ...(serverSettings.keyboardShortcuts as unknown as Partial<
@@ -796,8 +768,6 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
projectHistory: serverSettings.projectHistory, projectHistory: serverSettings.projectHistory,
projectHistoryIndex: serverSettings.projectHistoryIndex, projectHistoryIndex: serverSettings.projectHistoryIndex,
lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject, lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject,
currentWorktreeByProject:
serverSettings.currentWorktreeByProject ?? currentAppState.currentWorktreeByProject,
// UI State (previously in localStorage) // UI State (previously in localStorage)
worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false, worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false,
lastProjectDir: serverSettings.lastProjectDir ?? '', lastProjectDir: serverSettings.lastProjectDir ?? '',
@@ -813,12 +783,7 @@ 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.terminalFontFamily || serverSettings.openTerminalMode) && {
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 && {
@@ -827,16 +792,6 @@ 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,
}),
}, },
}), }),
}); });

View File

@@ -339,8 +339,6 @@ 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 {

View File

@@ -1,7 +1,6 @@
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"
@@ -251,12 +250,8 @@ 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>
<AppErrorBoundary>
<App /> <App />
</AppErrorBoundary>
</StrictMode> </StrictMode>
); );

View File

@@ -182,39 +182,25 @@ function selectAutoOpenProject(
function RootLayoutContent() { function RootLayoutContent() {
const location = useLocation(); const location = useLocation();
const {
// IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent setIpcConnected,
// re-rendering on every store mutation. The bare call subscribes to the ENTIRE store, projects,
// which during initialization causes cascading re-renders as multiple effects write currentProject,
// to the store (settings hydration, project settings, auto-open, etc.). With enough projectHistory,
// rapid mutations, React hits the maximum update depth limit (error #185). upsertAndSetCurrentProject,
// getEffectiveTheme,
// Each selector only triggers a re-render when its specific slice of state changes. getEffectiveFontSans,
const projects = useAppStore((s) => s.projects); getEffectiveFontMono,
const currentProject = useAppStore((s) => s.currentProject);
const projectHistory = useAppStore((s) => s.projectHistory);
const sidebarStyle = useAppStore((s) => s.sidebarStyle);
const skipSandboxWarning = useAppStore((s) => s.skipSandboxWarning);
// Subscribe to theme and font state to trigger re-renders when they change // Subscribe to theme and font state to trigger re-renders when they change
const theme = useAppStore((s) => s.theme); theme,
const fontFamilySans = useAppStore((s) => s.fontFamilySans); fontFamilySans,
const fontFamilyMono = useAppStore((s) => s.fontFamilyMono); fontFamilyMono,
// Subscribe to previewTheme so that getEffectiveTheme() re-renders when sidebarStyle,
// hover previews change the document theme. Without this, the selector skipSandboxWarning,
// for getEffectiveTheme (a stable function ref) won't trigger re-renders. setSkipSandboxWarning,
const previewTheme = useAppStore((s) => s.previewTheme); fetchCodexModels,
void previewTheme; // Used only for subscription } = useAppStore();
// Actions (stable references from Zustand - never change between renders) const { setupComplete, codexCliStatus } = useSetupStore();
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);
@@ -414,16 +400,19 @@ 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).
@@ -735,31 +724,33 @@ 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;
} }

View File

@@ -369,7 +369,6 @@ 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,
@@ -992,7 +991,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: false, isRunning: true,
runningTasks: [], runningTasks: [],
branchName, branchName,
}; };
@@ -1110,7 +1109,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.settings.updateGlobal({ skipVerificationInAutoMode: enabled }); await httpApi.put('/api/settings', { skipVerificationInAutoMode: enabled });
} catch (error) { } catch (error) {
logger.error('Failed to sync skipVerificationInAutoMode:', error); logger.error('Failed to sync skipVerificationInAutoMode:', error);
} }
@@ -1120,7 +1119,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.settings.updateGlobal({ enableAiCommitMessages: enabled }); await httpApi.put('/api/settings', { enableAiCommitMessages: enabled });
} catch (error) { } catch (error) {
logger.error('Failed to sync enableAiCommitMessages:', error); logger.error('Failed to sync enableAiCommitMessages:', error);
} }
@@ -1130,7 +1129,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.settings.updateGlobal({ mergePostAction: action }); await httpApi.put('/api/settings', { mergePostAction: action });
} catch (error) { } catch (error) {
logger.error('Failed to sync mergePostAction:', error); logger.error('Failed to sync mergePostAction:', error);
} }
@@ -1140,7 +1139,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.settings.updateGlobal({ planUseSelectedWorktreeBranch: enabled }); await httpApi.put('/api/settings', { planUseSelectedWorktreeBranch: enabled });
} catch (error) { } catch (error) {
logger.error('Failed to sync planUseSelectedWorktreeBranch:', error); logger.error('Failed to sync planUseSelectedWorktreeBranch:', error);
} }
@@ -1150,7 +1149,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.settings.updateGlobal({ addFeatureUseSelectedWorktreeBranch: enabled }); await httpApi.put('/api/settings', { addFeatureUseSelectedWorktreeBranch: enabled });
} catch (error) { } catch (error) {
logger.error('Failed to sync addFeatureUseSelectedWorktreeBranch:', error); logger.error('Failed to sync addFeatureUseSelectedWorktreeBranch:', error);
} }
@@ -1223,7 +1222,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.settings.updateGlobal({ phaseModels: get().phaseModels }); await httpApi.put('/api/settings', { phaseModels: get().phaseModels });
} catch (error) { } catch (error) {
logger.error('Failed to sync phase model:', error); logger.error('Failed to sync phase model:', error);
} }
@@ -1235,7 +1234,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.settings.updateGlobal({ phaseModels: get().phaseModels }); await httpApi.put('/api/settings', { phaseModels: get().phaseModels });
} catch (error) { } catch (error) {
logger.error('Failed to sync phase models:', error); logger.error('Failed to sync phase models:', error);
} }
@@ -1245,7 +1244,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.settings.updateGlobal({ phaseModels: DEFAULT_PHASE_MODELS }); await httpApi.put('/api/settings', { 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);
} }
@@ -1280,7 +1279,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.settings.updateGlobal({ codexAutoLoadAgents: enabled }); await httpApi.put('/api/settings', { codexAutoLoadAgents: enabled });
} catch (error) { } catch (error) {
logger.error('Failed to sync codexAutoLoadAgents:', error); logger.error('Failed to sync codexAutoLoadAgents:', error);
} }
@@ -1289,7 +1288,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.settings.updateGlobal({ codexSandboxMode: mode }); await httpApi.put('/api/settings', { codexSandboxMode: mode });
} catch (error) { } catch (error) {
logger.error('Failed to sync codexSandboxMode:', error); logger.error('Failed to sync codexSandboxMode:', error);
} }
@@ -1298,7 +1297,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.settings.updateGlobal({ codexApprovalPolicy: policy }); await httpApi.put('/api/settings', { codexApprovalPolicy: policy });
} catch (error) { } catch (error) {
logger.error('Failed to sync codexApprovalPolicy:', error); logger.error('Failed to sync codexApprovalPolicy:', error);
} }
@@ -1307,7 +1306,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.settings.updateGlobal({ codexEnableWebSearch: enabled }); await httpApi.put('/api/settings', { codexEnableWebSearch: enabled });
} catch (error) { } catch (error) {
logger.error('Failed to sync codexEnableWebSearch:', error); logger.error('Failed to sync codexEnableWebSearch:', error);
} }
@@ -1316,7 +1315,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.settings.updateGlobal({ codexEnableImages: enabled }); await httpApi.put('/api/settings', { codexEnableImages: enabled });
} catch (error) { } catch (error) {
logger.error('Failed to sync codexEnableImages:', error); logger.error('Failed to sync codexEnableImages:', error);
} }
@@ -1376,7 +1375,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.settings.updateGlobal({ autoLoadClaudeMd: enabled }); await httpApi.put('/api/settings', { autoLoadClaudeMd: enabled });
} catch (error) { } catch (error) {
logger.error('Failed to sync autoLoadClaudeMd:', error); logger.error('Failed to sync autoLoadClaudeMd:', error);
} }
@@ -1385,7 +1384,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.settings.updateGlobal({ skipSandboxWarning: skip }); await httpApi.put('/api/settings', { skipSandboxWarning: skip });
} catch (error) { } catch (error) {
logger.error('Failed to sync skipSandboxWarning:', error); logger.error('Failed to sync skipSandboxWarning:', error);
} }
@@ -1408,7 +1407,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
set({ promptCustomization: customization }); set({ promptCustomization: customization });
try { try {
const httpApi = getHttpApiClient(); const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ promptCustomization: customization }); await httpApi.put('/api/settings', { promptCustomization: customization });
} catch (error) { } catch (error) {
logger.error('Failed to sync prompt customization:', error); logger.error('Failed to sync prompt customization:', error);
} }
@@ -1424,7 +1423,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})); }));
try { try {
const httpApi = getHttpApiClient(); const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ await httpApi.put('/api/settings', {
claudeCompatibleProviders: get().claudeCompatibleProviders, claudeCompatibleProviders: get().claudeCompatibleProviders,
}); });
} catch (error) { } catch (error) {
@@ -1439,7 +1438,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})); }));
try { try {
const httpApi = getHttpApiClient(); const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ await httpApi.put('/api/settings', {
claudeCompatibleProviders: get().claudeCompatibleProviders, claudeCompatibleProviders: get().claudeCompatibleProviders,
}); });
} catch (error) { } catch (error) {
@@ -1452,7 +1451,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})); }));
try { try {
const httpApi = getHttpApiClient(); const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ await httpApi.put('/api/settings', {
claudeCompatibleProviders: get().claudeCompatibleProviders, claudeCompatibleProviders: get().claudeCompatibleProviders,
}); });
} catch (error) { } catch (error) {
@@ -1463,7 +1462,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.settings.updateGlobal({ claudeCompatibleProviders: providers }); await httpApi.put('/api/settings', { claudeCompatibleProviders: providers });
} catch (error) { } catch (error) {
logger.error('Failed to sync Claude-compatible providers:', error); logger.error('Failed to sync Claude-compatible providers:', error);
} }
@@ -1476,7 +1475,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})); }));
try { try {
const httpApi = getHttpApiClient(); const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ await httpApi.put('/api/settings', {
claudeCompatibleProviders: get().claudeCompatibleProviders, claudeCompatibleProviders: get().claudeCompatibleProviders,
}); });
} catch (error) { } catch (error) {
@@ -1491,7 +1490,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})); }));
try { try {
const httpApi = getHttpApiClient(); const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ claudeApiProfiles: get().claudeApiProfiles }); await httpApi.put('/api/settings', { 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);
} }
@@ -1504,7 +1503,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})); }));
try { try {
const httpApi = getHttpApiClient(); const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ claudeApiProfiles: get().claudeApiProfiles }); await httpApi.put('/api/settings', { 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);
} }
@@ -1517,7 +1516,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})); }));
try { try {
const httpApi = getHttpApiClient(); const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ await httpApi.put('/api/settings', {
claudeApiProfiles: get().claudeApiProfiles, claudeApiProfiles: get().claudeApiProfiles,
activeClaudeApiProfileId: get().activeClaudeApiProfileId, activeClaudeApiProfileId: get().activeClaudeApiProfileId,
}); });
@@ -1529,7 +1528,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.settings.updateGlobal({ activeClaudeApiProfileId: id }); await httpApi.put('/api/settings', { 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);
} }
@@ -1538,7 +1537,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.settings.updateGlobal({ claudeApiProfiles: profiles }); await httpApi.put('/api/settings', { claudeApiProfiles: profiles });
} catch (error) { } catch (error) {
logger.error('Failed to sync Claude API profiles:', error); logger.error('Failed to sync Claude API profiles:', error);
} }
@@ -1948,16 +1947,6 @@ 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;
@@ -2352,7 +2341,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.settings.updateGlobal({ defaultThinkingLevel: level }); await httpApi.put('/api/settings', { defaultThinkingLevel: level });
} catch (error) { } catch (error) {
logger.error('Failed to sync defaultThinkingLevel:', error); logger.error('Failed to sync defaultThinkingLevel:', error);
} }
@@ -2363,27 +2352,12 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
// Sync to server // Sync to server
try { try {
const httpApi = getHttpApiClient(); const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ defaultReasoningEffort: effort }); await httpApi.put('/api/settings', { 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 }),

View File

@@ -18,6 +18,4 @@ export const defaultTerminalState: TerminalState = {
maxSessions: 100, maxSessions: 100,
lastActiveProjectPath: null, lastActiveProjectPath: null,
openTerminalMode: 'newTab', openTerminalMode: 'newTab',
customBackgroundColor: null,
customForegroundColor: null,
}; };

View File

@@ -182,9 +182,6 @@ 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
@@ -567,7 +564,6 @@ 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;
@@ -712,8 +708,6 @@ 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;

View File

@@ -33,8 +33,6 @@ 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
@@ -81,6 +79,4 @@ 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)
} }

View File

@@ -113,25 +113,8 @@ export function syncUICache(appState: {
if ('collapsedNavSections' in appState) { if ('collapsedNavSections' in appState) {
update.cachedCollapsedNavSections = appState.collapsedNavSections; update.cachedCollapsedNavSections = appState.collapsedNavSections;
} }
if ('currentWorktreeByProject' in appState && appState.currentWorktreeByProject) { if ('currentWorktreeByProject' in appState) {
// Sanitize on write: only persist entries where path is null (main branch). update.cachedCurrentWorktreeByProject = appState.currentWorktreeByProject;
// 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) {
@@ -177,37 +160,11 @@ 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
) { ) {
const sanitized: Record<string, { path: string | null; branch: string }> = {}; stateUpdate.currentWorktreeByProject = cache.cachedCurrentWorktreeByProject;
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.

View File

@@ -69,7 +69,6 @@ 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;
@@ -911,19 +910,6 @@ 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;
}>; }>;

View File

@@ -64,10 +64,8 @@ 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: auto-detect local network origins) # Optional - CORS origin (default allows all)
# With nginx proxying API requests, CORS is not needed for same-origin access. - CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3007}
# 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

View File

@@ -9,12 +9,12 @@
import type { ThemeMode } from '@automaker/types'; import type { ThemeMode } from '@automaker/types';
import type { TerminalTheme } from './rc-generator.js'; import type { TerminalTheme } from './rc-generator.js';
// Dark theme (default) - true black background with white foreground // Dark theme (default)
const darkTheme: TerminalTheme = { const darkTheme: TerminalTheme = {
background: '#000000', background: '#0a0a0a',
foreground: '#ffffff', foreground: '#d4d4d4',
cursor: '#ffffff', cursor: '#d4d4d4',
cursorAccent: '#000000', cursorAccent: '#0a0a0a',
selectionBackground: '#264f78', selectionBackground: '#264f78',
black: '#1e1e1e', black: '#1e1e1e',
red: '#f44747', red: '#f44747',

View File

@@ -6,7 +6,6 @@ export interface AgentSession {
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;

View File

@@ -1117,15 +1117,6 @@ export interface GlobalSettings {
* in the two-stage model selector. Defaults to 'none'. */ * in the two-stage model selector. Defaults to 'none'. */
defaultReasoningEffort?: ReasoningEffort; defaultReasoningEffort?: ReasoningEffort;
/** Default maximum number of agent turns (tool call round-trips) for feature execution.
* Controls how many iterations the AI agent can perform before stopping.
* Higher values allow more complex tasks but use more API credits.
* Defaults to 1000. Range: 1-2000.
*
* Note: Currently supported by Claude (via SDK) and Codex (via CLI config).
* Gemini and OpenCode CLI providers do not support max turns configuration. */
defaultMaxTurns?: number;
// Legacy AI Model Selection (deprecated - use phaseModels instead) // Legacy AI Model Selection (deprecated - use phaseModels instead)
/** @deprecated Use phaseModels.enhancementModel instead */ /** @deprecated Use phaseModels.enhancementModel instead */
enhancementModel: ModelAlias; enhancementModel: ModelAlias;
@@ -1632,7 +1623,6 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
phaseModels: DEFAULT_PHASE_MODELS, phaseModels: DEFAULT_PHASE_MODELS,
defaultThinkingLevel: 'none', defaultThinkingLevel: 'none',
defaultReasoningEffort: 'none', defaultReasoningEffort: 'none',
defaultMaxTurns: 1000,
enhancementModel: 'sonnet', // Legacy alias still supported enhancementModel: 'sonnet', // Legacy alias still supported
validationModel: 'opus', // Legacy alias still supported validationModel: 'opus', // Legacy alias still supported
enabledCursorModels: getAllCursorModelIds(), // Returns prefixed IDs enabledCursorModels: getAllCursorModelIds(), // Returns prefixed IDs