Feature: worktree view customization and stability fixes (#805)

* Changes from feature/worktree-view-customization

* Feature: Git sync, set-tracking, and push divergence handling (#796)

* Add quick-add feature with improved workflows (#802)

* Changes from feature/quick-add

* feat: Clarify system prompt and improve error handling across services. Address PR Feedback

* feat: Improve PR description parsing and refactor event handling

* feat: Add context options to pipeline orchestrator initialization

* fix: Deduplicate React and handle CJS interop for use-sync-external-store

Resolve "Cannot read properties of null (reading 'useState')" errors by
deduplicating React/react-dom and ensuring use-sync-external-store is
bundled together with React to prevent CJS packages from resolving to
different React instances.

* Changes from feature/worktree-view-customization

* refactor: Remove unused worktree swap and highlight props

* refactor: Consolidate feature completion logic and improve thinking level defaults

* feat: Increase max turn limit to 10000

- Update DEFAULT_MAX_TURNS from 1000 to 10000 in settings-helpers.ts and agent-executor.ts
- Update MAX_ALLOWED_TURNS from 2000 to 10000 in settings-helpers.ts
- Update UI clamping logic from 2000 to 10000 in app-store.ts
- Update fallback values from 1000 to 10000 in use-settings-sync.ts
- Update default value from 1000 to 10000 in DEFAULT_GLOBAL_SETTINGS
- Update documentation to reflect new range: 1-10000

Allows agents to perform up to 10000 turns for complex feature execution.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* feat: Add model resolution, improve session handling, and enhance UI stability

* refactor: Remove unused sync and tracking branch props from worktree components

* feat: Add PR number update functionality to worktrees. Address pr feedback

* feat: Optimize Gemini CLI startup and add tool result tracking

* refactor: Improve error handling and simplify worktree task cleanup

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-02-23 20:31:25 -08:00
committed by GitHub
parent e7504b247f
commit 0330c70261
72 changed files with 3667 additions and 1173 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/server",
"version": "0.13.0",
"version": "0.15.0",
"description": "Backend server for Automaker - provides API for both web and Electron modes",
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",

View File

@@ -133,12 +133,16 @@ export const TOOL_PRESETS = {
'Read',
'Write',
'Edit',
'MultiEdit',
'Glob',
'Grep',
'LS',
'Bash',
'WebSearch',
'WebFetch',
'TodoWrite',
'Task',
'Skill',
] as const,
/** Tools for chat/interactive mode */
@@ -146,12 +150,16 @@ export const TOOL_PRESETS = {
'Read',
'Write',
'Edit',
'MultiEdit',
'Glob',
'Grep',
'LS',
'Bash',
'WebSearch',
'WebFetch',
'TodoWrite',
'Task',
'Skill',
] as const,
} as const;

View File

@@ -34,10 +34,10 @@ import {
const logger = createLogger('SettingsHelper');
/** Default number of agent turns used when no value is configured. */
export const DEFAULT_MAX_TURNS = 1000;
export const DEFAULT_MAX_TURNS = 10000;
/** Upper bound for the max-turns clamp; values above this are capped here. */
export const MAX_ALLOWED_TURNS = 2000;
export const MAX_ALLOWED_TURNS = 10000;
/**
* Get the autoLoadClaudeMd setting, with project settings taking precedence over global.

View File

@@ -127,11 +127,16 @@ const DEFAULT_ALLOWED_TOOLS = [
'Read',
'Write',
'Edit',
'MultiEdit',
'Glob',
'Grep',
'LS',
'Bash',
'WebSearch',
'WebFetch',
'TodoWrite',
'Task',
'Skill',
] as const;
const SEARCH_TOOL_NAMES = new Set(['WebSearch', 'WebFetch']);
const MIN_MAX_TURNS = 1;

View File

@@ -24,7 +24,7 @@ import type {
import { validateBareModelId } from '@automaker/types';
import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';
import { createLogger, isAbortError } from '@automaker/utils';
import { spawnJSONLProcess } from '@automaker/platform';
import { spawnJSONLProcess, type SubprocessOptions } from '@automaker/platform';
import { normalizeTodos } from './tool-normalization.js';
// Create logger for this module
@@ -263,6 +263,14 @@ export class GeminiProvider extends CliProvider {
// Use explicit approval-mode for clearer semantics
cliArgs.push('--approval-mode', 'yolo');
// Force headless (non-interactive) mode with --prompt flag.
// The actual prompt content is passed via stdin (see buildSubprocessOptions()),
// but we MUST include -p to trigger headless mode. Without it, Gemini CLI
// starts in interactive mode which adds significant startup overhead
// (interactive REPL setup, extra context loading, etc.).
// Per Gemini CLI docs: stdin content is "appended to" the -p value.
cliArgs.push('--prompt', '');
// Explicitly include the working directory in allowed workspace directories
// This ensures Gemini CLI allows file operations in the project directory,
// even if it has a different workspace cached from a previous session
@@ -279,9 +287,6 @@ export class GeminiProvider extends CliProvider {
// Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro).
// The model handles thinking internally based on the task complexity.
// The prompt will be passed as the last positional argument
// We'll append it in executeQuery after extracting the text
return cliArgs;
}
@@ -413,6 +418,32 @@ export class GeminiProvider extends CliProvider {
// CliProvider Overrides
// ==========================================================================
/**
* Build subprocess options with stdin data for prompt and speed-optimized env vars.
*
* Passes the prompt via stdin instead of --prompt CLI arg to:
* - Avoid shell argument size limits with large prompts (system prompt + context)
* - Avoid shell escaping issues with special characters in prompts
* - Match the pattern used by Cursor, OpenCode, and Codex providers
*
* Also injects environment variables to reduce Gemini CLI startup overhead:
* - GEMINI_TELEMETRY_ENABLED=false: Disables OpenTelemetry collection
*/
protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions {
const subprocessOptions = super.buildSubprocessOptions(options, cliArgs);
// Pass prompt via stdin to avoid shell interpretation of special characters
// and shell argument size limits with large system prompts + context files
subprocessOptions.stdinData = this.extractPromptText(options);
// Disable telemetry to reduce startup overhead
if (subprocessOptions.env) {
subprocessOptions.env['GEMINI_TELEMETRY_ENABLED'] = 'false';
}
return subprocessOptions;
}
/**
* Override error mapping for Gemini-specific error codes
*/
@@ -522,14 +553,21 @@ export class GeminiProvider extends CliProvider {
);
}
// Extract prompt text to pass as positional argument
const promptText = this.extractPromptText(options);
// Ensure .geminiignore exists in the working directory to prevent Gemini CLI
// from scanning .git and node_modules directories during startup. This reduces
// startup time significantly (reported: 35s → 11s) by skipping large directories
// that Gemini CLI would otherwise traverse for context discovery.
await this.ensureGeminiIgnore(options.cwd || process.cwd());
// Build CLI args and append the prompt as the last positional argument
const cliArgs = this.buildCliArgs(options);
cliArgs.push(promptText); // Gemini CLI uses positional args for the prompt
// Embed system prompt into the user prompt so Gemini CLI receives
// project context (CLAUDE.md, CODE_QUALITY.md, etc.) that would
// otherwise be silently dropped since Gemini CLI has no --system-prompt flag.
const effectiveOptions = this.embedSystemPromptIntoPrompt(options);
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
// Build CLI args for headless execution.
const cliArgs = this.buildCliArgs(effectiveOptions);
const subprocessOptions = this.buildSubprocessOptions(effectiveOptions, cliArgs);
let sessionId: string | undefined;
@@ -582,6 +620,49 @@ export class GeminiProvider extends CliProvider {
// Gemini-Specific Methods
// ==========================================================================
/**
* Ensure a .geminiignore file exists in the working directory.
*
* Gemini CLI scans the working directory for context discovery during startup.
* Excluding .git and node_modules dramatically reduces startup time by preventing
* traversal of large directories (reported improvement: 35s → 11s).
*
* Only creates the file if it doesn't already exist to avoid overwriting user config.
*/
private async ensureGeminiIgnore(cwd: string): Promise<void> {
const ignorePath = path.join(cwd, '.geminiignore');
const content = [
'# Auto-generated by Automaker to speed up Gemini CLI startup',
'# Prevents Gemini CLI from scanning large directories during context discovery',
'.git',
'node_modules',
'dist',
'build',
'.next',
'.nuxt',
'coverage',
'.automaker',
'.worktrees',
'.vscode',
'.idea',
'*.lock',
'',
].join('\n');
try {
// Use 'wx' flag for atomic creation - fails if file exists (EEXIST)
await fs.writeFile(ignorePath, content, { encoding: 'utf-8', flag: 'wx' });
logger.debug(`Created .geminiignore at ${ignorePath}`);
} catch (writeError) {
// EEXIST means file already exists - that's fine, preserve user's file
if ((writeError as NodeJS.ErrnoException).code === 'EEXIST') {
logger.debug(`.geminiignore already exists at ${ignorePath}, preserving existing file`);
return;
}
// Non-fatal: startup will just be slower without the ignore file
logger.debug(`Failed to create .geminiignore: ${writeError}`);
}
}
/**
* Create a GeminiError with details
*/

View File

@@ -44,7 +44,11 @@ export function createFeaturesRoutes(
validatePathParams('projectPath'),
createCreateHandler(featureLoader, events)
);
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
router.post(
'/update',
validatePathParams('projectPath'),
createUpdateHandler(featureLoader, events)
);
router.post(
'/bulk-update',
validatePathParams('projectPath'),

View File

@@ -5,6 +5,7 @@
import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { Feature, FeatureStatus } from '@automaker/types';
import type { EventEmitter } from '../../../lib/events.js';
import { getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils';
@@ -13,7 +14,7 @@ const logger = createLogger('features/update');
// Statuses that should trigger syncing to app_spec.txt
const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed'];
export function createUpdateHandler(featureLoader: FeatureLoader) {
export function createUpdateHandler(featureLoader: FeatureLoader, events?: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const {
@@ -54,8 +55,18 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
preEnhancementDescription
);
// Trigger sync to app_spec.txt when status changes to verified or completed
// Emit completion event and sync to app_spec.txt when status transitions to verified/completed
if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) {
events?.emit('feature:completed', {
featureId,
featureName: updated.title,
projectPath,
passes: true,
message:
newStatus === 'verified' ? 'Feature verified manually' : 'Feature completed manually',
executionMode: 'manual',
});
try {
const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated);
if (synced) {

View File

@@ -69,6 +69,7 @@ import { createStageFilesHandler } from './routes/stage-files.js';
import { createCheckChangesHandler } from './routes/check-changes.js';
import { createSetTrackingHandler } from './routes/set-tracking.js';
import { createSyncHandler } from './routes/sync.js';
import { createUpdatePRNumberHandler } from './routes/update-pr-number.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes(
@@ -96,6 +97,12 @@ export function createWorktreeRoutes(
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
router.post('/create-pr', createCreatePRHandler());
router.post('/pr-info', createPRInfoHandler());
router.post(
'/update-pr-number',
validatePathParams('worktreePath', 'projectPath?'),
requireValidWorktree,
createUpdatePRNumberHandler()
);
router.post(
'/commit',
validatePathParams('worktreePath'),

View File

@@ -0,0 +1,163 @@
/**
* POST /update-pr-number endpoint - Update the tracked PR number for a worktree
*
* Allows users to manually change which PR number is tracked for a worktree branch.
* Fetches updated PR info from GitHub when available, or updates metadata with the
* provided number only if GitHub CLI is unavailable.
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError, execAsync, execEnv, isGhCliAvailable } from '../common.js';
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';
import { validatePRState } from '@automaker/types';
const logger = createLogger('UpdatePRNumber');
export function createUpdatePRNumberHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, projectPath, prNumber } = req.body as {
worktreePath: string;
projectPath?: string;
prNumber: number;
};
if (!worktreePath) {
res.status(400).json({ success: false, error: 'worktreePath required' });
return;
}
if (
!prNumber ||
typeof prNumber !== 'number' ||
prNumber <= 0 ||
!Number.isInteger(prNumber)
) {
res.status(400).json({ success: false, error: 'prNumber must be a positive integer' });
return;
}
const effectiveProjectPath = projectPath || worktreePath;
// Get current branch name
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
env: execEnv,
});
const branchName = branchOutput.trim();
if (!branchName || branchName === 'HEAD') {
res.status(400).json({
success: false,
error: 'Cannot update PR number in detached HEAD state',
});
return;
}
// Try to fetch PR info from GitHub for the given PR number
const ghCliAvailable = await isGhCliAvailable();
if (ghCliAvailable) {
try {
// Detect repository for gh CLI
let repoFlag = '';
try {
const { stdout: remotes } = await execAsync('git remote -v', {
cwd: worktreePath,
env: execEnv,
});
const lines = remotes.split(/\r?\n/);
let upstreamRepo: string | null = null;
let originOwner: string | null = null;
let originRepo: string | null = null;
for (const line of lines) {
const match =
line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/) ||
line.match(/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/) ||
line.match(/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/);
if (match) {
const [, remoteName, owner, repo] = match;
if (remoteName === 'upstream') {
upstreamRepo = `${owner}/${repo}`;
} else if (remoteName === 'origin') {
originOwner = owner;
originRepo = repo;
}
}
}
const targetRepo =
upstreamRepo || (originOwner && originRepo ? `${originOwner}/${originRepo}` : null);
if (targetRepo) {
repoFlag = ` --repo "${targetRepo}"`;
}
} catch {
// Ignore remote parsing errors
}
// Fetch PR info from GitHub using the PR number
const viewCmd = `gh pr view ${prNumber}${repoFlag} --json number,title,url,state,createdAt`;
const { stdout: prOutput } = await execAsync(viewCmd, {
cwd: worktreePath,
env: execEnv,
});
const prData = JSON.parse(prOutput);
const prInfo = {
number: prData.number,
url: prData.url,
title: prData.title,
state: validatePRState(prData.state),
createdAt: prData.createdAt || new Date().toISOString(),
};
await updateWorktreePRInfo(effectiveProjectPath, branchName, prInfo);
logger.info(`Updated PR tracking to #${prNumber} for branch ${branchName}`);
res.json({
success: true,
result: {
branch: branchName,
prInfo,
},
});
return;
} catch (error) {
logger.warn(`Failed to fetch PR #${prNumber} from GitHub:`, error);
// Fall through to simple update below
}
}
// Fallback: update with just the number, preserving existing PR info structure
// or creating minimal info if no GitHub data available
const prInfo = {
number: prNumber,
url: `https://github.com/pulls/${prNumber}`,
title: `PR #${prNumber}`,
state: validatePRState('OPEN'),
createdAt: new Date().toISOString(),
};
await updateWorktreePRInfo(effectiveProjectPath, branchName, prInfo);
logger.info(`Updated PR tracking to #${prNumber} for branch ${branchName} (no GitHub data)`);
res.json({
success: true,
result: {
branch: branchName,
prInfo,
ghCliUnavailable: !ghCliAvailable,
},
});
} catch (error) {
logError(error, 'Update PR number failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -38,7 +38,7 @@ export type {
const logger = createLogger('AgentExecutor');
const DEFAULT_MAX_TURNS = 1000;
const DEFAULT_MAX_TURNS = 10000;
export class AgentExecutor {
private static readonly WRITE_DEBOUNCE_MS = 500;

View File

@@ -487,7 +487,19 @@ export class AgentService {
Object.keys(customSubagents).length > 0;
// Base tools that match the provider's default set
const baseTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
const baseTools = [
'Read',
'Write',
'Edit',
'MultiEdit',
'Glob',
'Grep',
'LS',
'Bash',
'WebSearch',
'WebFetch',
'TodoWrite',
];
if (allowedTools) {
allowedTools = [...allowedTools]; // Create a copy to avoid mutating SDK options
@@ -572,6 +584,7 @@ export class AgentService {
let currentAssistantMessage: Message | null = null;
let responseText = '';
const toolUses: Array<{ name: string; input: unknown }> = [];
const toolNamesById = new Map<string, string>();
for await (const msg of stream) {
// Capture SDK session ID from any message and persist it.
@@ -616,11 +629,50 @@ export class AgentService {
input: block.input,
};
toolUses.push(toolUse);
if (block.tool_use_id) {
toolNamesById.set(block.tool_use_id, toolUse.name);
}
this.emitAgentEvent(sessionId, {
type: 'tool_use',
tool: toolUse,
});
} else if (block.type === 'tool_result') {
const toolUseId = block.tool_use_id;
const toolName = toolUseId ? toolNamesById.get(toolUseId) : undefined;
// Normalize block.content to a string for the emitted event
const rawContent: unknown = block.content;
let contentString: string;
if (typeof rawContent === 'string') {
contentString = rawContent;
} else if (Array.isArray(rawContent)) {
// Extract text from content blocks (TextBlock, ImageBlock, etc.)
contentString = rawContent
.map((part: { text?: string; type?: string }) => {
if (typeof part === 'string') return part;
if (part.text) return part.text;
// For non-text blocks (e.g., images), represent as type indicator
if (part.type) return `[${part.type}]`;
return JSON.stringify(part);
})
.join('\n');
} else if (rawContent !== undefined && rawContent !== null) {
contentString = JSON.stringify(rawContent);
} else {
contentString = '';
}
this.emitAgentEvent(sessionId, {
type: 'tool_result',
tool: {
name: toolName || 'unknown',
input: {
toolUseId,
content: contentString,
},
},
});
}
}
}

View File

@@ -767,6 +767,7 @@ export class AutoModeServiceFacade {
featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
executionMode: 'auto',
passes: allPassed,
message: allPassed
? 'All verification checks passed'
@@ -829,6 +830,7 @@ export class AutoModeServiceFacade {
featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
executionMode: 'auto',
passes: true,
message: `Changes committed: ${hash.trim().substring(0, 8)}`,
projectPath: this.projectPath,

View File

@@ -60,6 +60,7 @@ interface AutoModeEventPayload {
featureId?: string;
featureName?: string;
passes?: boolean;
executionMode?: 'auto' | 'manual';
message?: string;
error?: string;
errorType?: string;
@@ -99,6 +100,18 @@ function isFeatureStatusChangedPayload(
);
}
/**
* Feature completed event payload structure
*/
interface FeatureCompletedPayload {
featureId: string;
featureName?: string;
projectPath: string;
passes?: boolean;
message?: string;
executionMode?: 'auto' | 'manual';
}
/**
* Event Hook Service
*
@@ -150,6 +163,8 @@ export class EventHookService {
this.handleAutoModeEvent(payload as AutoModeEventPayload);
} else if (type === 'feature:created') {
this.handleFeatureCreatedEvent(payload as FeatureCreatedPayload);
} else if (type === 'feature:completed') {
this.handleFeatureCompletedEvent(payload as FeatureCompletedPayload);
}
});
@@ -187,6 +202,9 @@ export class EventHookService {
switch (payload.type) {
case 'auto_mode_feature_complete':
// Only map explicit auto-mode completion events.
// Manual feature completions are emitted as feature:completed.
if (payload.executionMode !== 'auto') return;
trigger = payload.passes ? 'feature_success' : 'feature_error';
// Track this feature so feature_status_changed doesn't double-fire hooks
if (payload.featureId) {
@@ -248,6 +266,46 @@ export class EventHookService {
await this.executeHooksForTrigger(trigger, context, { passes: payload.passes });
}
/**
* Handle feature:completed events and trigger matching hooks
*/
private async handleFeatureCompletedEvent(payload: FeatureCompletedPayload): Promise<void> {
if (!payload.featureId || !payload.projectPath) return;
// Mark as handled to prevent duplicate firing if feature_status_changed also fires
this.markFeatureHandled(payload.featureId);
const passes = payload.passes ?? true;
const trigger: EventHookTrigger = passes ? 'feature_success' : 'feature_error';
// Load feature name if we have featureId but no featureName
let featureName: string | undefined = undefined;
if (payload.projectPath && this.featureLoader) {
try {
const feature = await this.featureLoader.get(payload.projectPath, payload.featureId);
if (feature?.title) {
featureName = feature.title;
}
} catch (error) {
logger.warn(`Failed to load feature ${payload.featureId} for event hook:`, error);
}
}
const isErrorTrigger = trigger === 'feature_error';
const context: HookContext = {
featureId: payload.featureId,
featureName: featureName || payload.featureName,
projectPath: payload.projectPath,
projectName: this.extractProjectName(payload.projectPath),
error: isErrorTrigger ? payload.message : undefined,
errorType: undefined,
timestamp: new Date().toISOString(),
eventType: trigger,
};
await this.executeHooksForTrigger(trigger, context, { passes });
}
/**
* Handle feature:created events and trigger matching hooks
*/

View File

@@ -457,6 +457,7 @@ Please continue from where you left off and complete all remaining tasks. Use th
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
executionMode: 'auto',
passes: true,
message: completionMessage,
projectPath,
@@ -473,6 +474,7 @@ Please continue from where you left off and complete all remaining tasks. Use th
featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
executionMode: 'auto',
passes: false,
message: 'Feature stopped by user',
projectPath,
@@ -502,6 +504,22 @@ Please continue from where you left off and complete all remaining tasks. Use th
async stopFeature(featureId: string): Promise<boolean> {
const running = this.concurrencyManager.getRunningFeature(featureId);
if (!running) return false;
const { projectPath } = running;
// Immediately update feature status to 'interrupted' so the UI reflects
// the stop right away. CLI-based providers can take seconds to terminate
// their subprocess after the abort signal fires, leaving the feature stuck
// in 'in_progress' on the Kanban board until the executeFeature catch block
// eventually runs. By persisting and emitting the status change here, the
// board updates immediately regardless of how long the subprocess takes to stop.
try {
await this.updateFeatureStatusFn(projectPath, featureId, 'interrupted');
} catch (err) {
// Non-fatal: the abort still proceeds and executeFeature's catch block
// will attempt the same update once the subprocess terminates.
logger.warn(`stopFeature: failed to immediately update status for ${featureId}:`, err);
}
running.abortController.abort();
this.releaseRunningFeature(featureId, { force: true });
return true;

View File

@@ -243,6 +243,7 @@ export class PipelineOrchestrator {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
executionMode: 'auto',
passes: true,
message: 'Pipeline step no longer exists',
projectPath,
@@ -292,6 +293,7 @@ export class PipelineOrchestrator {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
executionMode: 'auto',
passes: true,
message: 'Pipeline completed (remaining steps excluded)',
projectPath,
@@ -317,6 +319,7 @@ export class PipelineOrchestrator {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
executionMode: 'auto',
passes: true,
message: 'Pipeline completed (all steps excluded)',
projectPath,
@@ -401,6 +404,7 @@ export class PipelineOrchestrator {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
executionMode: 'auto',
passes: true,
message: 'Pipeline resumed successfully',
projectPath,
@@ -414,6 +418,7 @@ export class PipelineOrchestrator {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
executionMode: 'auto',
passes: false,
message: 'Pipeline stopped by user',
projectPath,
@@ -580,6 +585,7 @@ export class PipelineOrchestrator {
featureId,
featureName: feature.title,
branchName,
executionMode: 'auto',
passes: true,
message: 'Pipeline completed and merged',
projectPath,

View File

@@ -8,13 +8,10 @@
import path from 'path';
import fs from 'fs/promises';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { execGitCommand } from '@automaker/git-utils';
import type { EventEmitter } from '../lib/events.js';
import type { SettingsService } from './settings-service.js';
const execFileAsync = promisify(execFile);
/**
* Get the list of remote names that have a branch matching the given branch name.
*
@@ -41,10 +38,9 @@ export async function getRemotesWithBranch(
}
try {
const { stdout: remoteRefsOutput } = await execFileAsync(
'git',
const remoteRefsOutput = await execGitCommand(
['for-each-ref', '--format=%(refname:short)', `refs/remotes/*/${currentBranch}`],
{ cwd: worktreePath }
worktreePath
);
if (!remoteRefsOutput.trim()) {

View File

@@ -168,7 +168,7 @@ describe('enhancement-prompts.ts', () => {
const prompt = buildUserPrompt('improve', testText);
expect(prompt).toContain('Example 1:');
expect(prompt).toContain(testText);
expect(prompt).toContain('Now, please enhance the following task description:');
expect(prompt).toContain('Please enhance the following task description:');
});
it('should build prompt without examples when includeExamples is false', () => {

View File

@@ -9,6 +9,18 @@ describe('gemini-provider.ts', () => {
});
describe('buildCliArgs', () => {
it('should include --prompt with empty string to force headless mode', () => {
const args = provider.buildCliArgs({
prompt: 'Hello from Gemini',
model: '2.5-flash',
cwd: '/tmp/project',
});
const promptIndex = args.indexOf('--prompt');
expect(promptIndex).toBeGreaterThan(-1);
expect(args[promptIndex + 1]).toBe('');
});
it('should include --resume when sdkSessionId is provided', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
@@ -31,5 +43,77 @@ describe('gemini-provider.ts', () => {
expect(args).not.toContain('--resume');
});
it('should include --sandbox false for faster execution', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: '2.5-flash',
cwd: '/tmp/project',
});
const sandboxIndex = args.indexOf('--sandbox');
expect(sandboxIndex).toBeGreaterThan(-1);
expect(args[sandboxIndex + 1]).toBe('false');
});
it('should include --approval-mode yolo for non-interactive use', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: '2.5-flash',
cwd: '/tmp/project',
});
const approvalIndex = args.indexOf('--approval-mode');
expect(approvalIndex).toBeGreaterThan(-1);
expect(args[approvalIndex + 1]).toBe('yolo');
});
it('should include --output-format stream-json', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: '2.5-flash',
cwd: '/tmp/project',
});
const formatIndex = args.indexOf('--output-format');
expect(formatIndex).toBeGreaterThan(-1);
expect(args[formatIndex + 1]).toBe('stream-json');
});
it('should include --include-directories with cwd', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: '2.5-flash',
cwd: '/tmp/my-project',
});
const dirIndex = args.indexOf('--include-directories');
expect(dirIndex).toBeGreaterThan(-1);
expect(args[dirIndex + 1]).toBe('/tmp/my-project');
});
it('should add gemini- prefix to bare model names', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: '2.5-flash',
cwd: '/tmp/project',
});
const modelIndex = args.indexOf('--model');
expect(modelIndex).toBeGreaterThan(-1);
expect(args[modelIndex + 1]).toBe('gemini-2.5-flash');
});
it('should not double-prefix model names that already have gemini-', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: 'gemini-2.5-pro',
cwd: '/tmp/project',
});
const modelIndex = args.indexOf('--model');
expect(modelIndex).toBeGreaterThan(-1);
expect(args[modelIndex + 1]).toBe('gemini-2.5-pro');
});
});
});

View File

@@ -188,6 +188,125 @@ describe('agent-service.ts', () => {
expect(mockEvents.emit).toHaveBeenCalled();
});
it('should emit tool_result events from provider stream', async () => {
const mockProvider = {
getName: () => 'gemini',
executeQuery: async function* () {
yield {
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
name: 'Read',
tool_use_id: 'tool-1',
input: { file_path: 'README.md' },
},
],
},
};
yield {
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-1',
content: 'File contents here',
},
],
},
};
yield {
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
content: 'Hello',
hasImages: false,
});
await service.sendMessage({
sessionId: 'session-1',
message: 'Hello',
});
expect(mockEvents.emit).toHaveBeenCalledWith(
'agent:stream',
expect.objectContaining({
sessionId: 'session-1',
type: 'tool_result',
tool: {
name: 'Read',
input: {
toolUseId: 'tool-1',
content: 'File contents here',
},
},
})
);
});
it('should emit tool_result with unknown tool name for unregistered tool_use_id', async () => {
const mockProvider = {
getName: () => 'gemini',
executeQuery: async function* () {
// Yield tool_result WITHOUT a preceding tool_use (unregistered tool_use_id)
yield {
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_result',
tool_use_id: 'unregistered-id',
content: 'Some result content',
},
],
},
};
yield {
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
content: 'Hello',
hasImages: false,
});
await service.sendMessage({
sessionId: 'session-1',
message: 'Hello',
});
expect(mockEvents.emit).toHaveBeenCalledWith(
'agent:stream',
expect.objectContaining({
sessionId: 'session-1',
type: 'tool_result',
tool: {
name: 'unknown',
input: {
toolUseId: 'unregistered-id',
content: 'Some result content',
},
},
})
);
});
it('should handle images in message', async () => {
const mockProvider = {
getName: () => 'claude',

View File

@@ -116,6 +116,7 @@ describe('EventHookService', () => {
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
@@ -144,6 +145,7 @@ describe('EventHookService', () => {
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: false,
@@ -171,6 +173,7 @@ describe('EventHookService', () => {
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
@@ -200,6 +203,7 @@ describe('EventHookService', () => {
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: false,
@@ -217,6 +221,55 @@ describe('EventHookService', () => {
// Error field should be populated for error triggers
expect(storeCall.error).toBe('Feature stopped by user');
});
it('should ignore feature complete events without explicit auto execution mode', async () => {
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
featureId: 'feat-1',
featureName: 'Manual Feature',
passes: true,
message: 'Manually verified',
projectPath: '/test/project',
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEventHistoryService.storeEvent).not.toHaveBeenCalled();
});
});
describe('event mapping - feature:completed', () => {
it('should map manual completion to feature_success', async () => {
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('feature:completed', {
featureId: 'feat-1',
featureName: 'Manual Feature',
projectPath: '/test/project',
passes: true,
executionMode: 'manual',
});
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);
});
});
describe('event mapping - auto_mode_error', () => {
@@ -400,6 +453,7 @@ describe('EventHookService', () => {
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
@@ -420,7 +474,6 @@ describe('EventHookService', () => {
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',
@@ -444,6 +497,7 @@ describe('EventHookService', () => {
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
@@ -480,6 +534,7 @@ describe('EventHookService', () => {
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
passes: true,
message: 'Done',
@@ -507,6 +562,7 @@ describe('EventHookService', () => {
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Fallback Name',
passes: true,
@@ -617,6 +673,7 @@ describe('EventHookService', () => {
// First: auto_mode_feature_complete fires (auto-mode path)
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Auto Feature',
passes: true,
@@ -690,6 +747,7 @@ describe('EventHookService', () => {
// Auto-mode completion for feat-1
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
passes: true,
message: 'Done',
@@ -757,6 +815,7 @@ describe('EventHookService', () => {
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
passes: false,
message: 'Feature stopped by user',

View File

@@ -1269,6 +1269,34 @@ describe('execution-service.ts', () => {
expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1', { force: true });
});
it('immediately updates feature status to interrupted before subprocess terminates', async () => {
const runningFeature = createRunningFeature('feature-1');
vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(runningFeature);
await service.stopFeature('feature-1');
// Should update to 'interrupted' immediately so the UI reflects the stop
// without waiting for the CLI subprocess to fully terminate
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
'/test/project',
'feature-1',
'interrupted'
);
});
it('still aborts and releases even if status update fails', async () => {
const runningFeature = createRunningFeature('feature-1');
const abortSpy = vi.spyOn(runningFeature.abortController, 'abort');
vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(runningFeature);
vi.mocked(mockUpdateFeatureStatusFn).mockRejectedValueOnce(new Error('disk error'));
const result = await service.stopFeature('feature-1');
expect(result).toBe(true);
expect(abortSpy).toHaveBeenCalled();
expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1', { force: true });
});
});
describe('worktree resolution', () => {

View File

@@ -740,8 +740,11 @@ describe('settings-service.ts', () => {
// Legacy fields should be migrated to phaseModels with canonical IDs
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-haiku' });
expect(settings.phaseModels.validationModel).toEqual({ model: 'claude-opus' });
// Other fields should use defaults (canonical IDs)
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' });
// Other fields should use defaults (canonical IDs) - specGenerationModel includes thinkingLevel from DEFAULT_PHASE_MODELS
expect(settings.phaseModels.specGenerationModel).toEqual({
model: 'claude-opus',
thinkingLevel: 'adaptive',
});
});
it('should use default phase models when none are configured', async () => {
@@ -755,10 +758,13 @@ describe('settings-service.ts', () => {
const settings = await settingsService.getGlobalSettings();
// Should use DEFAULT_PHASE_MODELS (with canonical IDs)
// Should use DEFAULT_PHASE_MODELS (with canonical IDs) - specGenerationModel includes thinkingLevel from DEFAULT_PHASE_MODELS
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' });
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'claude-haiku' });
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' });
expect(settings.phaseModels.specGenerationModel).toEqual({
model: 'claude-opus',
thinkingLevel: 'adaptive',
});
});
it('should deep merge phaseModels on update', async () => {

View File

@@ -21,6 +21,8 @@ server {
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;
proxy_buffering off;
proxy_read_timeout 3600s;
}
location / {

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/ui",
"version": "0.13.0",
"version": "0.15.0",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker",
"repository": {
@@ -56,6 +56,7 @@
"@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/merge": "^6.12.0",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "6.1.3",

View File

@@ -19,6 +19,7 @@ export default defineConfig({
baseURL: `http://localhost:${port}`,
trace: 'on-failure',
screenshot: 'only-on-failure',
serviceWorkers: 'block',
},
// Global setup - authenticate before each test
globalSetup: require.resolve('./tests/global-setup.ts'),

View File

@@ -310,6 +310,8 @@ export function SessionManager({
});
if (activeSessionsList.length > 0) {
onSelectSession(activeSessionsList[0].id);
} else {
onSelectSession(null);
}
}
}

View File

@@ -0,0 +1,220 @@
/**
* CodeMirror-based unified diff viewer.
*
* Uses @codemirror/merge's `unifiedMergeView` extension to display a
* syntax-highlighted inline diff between the original and modified file content.
* The viewer is read-only and collapses unchanged regions.
*/
import { useMemo, useRef, useEffect } from 'react';
import { EditorView } from '@codemirror/view';
import { EditorState, type Extension } from '@codemirror/state';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import { unifiedMergeView } from '@codemirror/merge';
import { getLanguageExtension } from '@/lib/codemirror-languages';
import { reconstructFilesFromDiff } from '@/lib/diff-utils';
import { cn } from '@/lib/utils';
// Reuse the same syntax highlighting from the code editor
const syntaxColors = HighlightStyle.define([
{ tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
{ tag: t.number, color: 'var(--chart-3, oklch(0.7 0.15 150))' },
{ tag: t.bool, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
{ tag: t.null, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
{ tag: t.propertyName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
{ tag: t.variableName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
{ tag: t.function(t.variableName), color: 'var(--primary)' },
{ tag: t.typeName, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
{ tag: t.className, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
{ tag: t.definition(t.variableName), color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
{ tag: t.operator, color: 'var(--muted-foreground)' },
{ tag: t.bracket, color: 'var(--muted-foreground)' },
{ tag: t.punctuation, color: 'var(--muted-foreground)' },
{ tag: t.attributeName, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
{ tag: t.attributeValue, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
{ tag: t.tagName, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
{ tag: t.heading, color: 'var(--foreground)', fontWeight: 'bold' },
{ tag: t.emphasis, fontStyle: 'italic' },
{ tag: t.strong, fontWeight: 'bold' },
{ tag: t.link, color: 'var(--primary)', textDecoration: 'underline' },
{ tag: t.content, color: 'var(--foreground)' },
{ tag: t.regexp, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
{ tag: t.meta, color: 'var(--muted-foreground)' },
]);
const diffViewTheme = EditorView.theme(
{
'&': {
fontSize: '12px',
fontFamily:
'var(--font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace)',
backgroundColor: 'var(--background)',
color: 'var(--foreground)',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily:
'var(--font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace)',
},
'.cm-content': {
padding: '0',
minHeight: 'auto',
},
'.cm-line': {
padding: '0 0.5rem',
},
'&.cm-focused': {
outline: 'none',
},
'.cm-gutters': {
backgroundColor: 'transparent',
color: 'var(--muted-foreground)',
border: 'none',
borderRight: '1px solid var(--border)',
paddingRight: '0.25rem',
},
'.cm-lineNumbers .cm-gutterElement': {
minWidth: '3rem',
textAlign: 'right',
paddingRight: '0.5rem',
fontSize: '11px',
},
// --- GitHub-style diff colors (dark mode) ---
// Added/changed lines: green background
'&.cm-merge-b .cm-changedLine': {
backgroundColor: 'rgba(46, 160, 67, 0.15)',
},
// Highlighted text within added/changed lines: stronger green
'&.cm-merge-b .cm-changedText': {
background: 'rgba(46, 160, 67, 0.4)',
},
// Deleted chunk container: red background
'.cm-deletedChunk': {
backgroundColor: 'rgba(248, 81, 73, 0.1)',
paddingLeft: '6px',
},
// Individual deleted lines within the chunk
'.cm-deletedChunk .cm-deletedLine': {
backgroundColor: 'rgba(248, 81, 73, 0.15)',
},
// Highlighted text within deleted lines: stronger red
'.cm-deletedChunk .cm-deletedText': {
background: 'rgba(248, 81, 73, 0.4)',
},
// Remove strikethrough from deleted text (GitHub doesn't use it)
'.cm-insertedLine, .cm-deletedLine, .cm-deletedLine del': {
textDecoration: 'none',
},
// Gutter markers for changed lines (green bar)
'&.cm-merge-b .cm-changedLineGutter': {
background: '#3fb950',
},
// Gutter markers for deleted lines (red bar)
'.cm-deletedLineGutter': {
background: '#f85149',
},
// Collapse button styling
'.cm-collapsedLines': {
color: 'var(--muted-foreground)',
backgroundColor: 'var(--muted)',
borderTop: '1px solid var(--border)',
borderBottom: '1px solid var(--border)',
cursor: 'pointer',
padding: '2px 8px',
fontSize: '11px',
},
// Selection styling
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
},
},
{ dark: true }
);
interface CodeMirrorDiffViewProps {
/** The unified diff text for a single file */
fileDiff: string;
/** File path for language detection */
filePath: string;
/** Max height of the diff view (CSS value) */
maxHeight?: string;
className?: string;
}
export function CodeMirrorDiffView({
fileDiff,
filePath,
maxHeight = '400px',
className,
}: CodeMirrorDiffViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const { oldContent, newContent } = useMemo(() => reconstructFilesFromDiff(fileDiff), [fileDiff]);
const extensions = useMemo(() => {
const exts: Extension[] = [
EditorView.darkTheme.of(true),
diffViewTheme,
syntaxHighlighting(syntaxColors),
EditorView.editable.of(false),
EditorState.readOnly.of(true),
EditorView.lineWrapping,
unifiedMergeView({
original: oldContent,
highlightChanges: true,
gutter: true,
syntaxHighlightDeletions: true,
mergeControls: false,
collapseUnchanged: { margin: 3, minSize: 4 },
}),
];
const langExt = getLanguageExtension(filePath);
if (langExt) {
exts.push(langExt);
}
return exts;
}, [oldContent, filePath]);
useEffect(() => {
if (!containerRef.current) return;
// Clean up previous view
if (viewRef.current) {
viewRef.current.destroy();
viewRef.current = null;
}
const state = EditorState.create({
doc: newContent,
extensions,
});
const view = new EditorView({
state,
parent: containerRef.current,
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
}, [newContent, extensions]);
return (
<div ref={containerRef} className={cn('overflow-auto', className)} style={{ maxHeight }} />
);
}

View File

@@ -17,10 +17,13 @@ import {
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
import { CodeMirrorDiffView } from '@/components/ui/codemirror-diff-view';
import { Button } from './button';
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { parseDiff, splitDiffByFile } from '@/lib/diff-utils';
import type { ParsedFileDiff } from '@/lib/diff-utils';
import type { FileStatus, MergeStateInfo } from '@/types/electron';
interface GitDiffPanelProps {
@@ -37,23 +40,6 @@ interface GitDiffPanelProps {
worktreePath?: string;
}
interface ParsedDiffHunk {
header: string;
lines: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}[];
}
interface ParsedFileDiff {
filePath: string;
hunks: ParsedDiffHunk[];
isNew?: boolean;
isDeleted?: boolean;
isRenamed?: boolean;
}
const getFileIcon = (status: string) => {
switch (status) {
case 'A':
@@ -129,174 +115,6 @@ function getStagingState(file: FileStatus): 'staged' | 'unstaged' | 'partial' {
return 'unstaged';
}
/**
* Parse unified diff format into structured data
*/
function parseDiff(diffText: string): ParsedFileDiff[] {
if (!diffText) return [];
const files: ParsedFileDiff[] = [];
const lines = diffText.split('\n');
let currentFile: ParsedFileDiff | null = null;
let currentHunk: ParsedDiffHunk | null = null;
let oldLineNum = 0;
let newLineNum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// New file diff
if (line.startsWith('diff --git')) {
if (currentFile) {
if (currentHunk) {
currentFile.hunks.push(currentHunk);
}
files.push(currentFile);
}
// Extract file path from diff header
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : 'unknown',
hunks: [],
};
currentHunk = null;
continue;
}
// New file indicator
if (line.startsWith('new file mode')) {
if (currentFile) currentFile.isNew = true;
continue;
}
// Deleted file indicator
if (line.startsWith('deleted file mode')) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
// Renamed file indicator
if (line.startsWith('rename from') || line.startsWith('rename to')) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
// Skip index, ---/+++ lines
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
continue;
}
// Hunk header
if (line.startsWith('@@')) {
if (currentHunk && currentFile) {
currentFile.hunks.push(currentHunk);
}
// Parse line numbers from @@ -old,count +new,count @@
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
currentHunk = {
header: line,
lines: [{ type: 'header', content: line }],
};
continue;
}
// Diff content lines
if (currentHunk) {
if (line.startsWith('+')) {
currentHunk.lines.push({
type: 'addition',
content: line.substring(1),
lineNumber: { new: newLineNum },
});
newLineNum++;
} else if (line.startsWith('-')) {
currentHunk.lines.push({
type: 'deletion',
content: line.substring(1),
lineNumber: { old: oldLineNum },
});
oldLineNum++;
} else if (line.startsWith(' ') || line === '') {
currentHunk.lines.push({
type: 'context',
content: line.substring(1) || '',
lineNumber: { old: oldLineNum, new: newLineNum },
});
oldLineNum++;
newLineNum++;
}
}
}
// Don't forget the last file and hunk
if (currentFile) {
if (currentHunk) {
currentFile.hunks.push(currentHunk);
}
files.push(currentFile);
}
return files;
}
function DiffLine({
type,
content,
lineNumber,
}: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}) {
const bgClass = {
context: 'bg-transparent',
addition: 'bg-green-500/10',
deletion: 'bg-red-500/10',
header: 'bg-blue-500/10',
};
const textClass = {
context: 'text-foreground-secondary',
addition: 'text-green-400',
deletion: 'text-red-400',
header: 'text-blue-400',
};
const prefix = {
context: ' ',
addition: '+',
deletion: '-',
header: '',
};
if (type === 'header') {
return (
<div className={cn('px-2 py-1 font-mono text-xs', bgClass[type], textClass[type])}>
{content}
</div>
);
}
return (
<div className={cn('flex font-mono text-xs', bgClass[type])}>
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
{lineNumber?.old ?? ''}
</span>
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
{lineNumber?.new ?? ''}
</span>
<span className={cn('w-4 flex-shrink-0 text-center select-none', textClass[type])}>
{prefix[type]}
</span>
<span className={cn('flex-1 px-2 whitespace-pre-wrap break-all', textClass[type])}>
{content || '\u00A0'}
</span>
</div>
);
}
function StagingBadge({ state }: { state: 'staged' | 'unstaged' | 'partial' }) {
if (state === 'staged') {
return (
@@ -401,6 +219,7 @@ function MergeStateBanner({ mergeState }: { mergeState: MergeStateInfo }) {
function FileDiffSection({
fileDiff,
rawDiff,
isExpanded,
onToggle,
fileStatus,
@@ -410,6 +229,8 @@ function FileDiffSection({
isStagingFile,
}: {
fileDiff: ParsedFileDiff;
/** Raw unified diff string for this file, used by CodeMirror merge view */
rawDiff?: string;
isExpanded: boolean;
onToggle: () => void;
fileStatus?: FileStatus;
@@ -418,14 +239,8 @@ function FileDiffSection({
onUnstage?: (filePath: string) => void;
isStagingFile?: boolean;
}) {
const additions = fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length,
0
);
const deletions = fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'deletion').length,
0
);
const additions = fileDiff.additions;
const deletions = fileDiff.deletions;
const stagingState = fileStatus ? getStagingState(fileStatus) : undefined;
@@ -521,20 +336,9 @@ function FileDiffSection({
)}
</div>
</div>
{isExpanded && (
<div className="bg-background border-t border-border max-h-[400px] overflow-y-auto scrollbar-visible">
{fileDiff.hunks.map((hunk, hunkIndex) => (
<div key={hunkIndex} className="border-b border-border-glass last:border-b-0">
{hunk.lines.map((line, lineIndex) => (
<DiffLine
key={lineIndex}
type={line.type}
content={line.content}
lineNumber={line.lineNumber}
/>
))}
</div>
))}
{isExpanded && rawDiff && (
<div className="bg-background border-t border-border">
<CodeMirrorDiffView fileDiff={rawDiff} filePath={fileDiff.filePath} maxHeight="400px" />
</div>
)}
</div>
@@ -619,6 +423,16 @@ export function GitDiffPanel({
return diffs;
}, [diffContent, mergeState, fileStatusMap]);
// Build a map from file path to raw diff string for CodeMirror merge view
const fileDiffMap = useMemo(() => {
const map = new Map<string, string>();
const perFileDiffs = splitDiffByFile(diffContent);
for (const entry of perFileDiffs) {
map.set(entry.filePath, entry.diff);
}
return map;
}, [diffContent]);
const toggleFile = (filePath: string) => {
setExpandedFiles((prev) => {
const next = new Set(prev);
@@ -822,25 +636,9 @@ export function GitDiffPanel({
return { staged, partial, unstaged, total: files.length };
}, [enableStaging, files]);
// Total stats
const totalAdditions = parsedDiffs.reduce(
(acc, file) =>
acc +
file.hunks.reduce(
(hAcc, hunk) => hAcc + hunk.lines.filter((l) => l.type === 'addition').length,
0
),
0
);
const totalDeletions = parsedDiffs.reduce(
(acc, file) =>
acc +
file.hunks.reduce(
(hAcc, hunk) => hAcc + hunk.lines.filter((l) => l.type === 'deletion').length,
0
),
0
);
// Total stats (pre-computed by shared parseDiff)
const totalAdditions = parsedDiffs.reduce((acc, file) => acc + file.additions, 0);
const totalDeletions = parsedDiffs.reduce((acc, file) => acc + file.deletions, 0);
return (
<div
@@ -1053,6 +851,7 @@ export function GitDiffPanel({
<FileDiffSection
key={fileDiff.filePath}
fileDiff={fileDiff}
rawDiff={fileDiffMap.get(fileDiff.filePath)}
isExpanded={expandedFiles.has(fileDiff.filePath)}
onToggle={() => toggleFile(fileDiff.filePath)}
fileStatus={fileStatusMap.get(fileDiff.filePath)}

View File

@@ -63,6 +63,7 @@ import {
PlanApprovalDialog,
MergeRebaseDialog,
QuickAddDialog,
ChangePRNumberDialog,
} from './board-view/dialogs';
import type { DependencyLinkType } from './board-view/dialogs';
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
@@ -198,6 +199,7 @@ export function BoardView() {
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false);
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [showChangePRNumberDialog, setShowChangePRNumberDialog] = useState(false);
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
const [showMergeRebaseDialog, setShowMergeRebaseDialog] = useState(false);
const [showPRCommentDialog, setShowPRCommentDialog] = useState(false);
@@ -1030,7 +1032,8 @@ export function BoardView() {
skipTests: defaultSkipTests,
model: resolveModelString(modelEntry.model) as ModelAlias,
thinkingLevel: (modelEntry.thinkingLevel as ThinkingLevel) || 'none',
branchName: addFeatureUseSelectedWorktreeBranch ? selectedWorktreeBranch : '',
reasoningEffort: modelEntry.reasoningEffort,
branchName: addFeatureUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined,
priority: 2,
planningMode: useAppStore.getState().defaultPlanningMode ?? 'skip',
requirePlanApproval: useAppStore.getState().defaultRequirePlanApproval ?? false,
@@ -1064,7 +1067,8 @@ export function BoardView() {
skipTests: defaultSkipTests,
model: resolveModelString(modelEntry.model) as ModelAlias,
thinkingLevel: (modelEntry.thinkingLevel as ThinkingLevel) || 'none',
branchName: addFeatureUseSelectedWorktreeBranch ? selectedWorktreeBranch : '',
reasoningEffort: modelEntry.reasoningEffort,
branchName: addFeatureUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined,
priority: 2,
planningMode: useAppStore.getState().defaultPlanningMode ?? 'skip',
requirePlanApproval: useAppStore.getState().defaultRequirePlanApproval ?? false,
@@ -1691,6 +1695,10 @@ export function BoardView() {
setSelectedWorktreeForAction(worktree);
setShowCreatePRDialog(true);
}}
onChangePRNumber={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowChangePRNumberDialog(true);
}}
onCreateBranch={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreateBranchDialog(true);
@@ -2229,6 +2237,18 @@ export function BoardView() {
}}
/>
{/* Change PR Number Dialog */}
<ChangePRNumberDialog
open={showChangePRNumberDialog}
onOpenChange={setShowChangePRNumberDialog}
worktree={selectedWorktreeForAction}
projectPath={currentProject?.path || null}
onChanged={() => {
setWorktreeRefreshKey((k) => k + 1);
setSelectedWorktreeForAction(null);
}}
/>
{/* Create Branch Dialog */}
<CreateBranchDialog
open={showCreateBranchDialog}

View File

@@ -28,7 +28,11 @@ import { cn } from '@/lib/utils';
import { modelSupportsThinking } from '@/lib/utils';
import { useAppStore, ThinkingLevel, FeatureImage, PlanningMode, Feature } from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry, AgentModel } from '@automaker/types';
import { supportsReasoningEffort, isAdaptiveThinkingModel } from '@automaker/types';
import {
supportsReasoningEffort,
isAdaptiveThinkingModel,
getThinkingLevelsForModel,
} from '@automaker/types';
import {
PrioritySelector,
WorkModeSelector,
@@ -211,6 +215,7 @@ export function AddFeatureDialog({
defaultRequirePlanApproval,
useWorktrees,
defaultFeatureModel,
defaultThinkingLevel,
currentProject,
} = useAppStore();
@@ -240,7 +245,22 @@ export function AddFeatureDialog({
);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setModelEntry(effectiveDefaultFeatureModel);
// Apply defaultThinkingLevel from settings to the model entry.
// This ensures the "Quick-Select Defaults" thinking level setting is respected
// even when the user doesn't change the model in the dropdown.
const modelId =
typeof effectiveDefaultFeatureModel.model === 'string'
? effectiveDefaultFeatureModel.model
: '';
const availableLevels = getThinkingLevelsForModel(modelId);
const effectiveThinkingLevel = availableLevels.includes(defaultThinkingLevel)
? defaultThinkingLevel
: availableLevels[0];
setModelEntry({
...effectiveDefaultFeatureModel,
thinkingLevel: effectiveThinkingLevel,
});
// Initialize description history (empty for new feature)
setDescriptionHistory([]);
@@ -269,6 +289,7 @@ export function AddFeatureDialog({
defaultPlanningMode,
defaultRequirePlanApproval,
effectiveDefaultFeatureModel,
defaultThinkingLevel,
useWorktrees,
selectedNonMainWorktreeBranch,
forceCurrentBranchMode,
@@ -394,7 +415,19 @@ export function AddFeatureDialog({
// When a non-main worktree is selected, use its branch name for custom mode
setBranchName(selectedNonMainWorktreeBranch || '');
setPriority(2);
setModelEntry(effectiveDefaultFeatureModel);
// Apply defaultThinkingLevel to the model entry (same logic as dialog open)
const resetModelId =
typeof effectiveDefaultFeatureModel.model === 'string'
? effectiveDefaultFeatureModel.model
: '';
const resetAvailableLevels = getThinkingLevelsForModel(resetModelId);
const resetThinkingLevel = resetAvailableLevels.includes(defaultThinkingLevel)
? defaultThinkingLevel
: resetAvailableLevels[0];
setModelEntry({
...effectiveDefaultFeatureModel,
thinkingLevel: resetThinkingLevel,
});
setWorkMode(
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
);

View File

@@ -0,0 +1,197 @@
import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { GitPullRequest } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
pr?: {
number: number;
url: string;
title: string;
state: string;
createdAt: string;
};
}
interface ChangePRNumberDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
projectPath: string | null;
onChanged: () => void;
}
export function ChangePRNumberDialog({
open,
onOpenChange,
worktree,
projectPath,
onChanged,
}: ChangePRNumberDialogProps) {
const [prNumberInput, setPrNumberInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Initialize with current PR number when dialog opens
useEffect(() => {
if (open && worktree?.pr?.number) {
setPrNumberInput(String(worktree.pr.number));
} else if (open) {
setPrNumberInput('');
}
setError(null);
}, [open, worktree]);
const handleSubmit = useCallback(async () => {
if (!worktree) return;
const trimmed = prNumberInput.trim();
if (!/^\d+$/.test(trimmed)) {
setError('Please enter a valid positive PR number');
return;
}
const prNumber = Number(trimmed);
if (prNumber <= 0) {
setError('Please enter a valid positive PR number');
return;
}
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api?.worktree?.updatePRNumber) {
setError('Worktree API not available');
return;
}
const result = await api.worktree.updatePRNumber(
worktree.path,
prNumber,
projectPath || undefined
);
if (result.success) {
const prInfo = result.result?.prInfo;
toast.success('PR tracking updated', {
description: prInfo?.title
? `Now tracking PR #${prNumber}: ${prInfo.title}`
: `Now tracking PR #${prNumber}`,
});
onOpenChange(false);
onChanged();
} else {
setError(result.error || 'Failed to update PR number');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update PR number');
} finally {
setIsLoading(false);
}
}, [worktree, prNumberInput, projectPath, onOpenChange, onChanged]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !isLoading) {
e.preventDefault();
handleSubmit();
}
},
[isLoading, handleSubmit]
);
if (!worktree) return null;
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isLoading) {
onOpenChange(isOpen);
}
}}
>
<DialogContent className="sm:max-w-[400px]" onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitPullRequest className="w-5 h-5" />
Change Tracked PR Number
</DialogTitle>
<DialogDescription>
Update which pull request number is tracked for{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>.
{worktree.pr && (
<span className="block mt-1 text-xs">
Currently tracking PR #{worktree.pr.number}
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="py-2 space-y-3">
<div className="space-y-2">
<Label htmlFor="pr-number">Pull Request Number</Label>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">#</span>
<Input
id="pr-number"
type="text"
inputMode="numeric"
placeholder="e.g. 42"
value={prNumberInput}
onChange={(e) => {
setPrNumberInput(e.target.value);
setError(null);
}}
disabled={isLoading}
autoFocus
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
Enter the GitHub PR number to associate with this worktree. The PR info will be
fetched from GitHub if available.
</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={isLoading || !prNumberInput.trim()}>
{isLoading ? (
<>
<Spinner size="xs" className="mr-2" />
Updating...
</>
) : (
<>
<GitPullRequest className="w-4 h-4 mr-2" />
Update PR
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -27,6 +27,7 @@ import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { useWorktreeBranches } from '@/hooks/queries';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import { resolveModelString } from '@automaker/model-resolver';
interface RemoteInfo {
name: string;
@@ -313,7 +314,7 @@ export function CreatePRDialog({
const result = await api.worktree.generatePRDescription(
worktree.path,
branchNameForApi,
prDescriptionModelOverride.effectiveModel,
resolveModelString(prDescriptionModelOverride.effectiveModel),
prDescriptionModelOverride.effectiveModelEntry.thinkingLevel,
prDescriptionModelOverride.effectiveModelEntry.providerId
);
@@ -501,7 +502,7 @@ export function CreatePRDialog({
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[550px] flex flex-col">
<DialogContent className="sm:max-w-[550px] max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitPullRequest className="w-5 h-5" />

View File

@@ -25,6 +25,7 @@ export { ViewStashesDialog } from './view-stashes-dialog';
export { StashApplyConflictDialog } from './stash-apply-conflict-dialog';
export { CherryPickDialog } from './cherry-pick-dialog';
export { GitPullDialog } from './git-pull-dialog';
export { ChangePRNumberDialog } from './change-pr-number-dialog';
export {
BranchConflictDialog,
type BranchConflictData,

View File

@@ -1,5 +1,6 @@
// @ts-nocheck - feature update logic with partial updates and image/file handling
import { useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
Feature,
FeatureImage,
@@ -18,11 +19,29 @@ import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
import { truncateDescription } from '@/lib/utils';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { createLogger } from '@automaker/utils/logger';
import { queryKeys } from '@/lib/query-keys';
const logger = createLogger('BoardActions');
const MAX_DUPLICATES = 50;
/**
* Removes a running task from all worktrees for a given project.
* Used when stopping features to ensure the task is removed from all worktree contexts,
* not just the current one.
*/
function removeRunningTaskFromAllWorktrees(projectId: string, featureId: string): void {
const store = useAppStore.getState();
const prefix = `${projectId}::`;
for (const [key, worktreeState] of Object.entries(store.autoModeByWorktree)) {
if (key.startsWith(prefix) && worktreeState.runningTasks?.includes(featureId)) {
const branchPart = key.slice(prefix.length);
const branch = branchPart === '__main__' ? null : branchPart;
store.removeRunningTask(projectId, branch, featureId);
}
}
}
interface UseBoardActionsProps {
currentProject: { path: string; id: string } | null;
features: Feature[];
@@ -84,6 +103,8 @@ export function useBoardActions({
onWorktreeAutoSelect,
currentWorktreeBranch,
}: UseBoardActionsProps) {
const queryClient = useQueryClient();
// IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent
// subscribing to the entire store. Bare useAppStore() causes the host component
// (BoardView) to re-render on EVERY store change, which cascades through effects
@@ -503,6 +524,10 @@ export function useBoardActions({
if (isRunning) {
try {
await autoMode.stopFeature(featureId);
// Remove from all worktrees
if (currentProject) {
removeRunningTaskFromAllWorktrees(currentProject.id, featureId);
}
toast.success('Agent stopped', {
description: `Stopped and deleted: ${truncateDescription(feature.description)}`,
});
@@ -533,7 +558,7 @@ export function useBoardActions({
removeFeature(featureId);
await persistFeatureDelete(featureId);
},
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete, currentProject]
);
const handleRunFeature = useCallback(
@@ -999,6 +1024,31 @@ export function useBoardActions({
? 'waiting_approval'
: 'backlog';
// Remove the running task from ALL worktrees for this project.
// autoMode.stopFeature only removes from its scoped worktree (branchName),
// but the feature may be tracked under a different worktree branch.
// Without this, runningAutoTasksAllWorktrees still contains the feature
// and the board column logic forces it into in_progress.
if (currentProject) {
removeRunningTaskFromAllWorktrees(currentProject.id, feature.id);
}
// Optimistically update the React Query features cache so the board
// moves the card immediately. Without this, the card stays in
// "in_progress" until the next poll cycle (30s) because the async
// refetch races with the persistFeatureUpdate write.
if (currentProject) {
queryClient.setQueryData(
queryKeys.features.all(currentProject.path),
(oldFeatures: Feature[] | undefined) => {
if (!oldFeatures) return oldFeatures;
return oldFeatures.map((f) =>
f.id === feature.id ? { ...f, status: targetStatus } : f
);
}
);
}
if (targetStatus !== feature.status) {
moveFeature(feature.id, targetStatus);
// Must await to ensure file is written before user can restart
@@ -1020,7 +1070,7 @@ export function useBoardActions({
});
}
},
[autoMode, moveFeature, persistFeatureUpdate]
[autoMode, moveFeature, persistFeatureUpdate, currentProject, queryClient]
);
const handleStartNextFeatures = useCallback(async () => {
@@ -1137,6 +1187,12 @@ export function useBoardActions({
})
)
);
// Remove from all worktrees
if (currentProject) {
for (const feature of runningVerified) {
removeRunningTaskFromAllWorktrees(currentProject.id, feature.id);
}
}
}
// Use bulk update API for a single server request instead of N individual calls

View File

@@ -6,13 +6,21 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Sparkles, ChevronDown, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
import {
EnhancementMode,
ENHANCEMENT_MODE_LABELS,
REWRITE_MODES,
ADDITIVE_MODES,
isAdditiveMode,
} from './enhancement-constants';
import { useAppStore } from '@/store/app-store';
const logger = createLogger('EnhanceWithAI');
@@ -79,7 +87,10 @@ export function EnhanceWithAI({
if (result?.success && result.enhancedText) {
const originalText = value;
const enhancedText = result.enhancedText;
// For additive modes, prepend the original description above the AI-generated content
const enhancedText = isAdditiveMode(enhancementMode)
? `${originalText.trim()}\n\n${result.enhancedText.trim()}`
: result.enhancedText;
onChange(enhancedText);
// Track in history if callback provided (includes original for restoration)
@@ -119,13 +130,19 @@ export function EnhanceWithAI({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{(Object.entries(ENHANCEMENT_MODE_LABELS) as [EnhancementMode, string][]).map(
([mode, label]) => (
<DropdownMenuItem key={mode} onClick={() => setEnhancementMode(mode)}>
{label}
</DropdownMenuItem>
)
)}
<DropdownMenuLabel>Rewrite</DropdownMenuLabel>
{REWRITE_MODES.map((mode) => (
<DropdownMenuItem key={mode} onClick={() => setEnhancementMode(mode)}>
{ENHANCEMENT_MODE_LABELS[mode]}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>Append Details</DropdownMenuLabel>
{ADDITIVE_MODES.map((mode) => (
<DropdownMenuItem key={mode} onClick={() => setEnhancementMode(mode)}>
{ENHANCEMENT_MODE_LABELS[mode]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -1,5 +1,5 @@
/** Enhancement mode options for AI-powered prompt improvement */
export type EnhancementMode = 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
import type { EnhancementMode } from '@automaker/types';
export type { EnhancementMode } from '@automaker/types';
/** Labels for enhancement modes displayed in the UI */
export const ENHANCEMENT_MODE_LABELS: Record<EnhancementMode, string> = {
@@ -18,3 +18,14 @@ export const ENHANCEMENT_MODE_DESCRIPTIONS: Record<EnhancementMode, string> = {
acceptance: 'Add specific acceptance criteria and test cases',
'ux-reviewer': 'Add user experience considerations and flows',
};
/** Modes that rewrite/replace the entire description */
export const REWRITE_MODES: EnhancementMode[] = ['improve', 'simplify'];
/** Modes that append additional content below the original description */
export const ADDITIVE_MODES: EnhancementMode[] = ['technical', 'acceptance', 'ux-reviewer'];
/** Check if a mode appends content rather than replacing */
export function isAdditiveMode(mode: EnhancementMode): boolean {
return ADDITIVE_MODES.includes(mode);
}

View File

@@ -43,6 +43,9 @@ import {
XCircle,
CheckCircle,
Settings2,
ArrowLeftRight,
Check,
Hash,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -105,6 +108,7 @@ interface WorktreeActionsDropdownProps {
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onChangePRNumber?: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
@@ -149,6 +153,14 @@ interface WorktreeActionsDropdownProps {
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
/** List of remote names that have a branch matching the current branch name */
remotesWithBranch?: string[];
/** Available worktrees for swapping into this slot (non-main only) */
availableWorktreesForSwap?: WorktreeInfo[];
/** The slot index for this tab in the pinned list (0-based, excluding main) */
slotIndex?: number;
/** Callback when user swaps this slot to a different worktree */
onSwapWorktree?: (slotIndex: number, newBranch: string) => void;
/** List of currently pinned branch names (to show which are pinned in the swap dropdown) */
pinnedBranches?: string[];
}
/**
@@ -259,6 +271,7 @@ export function WorktreeActionsDropdown({
onDiscardChanges,
onCommit,
onCreatePR,
onChangePRNumber,
onAddressPRComments,
onAutoAddressPRComments,
onResolveConflicts,
@@ -287,6 +300,10 @@ export function WorktreeActionsDropdown({
onSyncWithRemote,
onSetTracking,
remotesWithBranch,
availableWorktreesForSwap,
slotIndex,
onSwapWorktree,
pinnedBranches,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
const { editors } = useAvailableEditors();
@@ -1334,6 +1351,12 @@ export function WorktreeActionsDropdown({
<Zap className="w-3.5 h-3.5 mr-2" />
Address PR Comments
</DropdownMenuItem>
{onChangePRNumber && (
<DropdownMenuItem onClick={() => onChangePRNumber(worktree)} className="text-xs">
<Hash className="w-3.5 h-3.5 mr-2" />
Change PR Number
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
@@ -1359,6 +1382,36 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
</TooltipWrapper>
)}
{/* Swap Worktree submenu - only shown for non-main slots when there are other worktrees to swap to */}
{!worktree.isMain &&
availableWorktreesForSwap &&
availableWorktreesForSwap.length > 1 &&
slotIndex !== undefined &&
onSwapWorktree && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="text-xs">
<ArrowLeftRight className="w-3.5 h-3.5 mr-2" />
Swap Worktree
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-64 max-h-80 overflow-y-auto">
{availableWorktreesForSwap
.filter((wt) => wt.branch !== worktree.branch)
.map((wt) => {
const isPinned = pinnedBranches?.includes(wt.branch);
return (
<DropdownMenuItem
key={wt.path}
onSelect={() => onSwapWorktree(slotIndex, wt.branch)}
className="flex items-center gap-2 cursor-pointer font-mono text-xs"
>
<span className="truncate flex-1">{wt.branch}</span>
{isPinned && <Check className="w-3 h-3 shrink-0 text-muted-foreground" />}
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{!worktree.isMain && (
<DropdownMenuItem
onClick={() => onDeleteWorktree(worktree)}

View File

@@ -102,6 +102,7 @@ export interface WorktreeDropdownProps {
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onChangePRNumber?: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
@@ -148,6 +149,8 @@ export interface WorktreeDropdownProps {
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
/** List of remote names that have a branch matching the current branch name */
remotesWithBranch?: string[];
/** When false, the trigger button uses a subdued style instead of the primary highlight. Defaults to true. */
highlightTrigger?: boolean;
}
/**
@@ -215,6 +218,7 @@ export function WorktreeDropdown({
onDiscardChanges,
onCommit,
onCreatePR,
onChangePRNumber,
onAddressPRComments,
onAutoAddressPRComments,
onResolveConflicts,
@@ -245,10 +249,13 @@ export function WorktreeDropdown({
onSyncWithRemote,
onSetTracking,
remotesWithBranch,
highlightTrigger = true,
}: WorktreeDropdownProps) {
// Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
const displayBranch = selectedWorktree?.branch || 'Select worktree';
const displayBranch =
selectedWorktree?.branch ??
(worktrees.length > 0 ? `+${worktrees.length} more` : 'Select worktree');
const { truncated: truncatedBranch, isTruncated: isBranchNameTruncated } = truncateBranchName(
displayBranch,
MAX_TRIGGER_BRANCH_NAME_LENGTH
@@ -292,15 +299,28 @@ export function WorktreeDropdown({
const triggerButton = useMemo(
() => (
<Button
variant="outline"
variant={selectedWorktree && highlightTrigger ? 'default' : 'outline'}
size="sm"
className={cn(
'h-7 px-3 gap-1.5 font-mono text-xs bg-secondary/50 hover:bg-secondary min-w-0 border-r-0 rounded-r-none'
'h-7 px-3 gap-1.5 font-mono text-xs min-w-0',
selectedWorktree &&
highlightTrigger &&
'bg-primary text-primary-foreground border-r-0 rounded-l-md rounded-r-none',
selectedWorktree &&
!highlightTrigger &&
'bg-secondary/50 hover:bg-secondary border-r-0 rounded-l-md rounded-r-none',
!selectedWorktree && 'bg-secondary/50 hover:bg-secondary rounded-md'
)}
disabled={isActivating}
>
{/* Running/Activating indicator */}
{(selectedStatus.isRunning || isActivating) && <Spinner size="xs" className="shrink-0" />}
{(selectedStatus.isRunning || isActivating) && (
<Spinner
size="xs"
className="shrink-0"
variant={selectedWorktree && highlightTrigger ? 'foreground' : 'primary'}
/>
)}
{/* Branch icon */}
<GitBranch className="w-3.5 h-3.5 shrink-0" />
@@ -403,7 +423,14 @@ export function WorktreeDropdown({
<ChevronDown className="w-3 h-3 shrink-0 ml-auto" />
</Button>
),
[isActivating, selectedStatus, truncatedBranch, selectedWorktree, branchCardCounts]
[
isActivating,
selectedStatus,
truncatedBranch,
selectedWorktree,
branchCardCounts,
highlightTrigger,
]
);
// Wrap trigger button with dropdown trigger first to ensure ref is passed correctly
@@ -490,7 +517,7 @@ export function WorktreeDropdown({
{selectedWorktree?.isMain && (
<BranchSwitchDropdown
worktree={selectedWorktree}
isSelected={true}
isSelected={highlightTrigger}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
@@ -507,7 +534,7 @@ export function WorktreeDropdown({
{selectedWorktree && (
<WorktreeActionsDropdown
worktree={selectedWorktree}
isSelected={true}
isSelected={highlightTrigger}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
@@ -541,6 +568,7 @@ export function WorktreeDropdown({
onDiscardChanges={onDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onChangePRNumber={onChangePRNumber}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}

View File

@@ -66,6 +66,7 @@ interface WorktreeTabProps {
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onChangePRNumber?: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
@@ -118,6 +119,14 @@ interface WorktreeTabProps {
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
/** List of remote names that have a branch matching the current branch name */
remotesWithBranch?: string[];
/** Available worktrees for swapping into this slot (non-main only) */
availableWorktreesForSwap?: WorktreeInfo[];
/** The slot index for this tab in the pinned list (0-based, excluding main) */
slotIndex?: number;
/** Callback when user swaps this slot to a different worktree */
onSwapWorktree?: (slotIndex: number, newBranch: string) => void;
/** List of currently pinned branch names (to show which are pinned in the swap dropdown) */
pinnedBranches?: string[];
}
export function WorktreeTab({
@@ -164,6 +173,7 @@ export function WorktreeTab({
onDiscardChanges,
onCommit,
onCreatePR,
onChangePRNumber,
onAddressPRComments,
onAutoAddressPRComments,
onResolveConflicts,
@@ -196,6 +206,10 @@ export function WorktreeTab({
onSyncWithRemote,
onSetTracking,
remotesWithBranch,
availableWorktreesForSwap,
slotIndex,
onSwapWorktree,
pinnedBranches,
}: WorktreeTabProps) {
// Make the worktree tab a drop target for feature cards
const { setNodeRef, isOver } = useDroppable({
@@ -542,6 +556,7 @@ export function WorktreeTab({
onDiscardChanges={onDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onChangePRNumber={onChangePRNumber}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
@@ -570,6 +585,10 @@ export function WorktreeTab({
onSyncWithRemote={onSyncWithRemote}
onSetTracking={onSetTracking}
remotesWithBranch={remotesWithBranch}
availableWorktreesForSwap={availableWorktreesForSwap}
slotIndex={slotIndex}
onSwapWorktree={onSwapWorktree}
pinnedBranches={pinnedBranches}
/>
</div>
);

View File

@@ -120,6 +120,7 @@ export interface WorktreePanelProps {
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onChangePRNumber?: (worktree: WorktreeInfo) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;

View File

@@ -26,11 +26,11 @@ import {
} from './hooks';
import {
WorktreeTab,
WorktreeDropdown,
DevServerLogsPanel,
WorktreeMobileDropdown,
WorktreeActionsDropdown,
BranchSwitchDropdown,
WorktreeDropdown,
} from './components';
import { useAppStore } from '@/store/app-store';
import {
@@ -50,8 +50,9 @@ import type { SelectRemoteOperation } from '../dialogs';
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
import { getElectronAPI } from '@/lib/electron';
/** Threshold for switching from tabs to dropdown layout (number of worktrees) */
const WORKTREE_DROPDOWN_THRESHOLD = 3;
// Stable empty array to avoid creating a new [] reference on every render
// when pinnedWorktreeBranchesByProject[projectPath] is undefined
const EMPTY_BRANCHES: string[] = [];
export function WorktreePanel({
projectPath,
@@ -59,6 +60,7 @@ export function WorktreePanel({
onDeleteWorktree,
onCommit,
onCreatePR,
onChangePRNumber,
onCreateBranch,
onAddressPRComments,
onAutoAddressPRComments,
@@ -99,7 +101,6 @@ export function WorktreePanel({
aheadCount,
behindCount,
hasRemoteBranch,
trackingRemote,
getTrackingRemote,
remotesWithBranch,
isLoadingBranches,
@@ -139,13 +140,107 @@ export function WorktreePanel({
features,
});
// Pinned worktrees count from store
const pinnedWorktreesCount = useAppStore(
(state) => state.pinnedWorktreesCountByProject[projectPath] ?? 0
);
const pinnedWorktreeBranchesRaw = useAppStore(
(state) => state.pinnedWorktreeBranchesByProject[projectPath]
);
const pinnedWorktreeBranches = pinnedWorktreeBranchesRaw ?? EMPTY_BRANCHES;
const setPinnedWorktreeBranches = useAppStore((state) => state.setPinnedWorktreeBranches);
const swapPinnedWorktreeBranch = useAppStore((state) => state.swapPinnedWorktreeBranch);
// Resolve pinned worktrees from explicit branch assignments
// Shows exactly pinnedWorktreesCount slots, each with a specific worktree.
// Main worktree is always slot 0. Other slots can be swapped by the user.
const pinnedWorktrees = useMemo(() => {
const mainWt = worktrees.find((w) => w.isMain);
const otherWts = worktrees.filter((w) => !w.isMain);
// Slot 0 is always main worktree
const result: WorktreeInfo[] = mainWt ? [mainWt] : [];
// pinnedWorktreesCount represents only non-main worktrees; main is always shown separately
const otherSlotCount = Math.max(0, pinnedWorktreesCount);
if (otherSlotCount > 0 && otherWts.length > 0) {
// Use explicit branch assignments if available
const assignedBranches = pinnedWorktreeBranches;
const usedBranches = new Set<string>();
for (let i = 0; i < otherSlotCount; i++) {
const assignedBranch = assignedBranches[i];
let wt: WorktreeInfo | undefined;
// Try to find the explicitly assigned worktree
if (assignedBranch) {
wt = otherWts.find((w) => w.branch === assignedBranch && !usedBranches.has(w.branch));
}
// Fall back to next available worktree if assigned one doesn't exist
if (!wt) {
wt = otherWts.find((w) => !usedBranches.has(w.branch));
}
if (wt) {
result.push(wt);
usedBranches.add(wt.branch);
}
}
}
return result;
}, [worktrees, pinnedWorktreesCount, pinnedWorktreeBranches]);
// All non-main worktrees available for swapping into slots
const availableWorktreesForSwap = useMemo(() => {
return worktrees.filter((w) => !w.isMain);
}, [worktrees]);
// Handle swapping a worktree in a specific slot
const handleSwapWorktreeSlot = useCallback(
(slotIndex: number, newBranch: string) => {
swapPinnedWorktreeBranch(projectPath, slotIndex, newBranch);
},
[projectPath, swapPinnedWorktreeBranch]
);
// Initialize pinned branch assignments when worktrees change
// This ensures new worktrees get default slot assignments
// Read store state directly inside the effect to avoid a dependency cycle
// (the effect writes to the same state it would otherwise depend on)
useEffect(() => {
const mainWt = worktrees.find((w) => w.isMain);
const otherWts = worktrees.filter((w) => !w.isMain);
const otherSlotCount = Math.max(0, pinnedWorktreesCount);
const storedBranches = useAppStore.getState().pinnedWorktreeBranchesByProject[projectPath];
if (otherSlotCount > 0 && otherWts.length > 0) {
const existing = storedBranches ?? [];
if (existing.length < otherSlotCount) {
const used = new Set(existing.filter(Boolean));
const filled = [...existing];
for (const wt of otherWts) {
if (filled.length >= otherSlotCount) break;
if (!used.has(wt.branch)) {
filled.push(wt.branch);
used.add(wt.branch);
}
}
if (filled.length > 0) {
setPinnedWorktreeBranches(projectPath, filled);
}
}
}
}, [worktrees, pinnedWorktreesCount, projectPath, setPinnedWorktreeBranches]);
// Auto-mode state management using the store
// Use separate selectors to avoid creating new object references on each render
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
const currentProject = useAppStore((state) => state.currentProject);
const setAutoModeRunning = useAppStore((state) => state.setAutoModeRunning);
const getMaxConcurrencyForWorktree = useAppStore((state) => state.getMaxConcurrencyForWorktree);
// Helper to generate worktree key for auto-mode (inlined to avoid selector issues)
const getAutoModeWorktreeKey = useCallback(
(projectId: string, branchName: string | null): string => {
@@ -651,18 +746,6 @@ export function WorktreePanel({
// 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
const handlePushNewBranch = useCallback((worktree: WorktreeInfo) => {
setPushToRemoteWorktree(worktree);
@@ -887,7 +970,6 @@ export function WorktreePanel({
);
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
// Mobile view: single dropdown for all worktrees
if (isMobile) {
@@ -965,12 +1047,13 @@ export function WorktreePanel({
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onChangePRNumber={onChangePRNumber}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServerAndShowLogs}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
@@ -1145,56 +1228,124 @@ export function WorktreePanel({
);
}
// Use dropdown layout when worktree count meets or exceeds the threshold
const useDropdownLayout = worktrees.length >= WORKTREE_DROPDOWN_THRESHOLD;
// Desktop view: pinned worktrees as individual tabs (each slot can be swapped)
// Desktop view: full tabs layout or dropdown layout depending on worktree count
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">
{useDropdownLayout ? 'Worktree:' : 'Branch:'}
</span>
<div className="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-border bg-glass/50 backdrop-blur-sm">
<GitBranch className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="text-sm text-muted-foreground mr-2 shrink-0">Worktree:</span>
{/* Dropdown layout for 3+ worktrees */}
{useDropdownLayout ? (
<>
<WorktreeDropdown
worktrees={worktrees}
isWorktreeSelected={isWorktreeSelected}
hasRunningFeatures={hasRunningFeatures}
{/* When only 1 pinned slot (main only) and there are other worktrees,
use a compact dropdown to switch between them without highlighting main */}
{pinnedWorktreesCount === 0 && availableWorktreesForSwap.length > 0 ? (
<WorktreeDropdown
worktrees={worktrees}
isWorktreeSelected={isWorktreeSelected}
hasRunningFeatures={hasRunningFeatures}
isActivating={isActivating}
branchCardCounts={branchCardCounts}
isDevServerRunning={isDevServerRunning}
getDevServerInfo={getDevServerInfo}
isAutoModeRunningForWorktree={isAutoModeRunningForWorktree}
isTestRunningForWorktree={isTestRunningForWorktree}
getTestSessionInfo={getTestSessionInfo}
onSelectWorktree={handleSelectWorktree}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
getTrackingRemote={getTrackingRemote}
gitRepoStatus={gitRepoStatus}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
hasInitScript={hasInitScript}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
remotesCache={remotesCache}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onChangePRNumber={onChangePRNumber}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
highlightTrigger={false}
/>
) : pinnedWorktreesCount === 0 ? (
/* Only main worktree, no others exist - render main tab without highlight */
mainWorktree && (
<WorktreeTab
worktree={mainWorktree}
cardCount={branchCardCounts?.[mainWorktree.branch]}
hasChanges={mainWorktree.hasChanges}
changedFilesCount={mainWorktree.changedFilesCount}
isSelected={false}
isRunning={hasRunningFeatures(mainWorktree)}
isActivating={isActivating}
branchCardCounts={branchCardCounts}
isDevServerRunning={isDevServerRunning}
getDevServerInfo={getDevServerInfo}
isAutoModeRunningForWorktree={isAutoModeRunningForWorktree}
isTestRunningForWorktree={isTestRunningForWorktree}
getTestSessionInfo={getTestSessionInfo}
onSelectWorktree={handleSelectWorktree}
// Branch switching props
isDevServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
// Action dropdown props
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
trackingRemote={trackingRemote}
getTrackingRemote={getTrackingRemote}
trackingRemote={getTrackingRemote(mainWorktree.path)}
gitRepoStatus={gitRepoStatus}
hasTestCommand={hasTestCommand}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
isStartingTests={isStartingTests}
hasInitScript={hasInitScript}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange}
isTestRunning={isTestRunningForWorktree(mainWorktree)}
testSessionInfo={getTestSessionInfo(mainWorktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
@@ -1205,7 +1356,7 @@ export function WorktreePanel({
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
remotesWithBranch={remotesWithBranch}
remotesCache={remotesCache}
remotes={remotesCache[mainWorktree.path]}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -1214,12 +1365,13 @@ export function WorktreePanel({
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onChangePRNumber={onChangePRNumber}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServerAndShowLogs}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
@@ -1233,247 +1385,138 @@ export function WorktreePanel({
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
/>
{useWorktreesEnabled && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</>
)}
</>
)
) : (
/* Standard tabs layout for 1-2 worktrees */
/* Multiple pinned slots - show individual tabs */
pinnedWorktrees.map((worktree, index) => {
const hasOtherWorktrees = worktrees.length > 1;
const effectiveIsSelected =
isWorktreeSelected(worktree) && (hasOtherWorktrees || !worktree.isMain);
// Slot index for swap (0-based, excluding main which is always slot 0)
const slotIndex = worktree.isMain ? -1 : index - (pinnedWorktrees[0]?.isMain ? 1 : 0);
return (
<WorktreeTab
key={worktree.path}
worktree={worktree}
cardCount={branchCardCounts?.[worktree.branch]}
hasChanges={worktree.hasChanges}
changedFilesCount={worktree.changedFilesCount}
isSelected={effectiveIsSelected}
isRunning={hasRunningFeatures(worktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
trackingRemote={getTrackingRemote(worktree.path)}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(worktree)}
testSessionInfo={getTestSessionInfo(worktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
remotes={remotesCache[worktree.path]}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onChangePRNumber={onChangePRNumber}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
availableWorktreesForSwap={!worktree.isMain ? availableWorktreesForSwap : undefined}
slotIndex={slotIndex >= 0 ? slotIndex : undefined}
onSwapWorktree={slotIndex >= 0 ? handleSwapWorktreeSlot : undefined}
pinnedBranches={pinnedWorktrees.map((w) => w.branch)}
isSyncing={isSyncing}
onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
/>
);
})
)}
{/* Create and refresh buttons */}
{useWorktreesEnabled && (
<>
<div className="flex items-center gap-2">
{mainWorktree && (
<WorktreeTab
key={mainWorktree.path}
worktree={mainWorktree}
cardCount={branchCardCounts?.[mainWorktree.branch]}
hasChanges={mainWorktree.hasChanges}
changedFilesCount={mainWorktree.changedFilesCount}
isSelected={isWorktreeSelected(mainWorktree)}
isRunning={hasRunningFeatures(mainWorktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
trackingRemote={getTrackingRemote(mainWorktree.path)}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(mainWorktree)}
testSessionInfo={getTestSessionInfo(mainWorktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
isSyncing={isSyncing}
onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
remotes={remotesCache[mainWorktree.path]}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServerAndShowLogs}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
remotesWithBranch={remotesWithBranch}
/>
)}
</div>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
{/* Worktrees section - only show if enabled and not using dropdown layout */}
{useWorktreesEnabled && (
<>
<div className="w-px h-5 bg-border mx-2" />
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Worktrees:</span>
<div className="flex items-center gap-2 flex-wrap">
{nonMainWorktrees.map((worktree) => {
const cardCount = branchCardCounts?.[worktree.branch];
return (
<WorktreeTab
key={worktree.path}
worktree={worktree}
cardCount={cardCount}
hasChanges={worktree.hasChanges}
changedFilesCount={worktree.changedFilesCount}
isSelected={isWorktreeSelected(worktree)}
isRunning={hasRunningFeatures(worktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
trackingRemote={getTrackingRemote(worktree.path)}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(worktree)}
testSessionInfo={getTestSessionInfo(worktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
isSyncing={isSyncing}
onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
remotes={remotesCache[worktree.path]}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServerAndShowLogs}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
remotesWithBranch={remotesWithBranch}
/>
);
})}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</div>
</>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</>
)}

View File

@@ -1,34 +1,13 @@
import { useMemo, useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { EditorView, keymap } from '@codemirror/view';
import { Extension } from '@codemirror/state';
import { EditorView, keymap, Decoration, WidgetType } from '@codemirror/view';
import { Extension, RangeSetBuilder, StateField } from '@codemirror/state';
import { undo as cmUndo, redo as cmRedo } from '@codemirror/commands';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import { search, openSearchPanel } from '@codemirror/search';
// Language imports
import { javascript } from '@codemirror/lang-javascript';
import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { python } from '@codemirror/lang-python';
import { java } from '@codemirror/lang-java';
import { rust } from '@codemirror/lang-rust';
import { cpp } from '@codemirror/lang-cpp';
import { sql } from '@codemirror/lang-sql';
import { php } from '@codemirror/lang-php';
import { xml } from '@codemirror/lang-xml';
import { StreamLanguage } from '@codemirror/language';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { go } from '@codemirror/legacy-modes/mode/go';
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
import { swift } from '@codemirror/legacy-modes/mode/swift';
import { getLanguageExtension } from '@/lib/codemirror-languages';
import { cn } from '@/lib/utils';
import { useIsMobile } from '@/hooks/use-media-query';
import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
@@ -55,6 +34,8 @@ export interface CodeEditorHandle {
undo: () => void;
/** Redoes the last undone edit */
redo: () => void;
/** Returns the current text selection with line range, or null if nothing is selected */
getSelection: () => { text: string; fromLine: number; toLine: number } | null;
}
interface CodeEditorProps {
@@ -72,133 +53,10 @@ interface CodeEditorProps {
className?: string;
/** When true, scrolls the cursor into view (e.g. after virtual keyboard opens) */
scrollCursorIntoView?: boolean;
}
/** Detect language extension based on file extension */
function getLanguageExtension(filePath: string): Extension | null {
const name = filePath.split('/').pop()?.toLowerCase() || '';
const dotIndex = name.lastIndexOf('.');
// Files without an extension (no dot, or dotfile with dot at position 0)
const ext = dotIndex > 0 ? name.slice(dotIndex + 1) : '';
// Handle files by name first
switch (name) {
case 'dockerfile':
case 'dockerfile.dev':
case 'dockerfile.prod':
return StreamLanguage.define(dockerFile);
case 'makefile':
case 'gnumakefile':
return StreamLanguage.define(shell);
case '.gitignore':
case '.dockerignore':
case '.npmignore':
case '.eslintignore':
return StreamLanguage.define(shell); // close enough for ignore files
case '.env':
case '.env.local':
case '.env.development':
case '.env.production':
return StreamLanguage.define(shell);
}
switch (ext) {
// JavaScript/TypeScript
case 'js':
case 'mjs':
case 'cjs':
return javascript();
case 'jsx':
return javascript({ jsx: true });
case 'ts':
case 'mts':
case 'cts':
return javascript({ typescript: true });
case 'tsx':
return javascript({ jsx: true, typescript: true });
// Web
case 'html':
case 'htm':
case 'svelte':
case 'vue':
return html();
case 'css':
case 'scss':
case 'less':
return css();
case 'json':
case 'jsonc':
case 'json5':
return json();
case 'xml':
case 'svg':
case 'xsl':
case 'xslt':
case 'plist':
return xml();
// Markdown
case 'md':
case 'mdx':
case 'markdown':
return markdown();
// Python
case 'py':
case 'pyx':
case 'pyi':
return python();
// Java/Kotlin
case 'java':
case 'kt':
case 'kts':
return java();
// Systems
case 'rs':
return rust();
case 'c':
case 'h':
return cpp();
case 'cpp':
case 'cc':
case 'cxx':
case 'hpp':
case 'hxx':
return cpp();
case 'go':
return StreamLanguage.define(go);
case 'swift':
return StreamLanguage.define(swift);
// Scripting
case 'rb':
case 'erb':
return StreamLanguage.define(ruby);
case 'php':
return php();
case 'sh':
case 'bash':
case 'zsh':
case 'fish':
return StreamLanguage.define(shell);
// Data
case 'sql':
case 'mysql':
case 'pgsql':
return sql();
case 'yaml':
case 'yml':
return StreamLanguage.define(yaml);
case 'toml':
return StreamLanguage.define(toml);
default:
return null; // Plain text fallback
}
/** Raw unified diff string for the file, used to highlight added/removed lines */
diffContent?: string | null;
/** Fires when the text selection state changes (true = non-empty selection) */
onSelectionChange?: (hasSelection: boolean) => void;
}
/** Get a human-readable language name */
@@ -295,6 +153,215 @@ export function getLanguageName(filePath: string): string {
}
}
// ─── Inline Diff Decorations ─────────────────────────────────────────────
/** Parsed diff info: added line numbers and groups of deleted lines with content */
interface DiffInfo {
addedLines: Set<number>;
/**
* Groups of consecutive deleted lines keyed by the new-file line number
* they appear before. E.g. key=3 means the deleted lines were removed
* just before line 3 in the current file.
*/
deletedGroups: Map<number, string[]>;
}
/** Parse a unified diff to extract added lines and groups of deleted lines */
function parseUnifiedDiff(diffContent: string): DiffInfo {
const addedLines = new Set<number>();
const deletedGroups = new Map<number, string[]>();
const lines = diffContent.split('\n');
let currentNewLine = 0;
let inHunk = false;
let pendingDeletions: string[] = [];
const flushDeletions = () => {
if (pendingDeletions.length > 0) {
const existing = deletedGroups.get(currentNewLine);
if (existing) {
existing.push(...pendingDeletions);
} else {
deletedGroups.set(currentNewLine, [...pendingDeletions]);
}
pendingDeletions = [];
}
};
for (const line of lines) {
// Parse hunk header: @@ -oldStart,oldCount +newStart,newCount @@ ...
if (line.startsWith('@@')) {
flushDeletions();
const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (match) {
currentNewLine = parseInt(match[1], 10);
inHunk = true;
}
continue;
}
if (!inHunk) continue;
// Skip diff header lines
if (
line.startsWith('--- ') ||
line.startsWith('+++ ') ||
line.startsWith('diff ') ||
line.startsWith('index ')
) {
continue;
}
if (line.startsWith('+')) {
flushDeletions();
addedLines.add(currentNewLine);
currentNewLine++;
} else if (line.startsWith('-')) {
// Accumulate deleted lines to show as a group
pendingDeletions.push(line.substring(1));
} else if (line.startsWith(' ') || line === '') {
flushDeletions();
currentNewLine++;
}
}
flushDeletions();
return { addedLines, deletedGroups };
}
/** Widget that renders a block of deleted lines inline in the editor */
class DeletedLinesWidget extends WidgetType {
constructor(readonly lines: string[]) {
super();
}
toDOM() {
const container = document.createElement('div');
container.className = 'cm-diff-deleted-widget';
container.style.cssText =
'background-color: oklch(0.55 0.22 25 / 0.1); border-left: 3px solid oklch(0.55 0.22 25 / 0.5);';
for (const line of this.lines) {
const lineEl = document.createElement('div');
lineEl.style.cssText =
'text-decoration: line-through; color: oklch(0.55 0.22 25 / 0.8); background-color: oklch(0.55 0.22 25 / 0.15); padding: 0 0.5rem; padding-left: calc(0.5rem - 3px); white-space: pre; font-family: inherit;';
lineEl.textContent = line || ' ';
container.appendChild(lineEl);
}
return container;
}
eq(other: WidgetType) {
if (!(other instanceof DeletedLinesWidget)) return false;
return (
this.lines.length === other.lines.length && this.lines.every((l, i) => l === other.lines[i])
);
}
ignoreEvent() {
return true;
}
}
/** Create a CodeMirror extension that decorates lines based on diff */
function createDiffDecorations(diffContent: string | null | undefined): Extension {
if (!diffContent) {
return [];
}
const { addedLines, deletedGroups } = parseUnifiedDiff(diffContent);
if (addedLines.size === 0 && deletedGroups.size === 0) {
return [];
}
const addedLineDecoration = Decoration.line({
class: 'cm-diff-added-line',
attributes: { style: 'background-color: oklch(0.65 0.2 145 / 0.15);' },
});
const extensions: Extension[] = [];
// Line decorations for added lines
if (addedLines.size > 0) {
extensions.push(
EditorView.decorations.of((view) => {
const builder = new RangeSetBuilder<Decoration>();
const doc = view.state.doc;
for (const lineNum of addedLines) {
if (lineNum >= 1 && lineNum <= doc.lines) {
const linePos = doc.line(lineNum).from;
builder.add(linePos, linePos, addedLineDecoration);
}
}
return builder.finish();
})
);
}
// Widget decorations for deleted line groups.
// Block decorations MUST be provided via a StateField (not a plugin/function).
if (deletedGroups.size > 0) {
const buildDeletedDecorations = (doc: {
lines: number;
line(n: number): { from: number; to: number };
}) => {
const builder = new RangeSetBuilder<Decoration>();
const positions = [...deletedGroups.keys()].sort((a, b) => a - b);
for (const pos of positions) {
const deletedLines = deletedGroups.get(pos)!;
if (pos >= 1 && pos <= doc.lines) {
const linePos = doc.line(pos).from;
builder.add(
linePos,
linePos,
Decoration.widget({
widget: new DeletedLinesWidget(deletedLines),
block: true,
side: -1,
})
);
} else {
const lastLinePos = doc.line(doc.lines).to;
builder.add(
lastLinePos,
lastLinePos,
Decoration.widget({
widget: new DeletedLinesWidget(deletedLines),
block: true,
side: 1,
})
);
}
}
return builder.finish();
};
extensions.push(
StateField.define({
create(state) {
return buildDeletedDecorations(state.doc);
},
update(decorations, tr) {
if (tr.docChanged) {
return decorations.map(tr.changes);
}
return decorations;
},
provide: (f) => EditorView.decorations.from(f),
})
);
}
return extensions;
}
// ─────────────────────────────────────────────────────────────────────────
// Syntax highlighting using CSS variables for theme compatibility
const syntaxColors = HighlightStyle.define([
{ tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
@@ -338,6 +405,8 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function
onSave,
className,
scrollCursorIntoView = false,
diffContent,
onSelectionChange,
},
ref
) {
@@ -347,12 +416,17 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function
// Stable refs for callbacks to avoid frequent extension rebuilds
const onSaveRef = useRef(onSave);
const onCursorChangeRef = useRef(onCursorChange);
const onSelectionChangeRef = useRef(onSelectionChange);
const lastHasSelectionRef = useRef(false);
useEffect(() => {
onSaveRef.current = onSave;
}, [onSave]);
useEffect(() => {
onCursorChangeRef.current = onCursorChange;
}, [onCursorChange]);
useEffect(() => {
onSelectionChangeRef.current = onSelectionChange;
}, [onSelectionChange]);
// Expose imperative methods to parent components
useImperativeHandle(
@@ -381,6 +455,16 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function
cmRedo(editorRef.current.view);
}
},
getSelection: () => {
const view = editorRef.current?.view;
if (!view) return null;
const { from, to } = view.state.selection.main;
if (from === to) return null;
const text = view.state.sliceDoc(from, to);
const fromLine = view.state.doc.lineAt(from).number;
const toLine = view.state.doc.lineAt(to).number;
return { text, fromLine, toLine };
},
}),
[]
);
@@ -537,10 +621,20 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function
editorTheme,
search(),
EditorView.updateListener.of((update) => {
if (update.selectionSet && onCursorChangeRef.current) {
const pos = update.state.selection.main.head;
const line = update.state.doc.lineAt(pos);
onCursorChangeRef.current(line.number, pos - line.from + 1);
if (update.selectionSet) {
if (onCursorChangeRef.current) {
const pos = update.state.selection.main.head;
const line = update.state.doc.lineAt(pos);
onCursorChangeRef.current(line.number, pos - line.from + 1);
}
if (onSelectionChangeRef.current) {
const { from, to } = update.state.selection.main;
const hasSelection = from !== to;
if (hasSelection !== lastHasSelectionRef.current) {
lastHasSelectionRef.current = hasSelection;
onSelectionChangeRef.current(hasSelection);
}
}
}
}),
];
@@ -572,8 +666,13 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function
exts.push(langExt);
}
// Add inline diff decorations if diff content is provided
if (diffContent) {
exts.push(createDiffDecorations(diffContent));
}
return exts;
}, [filePath, wordWrap, tabSize, editorTheme]);
}, [filePath, wordWrap, tabSize, editorTheme, diffContent]);
return (
<div className={cn('h-full w-full', className)}>

View File

@@ -1,4 +1,5 @@
import { X, Circle, MoreHorizontal, Save } from 'lucide-react';
import { useRef, useEffect, useCallback } from 'react';
import { X, Circle, MoreHorizontal, Save, ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { EditorTab } from '../use-file-editor-store';
import {
@@ -84,61 +85,105 @@ export function EditorTabs({
isDirty,
showSaveButton,
}: EditorTabsProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const activeTabRef = useRef<HTMLDivElement>(null);
// Scroll the active tab into view when it changes
useEffect(() => {
if (activeTabRef.current) {
activeTabRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
});
}
}, [activeTabId]);
const scrollBy = useCallback((direction: 'left' | 'right') => {
if (!scrollRef.current) return;
const amount = direction === 'left' ? -200 : 200;
scrollRef.current.scrollBy({ left: amount, behavior: 'smooth' });
}, []);
if (tabs.length === 0) return null;
return (
<div
className="flex items-center border-b border-border bg-muted/30 overflow-x-auto"
data-testid="editor-tabs"
>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const fileColor = getFileColor(tab.fileName);
<div className="flex items-center border-b border-border bg-muted/30" data-testid="editor-tabs">
{/* Scroll left arrow */}
<button
onClick={() => scrollBy('left')}
className="shrink-0 p-1 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
title="Scroll tabs left"
>
<ChevronLeft className="w-4 h-4" />
</button>
return (
<div
key={tab.id}
className={cn(
'group flex items-center gap-1.5 px-3 py-1.5 cursor-pointer border-r border-border min-w-0 max-w-[200px] text-sm transition-colors',
isActive
? 'bg-background text-foreground border-b-2 border-b-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
)}
onClick={() => onTabSelect(tab.id)}
title={tab.filePath}
>
{/* Dirty indicator */}
{tab.isDirty ? (
<Circle className="w-2 h-2 shrink-0 fill-current text-primary" />
) : (
<span className={cn('w-2 h-2 rounded-full shrink-0', fileColor)} />
)}
{/* Scrollable tab area */}
<div
ref={scrollRef}
className="flex items-center overflow-x-auto flex-1 min-w-0 scrollbar-none"
>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const fileColor = getFileColor(tab.fileName);
{/* File name */}
<span className="truncate">{tab.fileName}</span>
{/* Close button */}
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(tab.id);
}}
return (
<div
key={tab.id}
ref={isActive ? activeTabRef : undefined}
className={cn(
'p-0.5 rounded shrink-0 transition-colors',
'opacity-0 group-hover:opacity-100',
isActive && 'opacity-60',
'hover:bg-accent'
'group flex items-center gap-1.5 px-3 py-1.5 cursor-pointer border-r border-border min-w-0 max-w-[200px] shrink-0 text-sm transition-colors',
isActive
? 'bg-background text-foreground border-b-2 border-b-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
)}
title="Close"
onClick={() => onTabSelect(tab.id)}
title={tab.filePath}
>
<X className="w-3 h-3" />
</button>
</div>
);
})}
{/* Dirty indicator */}
{tab.isDirty ? (
<Circle className="w-2 h-2 shrink-0 fill-current text-primary" />
) : (
<span
className={cn('w-2 h-2 rounded-full shrink-0', fileColor.replace('text-', 'bg-'))}
/>
)}
{/* File name */}
<span className="truncate">{tab.fileName}</span>
{/* Close button */}
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(tab.id);
}}
className={cn(
'p-0.5 rounded shrink-0 transition-colors',
'opacity-0 group-hover:opacity-100',
isActive && 'opacity-60',
'hover:bg-accent'
)}
title="Close"
>
<X className="w-3 h-3" />
</button>
</div>
);
})}
</div>
{/* Scroll right arrow */}
<button
onClick={() => scrollBy('right')}
className="shrink-0 p-1 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
title="Scroll tabs right"
>
<ChevronRight className="w-4 h-4" />
</button>
{/* Tab actions: save button (mobile) + close-all dropdown */}
<div className="ml-auto shrink-0 flex items-center px-1 gap-0.5">
<div className="shrink-0 flex items-center px-1 gap-0.5 border-l border-border">
{/* Save button — shown in the tab bar on mobile */}
{showSaveButton && onSave && (
<button

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import {
File,
Folder,
@@ -31,7 +31,11 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useFileEditorStore, type FileTreeNode } from '../use-file-editor-store';
import {
useFileEditorStore,
type FileTreeNode,
type EnhancedGitFileStatus,
} from '../use-file-editor-store';
import { useFileBrowser } from '@/contexts/file-browser-context';
interface FileTreeProps {
@@ -105,6 +109,51 @@ function getGitStatusLabel(status: string | undefined): string {
}
}
/** Status priority for determining the "dominant" status on a folder (higher = more prominent) */
const STATUS_PRIORITY: Record<string, number> = {
U: 6, // Conflicted - highest priority
D: 5, // Deleted
A: 4, // Added
M: 3, // Modified
R: 2, // Renamed
C: 2, // Copied
S: 1, // Staged
'?': 0, // Untracked
'!': -1, // Ignored - lowest priority
};
/** Compute aggregated git status info for a folder from the status maps */
function computeFolderGitRollup(
folderPath: string,
gitStatusMap: Map<string, string>,
enhancedGitStatusMap: Map<string, EnhancedGitFileStatus>
): { count: number; dominantStatus: string | null; totalAdded: number; totalRemoved: number } {
const prefix = folderPath + '/';
let count = 0;
let dominantStatus: string | null = null;
let dominantPriority = -2;
let totalAdded = 0;
let totalRemoved = 0;
for (const [filePath, status] of gitStatusMap) {
if (filePath.startsWith(prefix)) {
count++;
const priority = STATUS_PRIORITY[status] ?? -1;
if (priority > dominantPriority) {
dominantPriority = priority;
dominantStatus = status;
}
const enhanced = enhancedGitStatusMap.get(filePath);
if (enhanced) {
totalAdded += enhanced.linesAdded;
totalRemoved += enhanced.linesRemoved;
}
}
}
return { count, dominantStatus, totalAdded, totalRemoved };
}
/**
* Validate a file/folder name for safety.
* Rejects names containing path separators, relative path components,
@@ -281,6 +330,12 @@ function TreeNode({
const linesRemoved = enhancedStatus?.linesRemoved || 0;
const enhancedLabel = enhancedStatus?.statusLabel || statusLabel;
// Folder-level git status rollup
const folderRollup = useMemo(() => {
if (!node.isDirectory) return null;
return computeFolderGitRollup(node.path, gitStatusMap, enhancedGitStatusMap);
}, [node.isDirectory, node.path, gitStatusMap, enhancedGitStatusMap]);
// Drag state
const isDragging = dragState.draggedPaths.includes(node.path);
const isDropTarget = dragState.dropTargetPath === node.path && node.isDirectory;
@@ -385,9 +440,16 @@ function TreeNode({
// Build tooltip with enhanced info
let tooltip = node.name;
if (enhancedLabel) tooltip += ` (${enhancedLabel})`;
if (linesAdded > 0 || linesRemoved > 0) {
tooltip += ` +${linesAdded} -${linesRemoved}`;
if (node.isDirectory && folderRollup && folderRollup.count > 0) {
tooltip += ` (${folderRollup.count} changed file${folderRollup.count !== 1 ? 's' : ''})`;
if (folderRollup.totalAdded > 0 || folderRollup.totalRemoved > 0) {
tooltip += ` +${folderRollup.totalAdded} -${folderRollup.totalRemoved}`;
}
} else {
if (enhancedLabel) tooltip += ` (${enhancedLabel})`;
if (linesAdded > 0 || linesRemoved > 0) {
tooltip += ` +${linesAdded} -${linesRemoved}`;
}
}
return (
@@ -456,7 +518,33 @@ function TreeNode({
{/* Name */}
<span className="truncate flex-1">{node.name}</span>
{/* Diff stats (lines added/removed) shown inline */}
{/* Folder: modified file count badge and rollup indicator */}
{node.isDirectory && folderRollup && folderRollup.count > 0 && (
<>
<span
className="text-[10px] font-medium shrink-0 px-1 py-0 rounded-full bg-muted text-muted-foreground"
title={`${folderRollup.count} changed file${folderRollup.count !== 1 ? 's' : ''}`}
>
{folderRollup.count}
</span>
<span
className={cn('w-1.5 h-1.5 rounded-full shrink-0', {
'bg-yellow-500': folderRollup.dominantStatus === 'M',
'bg-green-500':
folderRollup.dominantStatus === 'A' || folderRollup.dominantStatus === 'S',
'bg-red-500': folderRollup.dominantStatus === 'D',
'bg-gray-400': folderRollup.dominantStatus === '?',
'bg-gray-600': folderRollup.dominantStatus === '!',
'bg-purple-500': folderRollup.dominantStatus === 'R',
'bg-cyan-500': folderRollup.dominantStatus === 'C',
'bg-orange-500': folderRollup.dominantStatus === 'U',
})}
title={`${folderRollup.dominantStatus ? getGitStatusLabel(folderRollup.dominantStatus) : 'Changed'} (${folderRollup.count})`}
/>
</>
)}
{/* File: diff stats (lines added/removed) shown inline */}
{!node.isDirectory && (linesAdded > 0 || linesRemoved > 0) && (
<span className="flex items-center gap-1 text-[10px] shrink-0 opacity-70">
{linesAdded > 0 && (
@@ -474,8 +562,8 @@ function TreeNode({
</span>
)}
{/* Git status indicator - two-tone badge for staged+unstaged */}
{gitStatus && (
{/* File: git status indicator - two-tone badge for staged+unstaged */}
{!node.isDirectory && gitStatus && (
<span className="flex items-center gap-0 shrink-0">
{isStaged && isUnstaged ? (
// Two-tone badge: staged (green) + unstaged (yellow)

View File

@@ -11,10 +11,17 @@ import {
Undo2,
Redo2,
Settings,
Diff,
FolderKanban,
} from 'lucide-react';
import { createLogger } from '@automaker/utils/logger';
import { resolveModelString } from '@automaker/model-resolver';
import { useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { cn, generateUUID, pathsEqual } from '@/lib/utils';
import { queryKeys } from '@/lib/query-keys';
import { useIsMobile } from '@/hooks/use-media-query';
import { useVirtualKeyboardResize } from '@/hooks/use-virtual-keyboard-resize';
import { Button } from '@/components/ui/button';
@@ -23,7 +30,6 @@ import {
HeaderActionsPanelTrigger,
} from '@/components/ui/header-actions-panel';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import {
@@ -42,6 +48,7 @@ import {
} from './components/markdown-preview';
import { WorktreeDirectoryDropdown } from './components/worktree-directory-dropdown';
import { GitDetailPanel } from './components/git-detail-panel';
import { AddFeatureDialog } from '@/components/views/board-view/dialogs';
const logger = createLogger('FileEditorView');
@@ -111,10 +118,13 @@ interface FileEditorViewProps {
}
export function FileEditorView({ initialPath }: FileEditorViewProps) {
const { currentProject } = useAppStore();
const { currentProject, defaultSkipTests, getCurrentWorktree, worktreesByProject } =
useAppStore();
const currentWorktree = useAppStore((s) =>
currentProject?.path ? (s.currentWorktreeByProject[currentProject.path] ?? null) : null
);
const queryClient = useQueryClient();
const navigate = useNavigate();
// Read persisted editor font settings from app store
const editorFontSize = useAppStore((s) => s.editorFontSize);
const editorFontFamily = useAppStore((s) => s.editorFontFamily);
@@ -131,6 +141,9 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
const editorRef = useRef<CodeEditorHandle>(null);
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [showActionsPanel, setShowActionsPanel] = useState(false);
const [hasEditorSelection, setHasEditorSelection] = useState(false);
const [showAddFeatureDialog, setShowAddFeatureDialog] = useState(false);
const [featureSelectionContext, setFeatureSelectionContext] = useState<string | undefined>();
// Derive the effective working path from the current worktree selection.
// When a worktree is selected (path is non-null), use the worktree path;
@@ -151,7 +164,6 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
tabSize,
wordWrap,
maxFileSize,
setFileTree,
openTab,
closeTab,
closeAllTabs,
@@ -159,14 +171,11 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
markTabSaved,
setMarkdownViewMode,
setMobileBrowserVisible,
setGitStatusMap,
setExpandedFolders,
setEnhancedGitStatusMap,
setGitBranch,
setActiveFileGitDetails,
activeFileGitDetails,
gitBranch,
enhancedGitStatusMap,
showInlineDiff,
setShowInlineDiff,
activeFileDiff,
} = store;
const activeTab = tabs.find((t) => t.id === activeTabId) || null;
@@ -217,6 +226,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
};
const tree = await buildTree(treePath);
const { setFileTree, setExpandedFolders } = useFileEditorStore.getState();
setFileTree(tree);
if (expandedSnapshot !== null) {
@@ -230,12 +240,14 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
logger.error('Failed to load file tree:', error);
}
},
[effectivePath, setFileTree, setExpandedFolders]
[effectivePath]
);
// ─── Load Git Status ─────────────────────────────────────────
const loadGitStatus = useCallback(async () => {
if (!effectivePath) return;
const { setGitStatusMap, setEnhancedGitStatusMap, setGitBranch } =
useFileEditorStore.getState();
try {
const api = getElectronAPI();
@@ -289,7 +301,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
// Git might not be available - that's okay
logger.debug('Git status not available:', error);
}
}, [effectivePath, setGitStatusMap, setEnhancedGitStatusMap, setGitBranch]);
}, [effectivePath]);
// ─── Load subdirectory children lazily ───────────────────────
const loadSubdirectory = useCallback(async (dirPath: string): Promise<FileTreeNode[]> => {
@@ -448,6 +460,33 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
[handleFileSelect, isMobile, setMobileBrowserVisible]
);
// ─── Load File Diff for Inline Display ───────────────────────────────────
const loadFileDiff = useCallback(
async (filePath: string) => {
if (!effectivePath) return;
const { setActiveFileDiff } = useFileEditorStore.getState();
try {
const api = getElectronAPI();
if (!api.git?.getFileDiff) return;
// Get relative path
const relativePath = filePath.startsWith(effectivePath)
? filePath.substring(effectivePath.length + 1)
: filePath;
const result = await api.git.getFileDiff(effectivePath, relativePath);
if (result.success && result.diff) {
setActiveFileDiff(result.diff);
} else {
setActiveFileDiff(null);
}
} catch {
setActiveFileDiff(null);
}
},
[effectivePath]
);
// ─── Handle Save ─────────────────────────────────────────────
const handleSave = useCallback(async () => {
if (!activeTab || !activeTab.isDirty) return;
@@ -458,15 +497,18 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
if (result.success) {
markTabSaved(activeTab.id, activeTab.content);
// Refresh git status after save
// Refresh git status and inline diff after save
loadGitStatus();
if (showInlineDiff) {
loadFileDiff(activeTab.filePath);
}
} else {
logger.error('Failed to save file:', result.error);
}
} catch (error) {
logger.error('Failed to save file:', error);
}
}, [activeTab, markTabSaved, loadGitStatus]);
}, [activeTab, markTabSaved, loadGitStatus, showInlineDiff, loadFileDiff]);
// ─── Auto Save: save a specific tab by ID ───────────────────
const saveTabById = useCallback(
@@ -482,6 +524,11 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
if (result.success) {
markTabSaved(tab.id, tab.content);
loadGitStatus();
// Refresh inline diff for the saved file if diff is active
const { showInlineDiff, activeTabId: currentActive } = useFileEditorStore.getState();
if (showInlineDiff && tab.id === currentActive) {
loadFileDiff(tab.filePath);
}
} else {
logger.error('Auto-save failed:', result.error);
}
@@ -489,7 +536,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
logger.error('Auto-save failed:', error);
}
},
[markTabSaved, loadGitStatus]
[markTabSaved, loadGitStatus, loadFileDiff]
);
// ─── Auto Save: on tab switch ──────────────────────────────
@@ -560,6 +607,151 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
}
}, []);
// ─── Get current branch from selected worktree ────────────
const currentBranch = useMemo(() => {
if (!currentProject?.path) return '';
const currentWorktreeInfo = getCurrentWorktree(currentProject.path);
const worktrees = worktreesByProject[currentProject.path] ?? [];
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
const selectedWorktree =
currentWorktreePath === null
? worktrees.find((w) => w.isMain)
: worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
return selectedWorktree?.branch || worktrees.find((w) => w.isMain)?.branch || '';
}, [currentProject?.path, getCurrentWorktree, worktreesByProject]);
// ─── Create Feature from Selection ─────────────────────────
const handleCreateFeatureFromSelection = useCallback(() => {
if (!activeTab || !editorRef.current || !effectivePath) return;
const selection = editorRef.current.getSelection();
if (!selection) return;
// Compute relative path from effectivePath
const relativePath = activeTab.filePath.startsWith(effectivePath)
? activeTab.filePath.substring(effectivePath.length + 1)
: activeTab.filePath.split('/').pop() || activeTab.filePath;
// Get language extension for code fence
const langName = getLanguageName(activeTab.filePath).toLowerCase();
const langMap: Record<string, string> = {
javascript: 'js',
jsx: 'jsx',
typescript: 'ts',
tsx: 'tsx',
python: 'py',
ruby: 'rb',
shell: 'sh',
'c++': 'cpp',
'plain text': '',
};
const fenceLang = langMap[langName] || langName;
// Truncate selection to ~200 lines
const lines = selection.text.split('\n');
const truncated = lines.length > 200;
const codeText = truncated ? lines.slice(0, 200).join('\n') + '\n[...]' : selection.text;
const description = [
`**File:** \`${relativePath}\` (Lines ${selection.fromLine}-${selection.toLine})`,
'',
`\`\`\`${fenceLang}`,
codeText,
'```',
truncated ? `\n*Selection truncated (${lines.length} lines total)*` : '',
'',
'---',
'',
]
.filter((line) => line !== undefined)
.join('\n');
setFeatureSelectionContext(description);
setShowAddFeatureDialog(true);
}, [activeTab, effectivePath]);
// ─── Handle feature creation from AddFeatureDialog ─────────
const handleAddFeatureFromEditor = useCallback(
async (featureData: {
title: string;
category: string;
description: string;
priority: number;
model: string;
thinkingLevel: string;
reasoningEffort: string;
skipTests: boolean;
branchName: string;
planningMode: string;
requirePlanApproval: boolean;
excludedPipelineSteps?: string[];
workMode: string;
imagePaths?: Array<{ id: string; path: string; description?: string }>;
textFilePaths?: Array<{ id: string; path: string; description?: string }>;
}) => {
if (!currentProject?.path) {
toast.error('No project selected');
return;
}
try {
const api = getElectronAPI();
if (api.features?.create) {
const feature = {
id: `editor-${generateUUID()}`,
title: featureData.title,
description: featureData.description,
category: featureData.category,
status: 'backlog' as const,
passes: false,
priority: featureData.priority,
model: resolveModelString(featureData.model),
thinkingLevel: featureData.thinkingLevel,
reasoningEffort: featureData.reasoningEffort,
skipTests: featureData.skipTests,
branchName: featureData.workMode === 'current' ? currentBranch : featureData.branchName,
planningMode: featureData.planningMode,
requirePlanApproval: featureData.requirePlanApproval,
excludedPipelineSteps: featureData.excludedPipelineSteps,
...(featureData.imagePaths?.length ? { imagePaths: featureData.imagePaths } : {}),
...(featureData.textFilePaths?.length
? { textFilePaths: featureData.textFilePaths }
: {}),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await api.features.create(currentProject.path, feature as any);
if (result.success) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
toast.success(
`Created feature: ${featureData.title || featureData.description.slice(0, 50)}`,
{
action: {
label: 'View Board',
onClick: () => navigate({ to: '/board' }),
},
}
);
setShowAddFeatureDialog(false);
setFeatureSelectionContext(undefined);
} else {
toast.error(result.error || 'Failed to create feature');
}
}
} catch (err) {
logger.error('Create feature from editor error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to create feature');
}
},
[currentProject?.path, currentBranch, queryClient, navigate]
);
// ─── File Operations ─────────────────────────────────────────
const handleCreateFile = useCallback(
async (parentPath: string, name: string) => {
@@ -847,6 +1039,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
const loadFileGitDetails = useCallback(
async (filePath: string) => {
if (!effectivePath) return;
const { setActiveFileGitDetails } = useFileEditorStore.getState();
try {
const api = getElectronAPI();
if (!api.git?.getDetails) return;
@@ -866,7 +1059,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
setActiveFileGitDetails(null);
}
},
[effectivePath, setActiveFileGitDetails]
[effectivePath]
);
// Load git details when active tab changes
@@ -874,9 +1067,24 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
if (activeTab && !activeTab.isBinary) {
loadFileGitDetails(activeTab.filePath);
} else {
setActiveFileGitDetails(null);
useFileEditorStore.getState().setActiveFileGitDetails(null);
}
}, [activeTab?.filePath, activeTab?.isBinary, loadFileGitDetails, setActiveFileGitDetails]);
}, [activeTab?.filePath, activeTab?.isBinary, loadFileGitDetails]);
// Load file diff when inline diff is enabled and active tab changes
useEffect(() => {
if (showInlineDiff && activeTab && !activeTab.isBinary && !activeTab.isTooLarge) {
loadFileDiff(activeTab.filePath);
} else {
useFileEditorStore.getState().setActiveFileDiff(null);
}
}, [
showInlineDiff,
activeTab?.filePath,
activeTab?.isBinary,
activeTab?.isTooLarge,
loadFileDiff,
]);
// ─── Handle Cursor Change ────────────────────────────────────
// Stable callback to avoid recreating CodeMirror extensions on every render.
@@ -938,7 +1146,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
return n;
});
};
setFileTree(updateChildren(fileTree));
useFileEditorStore.getState().setFileTree(updateChildren(fileTree));
}
}
@@ -946,7 +1154,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
// on every render, which would make this useCallback's dependency unstable.
useFileEditorStore.getState().toggleFolder(path);
},
[loadSubdirectory, setFileTree]
[loadSubdirectory]
);
// ─── Initial Load ────────────────────────────────────────────
@@ -1088,6 +1296,8 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
onCursorChange={handleCursorChange}
onSave={handleSave}
scrollCursorIntoView={isMobile && isKeyboardOpen}
diffContent={showInlineDiff ? activeFileDiff : null}
onSelectionChange={setHasEditorSelection}
/>
</Panel>
<PanelResizeHandle
@@ -1119,6 +1329,8 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
onCursorChange={handleCursorChange}
onSave={handleSave}
scrollCursorIntoView={isMobile && isKeyboardOpen}
diffContent={showInlineDiff ? activeFileDiff : null}
onSelectionChange={setHasEditorSelection}
/>
)}
</>
@@ -1302,6 +1514,41 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
</Button>
)}
{/* Desktop: Inline Diff toggle */}
{activeTab &&
!activeTab.isBinary &&
!activeTab.isTooLarge &&
!(isMobile && mobileBrowserVisible) && (
<Button
variant={showInlineDiff ? 'default' : 'outline'}
size="sm"
onClick={() => setShowInlineDiff(!showInlineDiff)}
className="hidden lg:flex"
title={showInlineDiff ? 'Hide git diff highlighting' : 'Show git diff highlighting'}
>
<Diff className="w-4 h-4 mr-2" />
Diff
</Button>
)}
{/* Desktop: Create Feature from selection */}
{hasEditorSelection &&
activeTab &&
!activeTab.isBinary &&
!activeTab.isTooLarge &&
!(isMobile && mobileBrowserVisible) && (
<Button
variant="outline"
size="sm"
onClick={handleCreateFeatureFromSelection}
className="hidden lg:flex"
title="Create a board feature from the selected code"
>
<FolderKanban className="w-4 h-4 mr-2" />
Create Feature
</Button>
)}
{/* Editor Settings popover */}
<Popover>
<PopoverTrigger asChild>
@@ -1415,6 +1662,37 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
</Button>
)}
{/* Inline Diff toggle */}
{activeTab && !activeTab.isBinary && !activeTab.isTooLarge && (
<button
onClick={() => setShowInlineDiff(!showInlineDiff)}
className={cn(
'flex items-center gap-2 w-full p-2 rounded-lg border transition-colors text-sm',
showInlineDiff
? 'bg-primary/10 border-primary/30 text-primary'
: 'bg-muted/30 border-border text-muted-foreground hover:text-foreground'
)}
>
<Diff className="w-4 h-4" />
<span>{showInlineDiff ? 'Hide Git Diff' : 'Show Git Diff'}</span>
</button>
)}
{/* Create Feature from selection */}
{hasEditorSelection && activeTab && !activeTab.isBinary && !activeTab.isTooLarge && (
<Button
variant="outline"
className="w-full justify-start"
onClick={() => {
handleCreateFeatureFromSelection();
setShowActionsPanel(false);
}}
>
<FolderKanban className="w-4 h-4 mr-2" />
Create Feature from Selection
</Button>
)}
{/* File info */}
{activeTab && !activeTab.isBinary && !activeTab.isTooLarge && (
<div className="flex flex-col gap-1.5 p-3 rounded-lg bg-muted/30 border border-border">
@@ -1478,6 +1756,27 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
</Panel>
</PanelGroup>
)}
{/* Add Feature Dialog - opened from code selection */}
<AddFeatureDialog
open={showAddFeatureDialog}
onOpenChange={(open) => {
setShowAddFeatureDialog(open);
if (!open) {
setFeatureSelectionContext(undefined);
}
}}
onAdd={handleAddFeatureFromEditor}
categorySuggestions={['From Editor']}
branchSuggestions={[]}
defaultSkipTests={defaultSkipTests}
defaultBranch={currentBranch}
currentBranch={currentBranch || undefined}
isMaximized={false}
projectPath={currentProject?.path}
prefilledDescription={featureSelectionContext}
prefilledCategory="From Editor"
/>
</div>
);
}

View File

@@ -101,6 +101,12 @@ interface FileEditorState {
// Git details for the currently active file (loaded on demand)
activeFileGitDetails: GitFileDetailsInfo | null;
// Inline diff display
/** Whether to show inline git diffs in the editor */
showInlineDiff: boolean;
/** The diff content for the active file (raw unified diff) */
activeFileDiff: string | null;
// Drag and drop state
dragState: DragState;
@@ -135,6 +141,9 @@ interface FileEditorState {
setGitBranch: (branch: string) => void;
setActiveFileGitDetails: (details: GitFileDetailsInfo | null) => void;
setShowInlineDiff: (show: boolean) => void;
setActiveFileDiff: (diff: string | null) => void;
setDragState: (state: DragState) => void;
setSelectedPaths: (paths: Set<string>) => void;
toggleSelectedPath: (path: string) => void;
@@ -159,6 +168,8 @@ const initialState = {
enhancedGitStatusMap: new Map<string, EnhancedGitFileStatus>(),
gitBranch: '',
activeFileGitDetails: null as GitFileDetailsInfo | null,
showInlineDiff: false,
activeFileDiff: null as string | null,
dragState: { draggedPaths: [], dropTargetPath: null } as DragState,
selectedPaths: new Set<string>(),
};
@@ -206,8 +217,18 @@ export const useFileEditorStore = create<FileEditorState>()(
const id = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const newTab: EditorTab = { ...tabData, id };
let updatedTabs = [...tabs, newTab];
// Enforce max open tabs evict the oldest non-dirty tab when over the limit
const MAX_TABS = 25;
while (updatedTabs.length > MAX_TABS) {
const evictIdx = updatedTabs.findIndex((t) => t.id !== id && !t.isDirty);
if (evictIdx === -1) break; // all other tabs are dirty, keep them
updatedTabs.splice(evictIdx, 1);
}
set({
tabs: [...tabs, newTab],
tabs: updatedTabs,
activeTabId: id,
});
},
@@ -282,6 +303,9 @@ export const useFileEditorStore = create<FileEditorState>()(
setGitBranch: (branch) => set({ gitBranch: branch }),
setActiveFileGitDetails: (details) => set({ activeFileGitDetails: details }),
setShowInlineDiff: (show) => set({ showInlineDiff: show }),
setActiveFileDiff: (diff) => set({ activeFileDiff: diff }),
setDragState: (state) => set({ dragState: state }),
setSelectedPaths: (paths) => set({ selectedPaths: paths }),
toggleSelectedPath: (path) => {

View File

@@ -1,8 +1,9 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Slider } from '@/components/ui/slider';
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
import {
GitBranch,
@@ -15,6 +16,8 @@ import {
Copy,
Plus,
FolderOpen,
LayoutGrid,
Pin,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
@@ -64,6 +67,10 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
const copyFiles = copyFilesFromStore ?? EMPTY_FILES;
const setWorktreeCopyFiles = useAppStore((s) => s.setWorktreeCopyFiles);
// Worktree display settings
const pinnedWorktreesCount = useAppStore((s) => s.getPinnedWorktreesCount(project.path));
const setPinnedWorktreesCount = useAppStore((s) => s.setPinnedWorktreesCount);
// Get effective worktrees setting (project override or global fallback)
const effectiveUseWorktrees = projectUseWorktrees ?? globalUseWorktrees;
@@ -78,6 +85,9 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
const [newCopyFilePath, setNewCopyFilePath] = useState('');
const [fileSelectorOpen, setFileSelectorOpen] = useState(false);
// Ref for storing previous slider value for rollback on error
const sliderPrevRef = useRef<number | null>(null);
// Check if there are unsaved changes
const hasChanges = scriptContent !== originalContent;
@@ -115,6 +125,9 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
if (response.settings.worktreeCopyFiles !== undefined) {
setWorktreeCopyFiles(currentPath, response.settings.worktreeCopyFiles);
}
if (response.settings.pinnedWorktreesCount !== undefined) {
setPinnedWorktreesCount(currentPath, response.settings.pinnedWorktreesCount);
}
}
} catch (error) {
if (!isCancelled) {
@@ -135,6 +148,7 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
setDefaultDeleteBranch,
setAutoDismissInitScriptIndicator,
setWorktreeCopyFiles,
setPinnedWorktreesCount,
]);
// Load init script content when project changes
@@ -507,6 +521,78 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
{/* Separator */}
<div className="border-t border-border/30" />
{/* Worktree Display Settings */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<LayoutGrid className="w-4 h-4 text-brand-500" />
<Label className="text-foreground font-medium">Display Settings</Label>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Control how worktrees are presented in the panel. Pinned worktrees appear as tabs, and
remaining worktrees are available in a combined overflow dropdown.
</p>
{/* Pinned Worktrees Count */}
<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="mt-0.5">
<Pin className="w-4 h-4 text-brand-500" />
</div>
<div className="space-y-2 flex-1">
<div className="flex items-center justify-between">
<Label
htmlFor="pinned-worktrees-count"
className="text-foreground cursor-pointer font-medium"
>
Pinned Worktree Tabs
</Label>
<span className="text-sm font-medium text-foreground tabular-nums">
{pinnedWorktreesCount}
</span>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Number of worktree tabs to pin (excluding the main worktree, which is always shown).
</p>
<Slider
id="pinned-worktrees-count"
min={0}
max={25}
step={1}
value={[pinnedWorktreesCount]}
onValueChange={(value) => {
// Capture previous value before mutation for potential rollback
const prevCount = pinnedWorktreesCount;
// Update local state immediately for visual feedback
const newValue = value[0] ?? pinnedWorktreesCount;
setPinnedWorktreesCount(project.path, newValue);
// Store prev for onValueCommit rollback
sliderPrevRef.current = prevCount;
}}
onValueCommit={async (value) => {
const newValue = value[0] ?? pinnedWorktreesCount;
const prev = sliderPrevRef.current ?? pinnedWorktreesCount;
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
pinnedWorktreesCount: newValue,
});
} catch (error) {
console.error('Failed to persist pinnedWorktreesCount:', error);
toast.error('Failed to save pinned worktrees setting');
// Rollback optimistic update using captured previous value
setPinnedWorktreesCount(project.path, prev);
}
}}
className="w-full"
/>
</div>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Copy Files Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">

View File

@@ -906,6 +906,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
if (result.success) {
removeRunningTask(currentProject.id, branchName, featureId);
logger.info('Feature stopped successfully:', featureId);
addAutoModeActivity({
featureId,

View File

@@ -15,6 +15,7 @@ interface UseElectronAgentOptions {
model?: string;
thinkingLevel?: string;
onToolUse?: (toolName: string, toolInput: unknown) => void;
onToolResult?: (toolName: string, result: unknown) => void;
}
// Server-side queued prompt type
@@ -72,6 +73,7 @@ export function useElectronAgent({
model,
thinkingLevel,
onToolUse,
onToolResult,
}: UseElectronAgentOptions): UseElectronAgentResult {
const [messages, setMessages] = useState<Message[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
@@ -308,6 +310,12 @@ export function useElectronAgent({
onToolUse?.(event.tool.name, event.tool.input);
break;
case 'tool_result':
// Tool completed - surface result via onToolResult callback
logger.info('Tool result:', event.tool.name);
onToolResult?.(event.tool.name, event.tool.input);
break;
case 'complete':
// Agent finished processing for THIS session
logger.info('Processing complete for session:', sessionId);
@@ -366,7 +374,7 @@ export function useElectronAgent({
unsubscribeRef.current = null;
}
};
}, [sessionId, onToolUse]);
}, [sessionId, onToolUse, onToolResult]);
// Send a message to the agent
const sendMessage = useCallback(

View File

@@ -26,6 +26,10 @@ export function useProjectSettingsLoader() {
(state) => state.setAutoDismissInitScriptIndicator
);
const setWorktreeCopyFiles = useAppStore((state) => state.setWorktreeCopyFiles);
const setProjectUseWorktrees = useAppStore((state) => state.setProjectUseWorktrees);
const setPinnedWorktreesCount = useAppStore((state) => state.setPinnedWorktreesCount);
const setWorktreeDropdownThreshold = useAppStore((state) => state.setWorktreeDropdownThreshold);
const setAlwaysUseWorktreeDropdown = useAppStore((state) => state.setAlwaysUseWorktreeDropdown);
const appliedProjectRef = useRef<{ path: string; dataUpdatedAt: number } | null>(null);
@@ -100,6 +104,24 @@ export function useProjectSettingsLoader() {
setWorktreeCopyFiles(projectPath, settings.worktreeCopyFiles);
}
// Apply useWorktrees if present
if (settings.useWorktrees !== undefined) {
setProjectUseWorktrees(projectPath, settings.useWorktrees);
}
// Apply worktree display settings if present
if (settings.pinnedWorktreesCount !== undefined) {
setPinnedWorktreesCount(projectPath, settings.pinnedWorktreesCount);
}
if (settings.worktreeDropdownThreshold !== undefined) {
setWorktreeDropdownThreshold(projectPath, settings.worktreeDropdownThreshold);
}
if (settings.alwaysUseWorktreeDropdown !== undefined) {
setAlwaysUseWorktreeDropdown(projectPath, settings.alwaysUseWorktreeDropdown);
}
// Apply activeClaudeApiProfileId and phaseModelOverrides if present
// These are stored directly on the project, so we need to update both
// currentProject AND the projects array to keep them in sync
@@ -167,5 +189,9 @@ export function useProjectSettingsLoader() {
setDefaultDeleteBranch,
setAutoDismissInitScriptIndicator,
setWorktreeCopyFiles,
setProjectUseWorktrees,
setPinnedWorktreesCount,
setWorktreeDropdownThreshold,
setAlwaysUseWorktreeDropdown,
]);
}

View File

@@ -750,6 +750,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false,
defaultFeatureModel: migratePhaseModelEntry(settings.defaultFeatureModel) ?? {
model: 'claude-opus',
thinkingLevel: 'adaptive',
},
muteDoneSound: settings.muteDoneSound ?? false,
disableSplashScreen: settings.disableSplashScreen ?? false,
@@ -759,7 +760,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
enhancementModel: settings.enhancementModel ?? 'claude-sonnet',
validationModel: settings.validationModel ?? 'claude-opus',
phaseModels: { ...DEFAULT_PHASE_MODELS, ...(settings.phaseModels ?? current.phaseModels) },
defaultThinkingLevel: settings.defaultThinkingLevel ?? 'none',
defaultThinkingLevel: settings.defaultThinkingLevel ?? 'adaptive',
defaultReasoningEffort: settings.defaultReasoningEffort ?? 'none',
enabledCursorModels: allCursorModels, // Always use ALL cursor models
cursorDefaultModel: sanitizedCursorDefaultModel,
@@ -805,7 +806,11 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
// (error boundary reloads → restores same bad path → crash again).
// The use-worktrees validation effect will re-discover valid worktrees
// from the server once they load.
currentWorktreeByProject: sanitizeWorktreeByProject(settings.currentWorktreeByProject),
currentWorktreeByProject: Object.fromEntries(
Object.entries(sanitizeWorktreeByProject(settings.currentWorktreeByProject)).filter(
([, worktree]) => worktree.path === null
)
),
// UI State
worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false,
lastProjectDir: settings.lastProjectDir ?? '',

View File

@@ -75,6 +75,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
'enhancementModel',
'validationModel',
'phaseModels',
'defaultThinkingLevel',
'defaultReasoningEffort',
'enabledCursorModels',
'cursorDefaultModel',
'enabledOpencodeModels',
@@ -781,9 +783,9 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval,
defaultFeatureModel: serverSettings.defaultFeatureModel
? migratePhaseModelEntry(serverSettings.defaultFeatureModel)
: { model: 'claude-opus' },
: { model: 'claude-opus', thinkingLevel: 'adaptive' },
muteDoneSound: serverSettings.muteDoneSound,
defaultMaxTurns: serverSettings.defaultMaxTurns ?? 1000,
defaultMaxTurns: serverSettings.defaultMaxTurns ?? 10000,
disableSplashScreen: serverSettings.disableSplashScreen ?? false,
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
@@ -793,6 +795,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
...DEFAULT_PHASE_MODELS,
...(migratedPhaseModels ?? serverSettings.phaseModels),
},
defaultThinkingLevel: serverSettings.defaultThinkingLevel ?? 'adaptive',
defaultReasoningEffort: serverSettings.defaultReasoningEffort ?? 'none',
enabledCursorModels: allCursorModels, // Always use ALL cursor models
cursorDefaultModel: sanitizedCursorDefault,
enabledOpencodeModels: sanitizedEnabledOpencodeModels,

View File

@@ -0,0 +1,155 @@
/**
* Shared CodeMirror language detection utilities.
*
* Extracted from code-editor.tsx so that both the file editor and
* the diff viewer can resolve language extensions from file paths.
*/
import type { Extension } from '@codemirror/state';
import { javascript } from '@codemirror/lang-javascript';
import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { python } from '@codemirror/lang-python';
import { java } from '@codemirror/lang-java';
import { rust } from '@codemirror/lang-rust';
import { cpp } from '@codemirror/lang-cpp';
import { sql } from '@codemirror/lang-sql';
import { php } from '@codemirror/lang-php';
import { xml } from '@codemirror/lang-xml';
import { StreamLanguage } from '@codemirror/language';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { go } from '@codemirror/legacy-modes/mode/go';
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
import { swift } from '@codemirror/legacy-modes/mode/swift';
/** Detect language extension based on file extension */
export function getLanguageExtension(filePath: string): Extension | null {
const name = filePath.split(/[/\\]/).pop()?.toLowerCase() || '';
const dotIndex = name.lastIndexOf('.');
// Files without an extension (no dot, or dotfile with dot at position 0)
const ext = dotIndex > 0 ? name.slice(dotIndex + 1) : '';
// Handle files by name first
switch (name) {
case 'dockerfile':
case 'dockerfile.dev':
case 'dockerfile.prod':
return StreamLanguage.define(dockerFile);
case 'makefile':
case 'gnumakefile':
return StreamLanguage.define(shell);
case '.gitignore':
case '.dockerignore':
case '.npmignore':
case '.eslintignore':
return StreamLanguage.define(shell);
case '.env':
case '.env.local':
case '.env.development':
case '.env.production':
return StreamLanguage.define(shell);
}
switch (ext) {
// JavaScript/TypeScript
case 'js':
case 'mjs':
case 'cjs':
return javascript();
case 'jsx':
return javascript({ jsx: true });
case 'ts':
case 'mts':
case 'cts':
return javascript({ typescript: true });
case 'tsx':
return javascript({ jsx: true, typescript: true });
// Web
case 'html':
case 'htm':
case 'svelte':
case 'vue':
return html();
case 'css':
case 'scss':
case 'less':
return css();
case 'json':
case 'jsonc':
case 'json5':
return json();
case 'xml':
case 'svg':
case 'xsl':
case 'xslt':
case 'plist':
return xml();
// Markdown
case 'md':
case 'mdx':
case 'markdown':
return markdown();
// Python
case 'py':
case 'pyx':
case 'pyi':
return python();
// Java/Kotlin
case 'java':
case 'kt':
case 'kts':
return java();
// Systems
case 'rs':
return rust();
case 'c':
case 'h':
return cpp();
case 'cpp':
case 'cc':
case 'cxx':
case 'hpp':
case 'hxx':
return cpp();
case 'go':
return StreamLanguage.define(go);
case 'swift':
return StreamLanguage.define(swift);
// Scripting
case 'rb':
case 'erb':
return StreamLanguage.define(ruby);
case 'php':
return php();
case 'sh':
case 'bash':
case 'zsh':
case 'fish':
return StreamLanguage.define(shell);
// Data
case 'sql':
case 'mysql':
case 'pgsql':
return sql();
case 'yaml':
case 'yml':
return StreamLanguage.define(yaml);
case 'toml':
return StreamLanguage.define(toml);
default:
return null; // Plain text fallback
}
}

View File

@@ -131,3 +131,130 @@ export function parseDiff(diffText: string): ParsedFileDiff[] {
return files;
}
/**
* Reconstruct old (original) and new (modified) file content from a single-file
* unified diff string. Used by the CodeMirror merge diff viewer which needs
* both document versions to compute inline highlighting.
*
* For new files (entire content is additions), oldContent will be empty.
* For deleted files (entire content is deletions), newContent will be empty.
*/
export function reconstructFilesFromDiff(diffText: string): {
oldContent: string;
newContent: string;
} {
if (!diffText) return { oldContent: '', newContent: '' };
const lines = diffText.split('\n');
const oldLines: string[] = [];
const newLines: string[] = [];
let inHunk = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip diff header lines
if (
line.startsWith('diff --git') ||
line.startsWith('index ') ||
line.startsWith('--- ') ||
line.startsWith('+++ ') ||
line.startsWith('new file mode') ||
line.startsWith('deleted file mode') ||
line.startsWith('rename from') ||
line.startsWith('rename to') ||
line.startsWith('similarity index') ||
line.startsWith('old mode') ||
line.startsWith('new mode')
) {
continue;
}
// Hunk header
if (line.startsWith('@@')) {
inHunk = true;
continue;
}
if (!inHunk) continue;
// Skip trailing empty line produced by split('\n')
if (line === '' && i === lines.length - 1) {
continue;
}
// "\ No newline at end of file" marker
if (line.startsWith('\\')) {
continue;
}
if (line.startsWith('+')) {
newLines.push(line.substring(1));
} else if (line.startsWith('-')) {
oldLines.push(line.substring(1));
} else {
// Context line (starts with space or is empty within hunk)
const content = line.startsWith(' ') ? line.substring(1) : line;
oldLines.push(content);
newLines.push(content);
}
}
return {
oldContent: oldLines.join('\n'),
newContent: newLines.join('\n'),
};
}
/**
* Split a combined multi-file diff string into per-file diff strings.
* Each entry in the returned array is a complete diff block for a single file.
*/
export function splitDiffByFile(
combinedDiff: string
): { filePath: string; diff: string; isNew: boolean; isDeleted: boolean }[] {
if (!combinedDiff) return [];
const results: { filePath: string; diff: string; isNew: boolean; isDeleted: boolean }[] = [];
const lines = combinedDiff.split('\n');
let currentLines: string[] = [];
let currentFilePath = '';
let currentIsNew = false;
let currentIsDeleted = false;
for (const line of lines) {
if (line.startsWith('diff --git')) {
// Push previous file if exists
if (currentLines.length > 0 && currentFilePath) {
results.push({
filePath: currentFilePath,
diff: currentLines.join('\n'),
isNew: currentIsNew,
isDeleted: currentIsDeleted,
});
}
currentLines = [line];
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFilePath = match ? match[2] : 'unknown';
currentIsNew = false;
currentIsDeleted = false;
} else {
if (line.startsWith('new file mode')) currentIsNew = true;
if (line.startsWith('deleted file mode')) currentIsDeleted = true;
currentLines.push(line);
}
}
// Push last file
if (currentLines.length > 0 && currentFilePath) {
results.push({
filePath: currentFilePath,
diff: currentLines.join('\n'),
isNew: currentIsNew,
isDeleted: currentIsDeleted,
});
}
return results;
}

View File

@@ -2334,6 +2334,23 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
updatePRNumber: async (worktreePath: string, prNumber: number, projectPath?: string) => {
console.log('[Mock] Updating PR number:', { worktreePath, prNumber, projectPath });
return {
success: true,
result: {
branch: 'feature-branch',
prInfo: {
number: prNumber,
url: `https://github.com/example/repo/pull/${prNumber}`,
title: `PR #${prNumber}`,
state: 'OPEN',
createdAt: new Date().toISOString(),
},
},
};
},
getDiffs: async (projectPath: string, featureId: string) => {
console.log('[Mock] Getting file diffs:', { projectPath, featureId });
return {

View File

@@ -2238,6 +2238,8 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/worktree/set-tracking', { worktreePath, remote, branch }),
createPR: (worktreePath: string, options?: CreatePROptions) =>
this.post('/api/worktree/create-pr', { worktreePath, ...options }),
updatePRNumber: (worktreePath: string, prNumber: number, projectPath?: string) =>
this.post('/api/worktree/update-pr-number', { worktreePath, prNumber, projectPath }),
getDiffs: (projectPath: string, featureId: string) =>
this.post('/api/worktree/diffs', { projectPath, featureId }),
getFileDiff: (projectPath: string, featureId: string, filePath: string) =>
@@ -2746,6 +2748,9 @@ export class HttpApiClient implements ElectronAPI {
defaultDeleteBranchWithWorktree?: boolean;
autoDismissInitScriptIndicator?: boolean;
worktreeCopyFiles?: string[];
pinnedWorktreesCount?: number;
worktreeDropdownThreshold?: number;
alwaysUseWorktreeDropdown?: boolean;
lastSelectedSessionId?: string;
testCommand?: string;
};

View File

@@ -36,6 +36,7 @@ import {
DEFAULT_COPILOT_MODEL,
DEFAULT_MAX_CONCURRENCY,
DEFAULT_GLOBAL_SETTINGS,
getThinkingLevelsForModel,
} from '@automaker/types';
// Import types from modular type files
@@ -371,9 +372,9 @@ const initialState: AppState = {
defaultPlanningMode: 'skip' as PlanningMode,
defaultRequirePlanApproval: false,
defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel,
defaultThinkingLevel: DEFAULT_GLOBAL_SETTINGS.defaultThinkingLevel ?? 'none',
defaultThinkingLevel: DEFAULT_GLOBAL_SETTINGS.defaultThinkingLevel ?? 'adaptive',
defaultReasoningEffort: DEFAULT_GLOBAL_SETTINGS.defaultReasoningEffort ?? 'none',
defaultMaxTurns: DEFAULT_GLOBAL_SETTINGS.defaultMaxTurns ?? 1000,
defaultMaxTurns: DEFAULT_GLOBAL_SETTINGS.defaultMaxTurns ?? 10000,
pendingPlanApproval: null,
claudeRefreshInterval: 60,
claudeUsage: null,
@@ -396,6 +397,10 @@ const initialState: AppState = {
autoDismissInitScriptIndicatorByProject: {},
useWorktreesByProject: {},
worktreeCopyFilesByProject: {},
pinnedWorktreesCountByProject: {},
pinnedWorktreeBranchesByProject: {},
worktreeDropdownThresholdByProject: {},
alwaysUseWorktreeDropdownByProject: {},
worktreePanelCollapsed: false,
lastProjectDir: '',
recentFolders: [],
@@ -2453,7 +2458,20 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
setDefaultFeatureModel: (entry) => set({ defaultFeatureModel: entry }),
setDefaultThinkingLevel: async (level) => {
set({ defaultThinkingLevel: level });
const currentModel = get().defaultFeatureModel;
const modelId = currentModel.model;
const availableLevels = getThinkingLevelsForModel(modelId);
// Also update defaultFeatureModel's thinkingLevel if compatible
if (availableLevels.includes(level)) {
set({
defaultThinkingLevel: level,
defaultFeatureModel: { ...currentModel, thinkingLevel: level },
});
} else {
set({ defaultThinkingLevel: level });
}
// Sync to server
try {
const httpApi = getHttpApiClient();
@@ -2478,7 +2496,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
// 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)));
const clamped = Math.max(1, Math.min(10000, Math.floor(safeValue)));
set({ defaultMaxTurns: clamped });
// Sync to server
try {
@@ -2641,6 +2659,65 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})),
getWorktreeCopyFiles: (projectPath) => get().worktreeCopyFilesByProject[projectPath] ?? [],
// Worktree Display Settings actions
setPinnedWorktreesCount: (projectPath, count) =>
set((state) => ({
pinnedWorktreesCountByProject: {
...state.pinnedWorktreesCountByProject,
[projectPath]: count,
},
})),
getPinnedWorktreesCount: (projectPath) => get().pinnedWorktreesCountByProject[projectPath] ?? 0,
setPinnedWorktreeBranches: (projectPath, branches) =>
set((state) => ({
pinnedWorktreeBranchesByProject: {
...state.pinnedWorktreeBranchesByProject,
[projectPath]: branches,
},
})),
getPinnedWorktreeBranches: (projectPath) =>
get().pinnedWorktreeBranchesByProject[projectPath] ?? [],
swapPinnedWorktreeBranch: (projectPath, slotIndex, newBranch) =>
set((state) => {
const src = state.pinnedWorktreeBranchesByProject[projectPath] ?? [];
// Pre-fill up to slotIndex to prevent sparse holes
const current: string[] = Array.from(
{ length: Math.max(src.length, slotIndex + 1) },
(_, i) => src[i] ?? ''
);
// If the new branch is already in another slot, swap them (only when newBranch is non-empty)
const existingIndex = newBranch !== '' ? current.indexOf(newBranch) : -1;
if (existingIndex !== -1 && existingIndex !== slotIndex) {
// Swap: put the old branch from this slot into the other slot
current[existingIndex] = current[slotIndex];
}
current[slotIndex] = newBranch;
return {
pinnedWorktreeBranchesByProject: {
...state.pinnedWorktreeBranchesByProject,
[projectPath]: current,
},
};
}),
setWorktreeDropdownThreshold: (projectPath, threshold) =>
set((state) => ({
worktreeDropdownThresholdByProject: {
...state.worktreeDropdownThresholdByProject,
[projectPath]: threshold,
},
})),
getWorktreeDropdownThreshold: (projectPath) =>
get().worktreeDropdownThresholdByProject[projectPath] ?? 3,
setAlwaysUseWorktreeDropdown: (projectPath, always) =>
set((state) => ({
alwaysUseWorktreeDropdownByProject: {
...state.alwaysUseWorktreeDropdownByProject,
[projectPath]: always,
},
})),
getAlwaysUseWorktreeDropdown: (projectPath) =>
get().alwaysUseWorktreeDropdownByProject[projectPath] ?? true,
// UI State actions
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),

View File

@@ -370,6 +370,17 @@ export interface AppState {
// List of relative file paths to copy from project root into new worktrees
worktreeCopyFilesByProject: Record<string, string[]>;
// Worktree Display Settings (per-project, keyed by project path)
// Number of worktrees always visible (pinned) without expanding a dropdown (default: 1)
pinnedWorktreesCountByProject: Record<string, number>;
// Explicit list of branch names assigned to pinned slots (ordered)
// When set, these branches are shown in the pinned slots instead of using default ordering
pinnedWorktreeBranchesByProject: Record<string, string[]>;
// Minimum number of worktrees before the list collapses into a dropdown (default: 3)
worktreeDropdownThresholdByProject: Record<string, number>;
// Always use dropdown layout regardless of worktree count (default: false)
alwaysUseWorktreeDropdownByProject: Record<string, boolean>;
// UI State (previously in localStorage, now synced via API)
/** Whether worktree panel is collapsed in board view */
worktreePanelCollapsed: boolean;
@@ -814,6 +825,17 @@ export interface AppActions {
setWorktreeCopyFiles: (projectPath: string, files: string[]) => void;
getWorktreeCopyFiles: (projectPath: string) => string[];
// Worktree Display Settings actions (per-project)
setPinnedWorktreesCount: (projectPath: string, count: number) => void;
getPinnedWorktreesCount: (projectPath: string) => number;
setPinnedWorktreeBranches: (projectPath: string, branches: string[]) => void;
getPinnedWorktreeBranches: (projectPath: string) => string[];
swapPinnedWorktreeBranch: (projectPath: string, slotIndex: number, newBranch: string) => void;
setWorktreeDropdownThreshold: (projectPath: string, threshold: number) => void;
getWorktreeDropdownThreshold: (projectPath: string) => number;
setAlwaysUseWorktreeDropdown: (projectPath: string, always: boolean) => void;
getAlwaysUseWorktreeDropdown: (projectPath: string) => boolean;
// UI State actions (previously in localStorage, now synced via API)
setWorktreePanelCollapsed: (collapsed: boolean) => void;
setLastProjectDir: (dir: string) => void;

View File

@@ -33,6 +33,14 @@ export interface ToolUse {
input: unknown;
}
export interface ToolResult {
name: string;
input: {
toolUseId?: string;
content: string;
};
}
export type StreamEvent =
| {
type: 'message';
@@ -51,6 +59,11 @@ export type StreamEvent =
sessionId: string;
tool: ToolUse;
}
| {
type: 'tool_result';
sessionId: string;
tool: ToolResult;
}
| {
type: 'complete';
sessionId: string;
@@ -1075,6 +1088,27 @@ export interface WorktreeAPI {
error?: string;
}>;
// Update the tracked PR number for a worktree branch
updatePRNumber: (
worktreePath: string,
prNumber: number,
projectPath?: string
) => Promise<{
success: boolean;
result?: {
branch: string;
prInfo: {
number: number;
url: string;
title: string;
state: string;
createdAt: string;
};
ghCliUnavailable?: boolean;
};
error?: string;
}>;
// Get file diffs for a feature worktree
getDiffs: (projectPath: string, featureId: string) => Promise<FileDiffsResult>;

View File

@@ -16,54 +16,98 @@ import {
handleLoginScreenIfPresent,
} from '../utils';
/**
* Helper to build overview API response bodies.
* Each test sets `overviewMock` before navigating so the single
* route handler registered in `beforeEach` returns the right data.
*/
function makeOverviewResponse(
overrides: {
projects?: unknown[];
aggregate?: Record<string, unknown>;
recentActivity?: unknown[];
status?: number;
error?: string;
} = {}
) {
const { projects = [], aggregate, recentActivity = [], status = 200, error } = overrides;
const defaultAggregate = {
projectCounts: { total: 0, active: 0, idle: 0, waiting: 0, withErrors: 0, allCompleted: 0 },
featureCounts: { total: 0, pending: 0, running: 0, completed: 0, failed: 0, verified: 0 },
totalUnreadNotifications: 0,
projectsWithAutoModeRunning: 0,
computedAt: new Date().toISOString(),
};
return {
status,
body: error
? JSON.stringify({ error })
: JSON.stringify({
success: true,
projects,
aggregate: aggregate ?? defaultAggregate,
recentActivity,
generatedAt: new Date().toISOString(),
}),
};
}
test.describe('Projects Overview Dashboard', () => {
// Mutable mock response - tests set this before navigating.
// The single route handler in beforeEach reads it on every request.
let overviewMock: { status: number; body: string };
test.beforeEach(async ({ page }) => {
// Start with an empty default
overviewMock = makeOverviewResponse();
// Set up mock projects state
await setupMockMultipleProjects(page, 3);
// Intercept settings API to preserve mock project data and prevent
// the server's settings from overriding our test setup.
// Without this, background reconciliation can clear the mock projects.
await page.route('**/api/settings/global', async (route) => {
const method = route.request().method();
if (method === 'PUT') {
// Allow settings sync writes to pass through
return route.continue();
}
const response = await route.fetch();
const json = await response.json();
if (json.settings) {
// Always overwrite projects with mock data so CI-provided projects
// that don't contain 'test-project-1' can't break hydration.
json.settings.projects = [
{
id: 'test-project-1',
name: 'Test Project 1',
path: '/mock/test-project-1',
lastOpened: new Date().toISOString(),
},
{
id: 'test-project-2',
name: 'Test Project 2',
path: '/mock/test-project-2',
lastOpened: new Date(Date.now() - 86400000).toISOString(),
},
{
id: 'test-project-3',
name: 'Test Project 3',
path: '/mock/test-project-3',
lastOpened: new Date(Date.now() - 172800000).toISOString(),
},
];
json.settings.currentProjectId = 'test-project-1';
json.settings.setupComplete = true;
json.settings.isFirstRun = false;
try {
const response = await route.fetch();
const json = await response.json();
if (json.settings) {
json.settings.projects = [
{
id: 'test-project-1',
name: 'Test Project 1',
path: '/mock/test-project-1',
lastOpened: new Date().toISOString(),
},
{
id: 'test-project-2',
name: 'Test Project 2',
path: '/mock/test-project-2',
lastOpened: new Date(Date.now() - 86400000).toISOString(),
},
{
id: 'test-project-3',
name: 'Test Project 3',
path: '/mock/test-project-3',
lastOpened: new Date(Date.now() - 172800000).toISOString(),
},
];
json.settings.currentProjectId = 'test-project-1';
json.settings.setupComplete = true;
json.settings.isFirstRun = false;
}
await route.fulfill({ response, json });
} catch {
// Route may be called after test ends; swallow errors from closed context
}
await route.fulfill({ response, json });
});
// Mock the initialize-project endpoint for mock paths that don't exist on disk.
// This prevents auto-open from failing when it tries to verify the project directory.
await page.route('**/api/project/initialize', async (route) => {
await route.fulfill({
status: 200,
@@ -81,44 +125,21 @@ test.describe('Projects Overview Dashboard', () => {
});
});
// Single overview route handler that reads from the mutable `overviewMock`.
// Tests update `overviewMock` before navigating to control the response.
await page.route('**/api/projects/overview', async (route) => {
await route.fulfill({
status: overviewMock.status,
contentType: 'application/json',
body: overviewMock.body,
});
});
await authenticateForTests(page);
});
test('should navigate to overview from sidebar and display overview UI', async ({ page }) => {
// Mock the projects overview API response
await page.route('**/api/projects/overview', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
projects: [],
aggregate: {
projectCounts: {
total: 0,
active: 0,
idle: 0,
waiting: 0,
withErrors: 0,
allCompleted: 0,
},
featureCounts: {
total: 0,
pending: 0,
running: 0,
completed: 0,
failed: 0,
verified: 0,
},
totalUnreadNotifications: 0,
projectsWithAutoModeRunning: 0,
computedAt: new Date().toISOString(),
},
recentActivity: [],
generatedAt: new Date().toISOString(),
}),
});
});
// Use default empty overview mock (set in beforeEach)
// Go to the app
await page.goto('/board');
@@ -136,7 +157,7 @@ test.describe('Projects Overview Dashboard', () => {
}
// Click on the Dashboard link in the sidebar (navigates to /overview)
const overviewLink = page.locator('[data-testid="nav-overview"]');
const overviewLink = page.getByRole('button', { name: 'Dashboard' });
await expect(overviewLink).toBeVisible({ timeout: 5000 });
await overviewLink.click();
@@ -149,78 +170,70 @@ test.describe('Projects Overview Dashboard', () => {
// Verify the refresh button is present
await expect(page.getByRole('button', { name: /Refresh/i })).toBeVisible();
// Verify the Open Project and New Project buttons are present
await expect(page.getByRole('button', { name: /Open Project/i })).toBeVisible();
await expect(page.getByRole('button', { name: /New Project/i })).toBeVisible();
// Verify the Open Project and New Project buttons are present in the overview header
const overviewHeader = page.locator('[data-testid="overview-view"] header');
await expect(overviewHeader.getByRole('button', { name: /Open Project/i })).toBeVisible();
await expect(overviewHeader.getByRole('button', { name: /New Project/i })).toBeVisible();
});
test('should display aggregate statistics cards', async ({ page }) => {
// Mock the projects overview API response
await page.route('**/api/projects/overview', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
projects: [
{
projectId: 'test-project-1',
projectName: 'Test Project 1',
projectPath: '/mock/test-project-1',
healthStatus: 'active',
featureCounts: { pending: 2, running: 1, completed: 3, failed: 0, verified: 2 },
totalFeatures: 8,
isAutoModeRunning: true,
unreadNotificationCount: 1,
},
{
projectId: 'test-project-2',
projectName: 'Test Project 2',
projectPath: '/mock/test-project-2',
healthStatus: 'idle',
featureCounts: { pending: 5, running: 0, completed: 10, failed: 1, verified: 8 },
totalFeatures: 24,
isAutoModeRunning: false,
unreadNotificationCount: 0,
},
],
aggregate: {
projectCounts: {
total: 2,
active: 1,
idle: 1,
waiting: 0,
withErrors: 1,
allCompleted: 0,
},
featureCounts: {
total: 32,
pending: 7,
running: 1,
completed: 13,
failed: 1,
verified: 10,
},
totalUnreadNotifications: 1,
projectsWithAutoModeRunning: 1,
computedAt: new Date().toISOString(),
},
recentActivity: [
{
id: 'activity-1',
projectId: 'test-project-1',
projectName: 'Test Project 1',
type: 'feature_completed',
description: 'Feature completed: Add login form',
severity: 'success',
timestamp: new Date().toISOString(),
featureId: 'feature-1',
featureTitle: 'Add login form',
},
],
generatedAt: new Date().toISOString(),
}),
});
overviewMock = makeOverviewResponse({
projects: [
{
projectId: 'test-project-1',
projectName: 'Test Project 1',
projectPath: '/mock/test-project-1',
healthStatus: 'active',
featureCounts: { pending: 2, running: 1, completed: 3, failed: 0, verified: 2 },
totalFeatures: 8,
isAutoModeRunning: true,
unreadNotificationCount: 1,
},
{
projectId: 'test-project-2',
projectName: 'Test Project 2',
projectPath: '/mock/test-project-2',
healthStatus: 'idle',
featureCounts: { pending: 5, running: 0, completed: 10, failed: 1, verified: 8 },
totalFeatures: 24,
isAutoModeRunning: false,
unreadNotificationCount: 0,
},
],
aggregate: {
projectCounts: {
total: 2,
active: 1,
idle: 1,
waiting: 0,
withErrors: 1,
allCompleted: 0,
},
featureCounts: {
total: 32,
pending: 7,
running: 1,
completed: 13,
failed: 1,
verified: 10,
},
totalUnreadNotifications: 1,
projectsWithAutoModeRunning: 1,
computedAt: new Date().toISOString(),
},
recentActivity: [
{
id: 'activity-1',
projectId: 'test-project-1',
projectName: 'Test Project 1',
type: 'feature_completed',
description: 'Feature completed: Add login form',
severity: 'success',
timestamp: new Date().toISOString(),
featureId: 'feature-1',
featureTitle: 'Add login form',
},
],
});
// Navigate directly to overview
@@ -249,50 +262,40 @@ test.describe('Projects Overview Dashboard', () => {
});
test('should display project status cards', async ({ page }) => {
// Mock the projects overview API response
await page.route('**/api/projects/overview', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
projects: [
{
projectId: 'test-project-1',
projectName: 'Test Project 1',
projectPath: '/mock/test-project-1',
healthStatus: 'active',
featureCounts: { pending: 2, running: 1, completed: 3, failed: 0, verified: 2 },
totalFeatures: 8,
isAutoModeRunning: true,
unreadNotificationCount: 1,
},
],
aggregate: {
projectCounts: {
total: 1,
active: 1,
idle: 0,
waiting: 0,
withErrors: 0,
allCompleted: 0,
},
featureCounts: {
total: 8,
pending: 2,
running: 1,
completed: 3,
failed: 0,
verified: 2,
},
totalUnreadNotifications: 1,
projectsWithAutoModeRunning: 1,
computedAt: new Date().toISOString(),
},
recentActivity: [],
generatedAt: new Date().toISOString(),
}),
});
overviewMock = makeOverviewResponse({
projects: [
{
projectId: 'test-project-1',
projectName: 'Test Project 1',
projectPath: '/mock/test-project-1',
healthStatus: 'active',
featureCounts: { pending: 2, running: 1, completed: 3, failed: 0, verified: 2 },
totalFeatures: 8,
isAutoModeRunning: true,
unreadNotificationCount: 1,
},
],
aggregate: {
projectCounts: {
total: 1,
active: 1,
idle: 0,
waiting: 0,
withErrors: 0,
allCompleted: 0,
},
featureCounts: {
total: 8,
pending: 2,
running: 1,
completed: 3,
failed: 0,
verified: 2,
},
totalUnreadNotifications: 1,
projectsWithAutoModeRunning: 1,
computedAt: new Date().toISOString(),
},
});
// Navigate directly to overview
@@ -318,50 +321,40 @@ test.describe('Projects Overview Dashboard', () => {
});
test('should navigate to board when clicking on a project card', async ({ page }) => {
// Mock the projects overview API response
await page.route('**/api/projects/overview', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
projects: [
{
projectId: 'test-project-1',
projectName: 'Test Project 1',
projectPath: '/mock/test-project-1',
healthStatus: 'idle',
featureCounts: { pending: 0, running: 0, completed: 0, failed: 0, verified: 0 },
totalFeatures: 0,
isAutoModeRunning: false,
unreadNotificationCount: 0,
},
],
aggregate: {
projectCounts: {
total: 1,
active: 0,
idle: 1,
waiting: 0,
withErrors: 0,
allCompleted: 0,
},
featureCounts: {
total: 0,
pending: 0,
running: 0,
completed: 0,
failed: 0,
verified: 0,
},
totalUnreadNotifications: 0,
projectsWithAutoModeRunning: 0,
computedAt: new Date().toISOString(),
},
recentActivity: [],
generatedAt: new Date().toISOString(),
}),
});
overviewMock = makeOverviewResponse({
projects: [
{
projectId: 'test-project-1',
projectName: 'Test Project 1',
projectPath: '/mock/test-project-1',
healthStatus: 'idle',
featureCounts: { pending: 0, running: 0, completed: 0, failed: 0, verified: 0 },
totalFeatures: 0,
isAutoModeRunning: false,
unreadNotificationCount: 0,
},
],
aggregate: {
projectCounts: {
total: 1,
active: 0,
idle: 1,
waiting: 0,
withErrors: 0,
allCompleted: 0,
},
featureCounts: {
total: 0,
pending: 0,
running: 0,
completed: 0,
failed: 0,
verified: 0,
},
totalUnreadNotifications: 0,
projectsWithAutoModeRunning: 0,
computedAt: new Date().toISOString(),
},
});
// Navigate directly to overview
@@ -378,40 +371,7 @@ test.describe('Projects Overview Dashboard', () => {
});
test('should display empty state when no projects exist', async ({ page }) => {
// Mock empty projects overview API response
await page.route('**/api/projects/overview', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
projects: [],
aggregate: {
projectCounts: {
total: 0,
active: 0,
idle: 0,
waiting: 0,
withErrors: 0,
allCompleted: 0,
},
featureCounts: {
total: 0,
pending: 0,
running: 0,
completed: 0,
failed: 0,
verified: 0,
},
totalUnreadNotifications: 0,
projectsWithAutoModeRunning: 0,
computedAt: new Date().toISOString(),
},
recentActivity: [],
generatedAt: new Date().toISOString(),
}),
});
});
// Default overviewMock already returns empty projects - no change needed
// Navigate directly to overview
await page.goto('/overview');
@@ -427,15 +387,9 @@ test.describe('Projects Overview Dashboard', () => {
});
test('should show error state when API fails', async ({ page }) => {
// Mock API error
await page.route('**/api/projects/overview', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({
error: 'Internal server error',
}),
});
overviewMock = makeOverviewResponse({
status: 500,
error: 'Internal server error',
});
// Navigate directly to overview

View File

@@ -94,7 +94,7 @@ test.describe('Settings startup sync race', () => {
// App should eventually render a main view after settings hydration.
await page
.locator(
'[data-testid="welcome-view"], [data-testid="dashboard-view"], [data-testid="board-view"]'
'[data-testid="welcome-view"], [data-testid="dashboard-view"], [data-testid="board-view"], [data-testid="overview-view"]'
)
.first()
.waitFor({ state: 'visible', timeout: 30000 });
@@ -115,7 +115,7 @@ test.describe('Settings startup sync race', () => {
await handleLoginScreenIfPresent(page);
await page
.locator(
'[data-testid="welcome-view"], [data-testid="dashboard-view"], [data-testid="board-view"]'
'[data-testid="welcome-view"], [data-testid="dashboard-view"], [data-testid="board-view"], [data-testid="overview-view"]'
)
.first()
.waitFor({ state: 'visible', timeout: 30000 });

View File

@@ -168,9 +168,11 @@ export async function navigateToWelcome(page: Page): Promise<void> {
// Handle login redirect if needed
await handleLoginScreenIfPresent(page);
// Wait for either welcome-view or dashboard-view (app redirects to /dashboard when no project)
// Wait for either welcome-view, dashboard-view, or overview-view (app redirects based on project state)
await page
.locator('[data-testid="welcome-view"], [data-testid="dashboard-view"]')
.locator(
'[data-testid="welcome-view"], [data-testid="dashboard-view"], [data-testid="overview-view"]'
)
.first()
.waitFor({ state: 'visible', timeout: 10000 });
}

View File

@@ -171,8 +171,9 @@ export async function detectMergeCommit(
/**
* Detect the current merge state of a git repository.
* Checks for .git/MERGE_HEAD, .git/REBASE_HEAD, .git/CHERRY_PICK_HEAD
* to determine if a merge/rebase/cherry-pick is in progress.
* Checks for .git/MERGE_HEAD, .git/rebase-merge, .git/rebase-apply,
* and .git/CHERRY_PICK_HEAD to determine if a merge/rebase/cherry-pick
* is in progress.
*
* @param repoPath - Path to the git repository or worktree
* @returns MergeStateInfo describing the current merge state
@@ -196,7 +197,6 @@ export async function detectMergeState(repoPath: string): Promise<MergeStateInfo
const checks = [
{ file: 'MERGE_HEAD', type: 'merge' as const },
{ file: 'REBASE_HEAD', type: 'rebase' as const },
{ file: 'rebase-merge', type: 'rebase' as const },
{ file: 'rebase-apply', type: 'rebase' as const },
{ file: 'CHERRY_PICK_HEAD', type: 'cherry-pick' as const },

View File

@@ -3,7 +3,7 @@
*/
import { spawn, type ChildProcess } from 'child_process';
import readline from 'readline';
import { StringDecoder } from 'string_decoder';
export interface SubprocessOptions {
command: string;
@@ -27,7 +27,16 @@ export interface SubprocessResult {
}
/**
* Spawns a subprocess and streams JSONL output line-by-line
* Spawns a subprocess and streams JSONL output line-by-line.
*
* Uses direct 'data' event handling with manual line buffering instead of
* readline's async iterator. The readline async iterator (for await...of on
* readline.Interface) has a known issue where events batch up rather than
* being delivered immediately, because it layers events.on() Promises on top
* of the readline 'line' event emitter. This causes visible delays (20-40s
* between batches) in CLI providers like Gemini that produce frequent small
* events. Direct data event handling delivers parsed events to the consumer
* as soon as they arrive from the pipe.
*/
export async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGenerator<unknown> {
const { command, args, cwd, env, abortController, timeout = 30000, stdinData } = options;
@@ -66,6 +75,19 @@ export async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGener
let stderrOutput = '';
let lastOutputTime = Date.now();
let timeoutHandle: NodeJS.Timeout | null = null;
let processExited = false;
// Stream consumer state - declared in outer scope so the abort handler can
// force the consumer to exit immediately without waiting for stdout to close.
// CLI tools (especially Gemini CLI) may take a long time to respond to SIGTERM,
// leaving the feature stuck in 'in_progress' state on the UI.
let streamEnded = false;
let notifyConsumer: (() => void) | null = null;
// Track process exit early so we don't block on an already-exited process
childProcess.on('exit', () => {
processExited = true;
});
// Collect stderr for error reporting
if (childProcess.stderr) {
@@ -102,6 +124,33 @@ export async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGener
clearTimeout(timeoutHandle);
}
childProcess.kill('SIGTERM');
// Force stream consumer to exit immediately instead of waiting for
// the process to close stdout. CLI tools (especially Gemini CLI) may
// take a long time to respond to SIGTERM while mid-API call.
streamEnded = true;
if (notifyConsumer) {
notifyConsumer();
notifyConsumer = null;
}
// Escalate to SIGKILL after 3 seconds if process hasn't exited.
// SIGKILL cannot be caught or ignored, guaranteeing termination.
const killTimer = setTimeout(() => {
if (!processExited) {
console.log('[SubprocessManager] Escalated to SIGKILL after SIGTERM timeout');
try {
childProcess.kill('SIGKILL');
} catch {
// Process may have already exited between the check and kill
}
}
}, 3000);
// Clean up the kill timer when process exits (don't leak timers)
childProcess.once('exit', () => {
clearTimeout(killTimer);
});
};
// Check if already aborted, if so call handler immediately
if (abortController.signal.aborted) {
@@ -119,39 +168,101 @@ export async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGener
}
};
// Parse stdout as JSONL (one JSON object per line)
// Parse stdout as JSONL using direct 'data' events with manual line buffering.
// This avoids the readline async iterator which batches events due to its
// internal events.on() Promise layering, causing significant delivery delays.
if (childProcess.stdout) {
const rl = readline.createInterface({
input: childProcess.stdout,
crlfDelay: Infinity,
// Queue of parsed events ready to be yielded
const eventQueue: unknown[] = [];
// Partial line buffer for incomplete lines across data chunks
let lineBuffer = '';
// StringDecoder handles multibyte UTF-8 sequences that may be split across chunks
const decoder = new StringDecoder('utf8');
childProcess.stdout.on('data', (chunk: Buffer) => {
resetTimeout();
lineBuffer += decoder.write(chunk);
const lines = lineBuffer.split('\n');
// Last element is either empty (line ended with \n) or a partial line
lineBuffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
eventQueue.push(JSON.parse(trimmed));
} catch (parseError) {
console.error(`[SubprocessManager] Failed to parse JSONL line: ${trimmed}`, parseError);
eventQueue.push({
type: 'error',
error: `Failed to parse output: ${trimmed}`,
});
}
}
// Wake up the consumer if it's waiting for events
if (notifyConsumer && eventQueue.length > 0) {
notifyConsumer();
notifyConsumer = null;
}
});
childProcess.stdout.on('end', () => {
// Flush any remaining bytes from the decoder
lineBuffer += decoder.end();
// Process any remaining partial line
if (lineBuffer.trim()) {
try {
eventQueue.push(JSON.parse(lineBuffer.trim()));
} catch (parseError) {
console.error(
`[SubprocessManager] Failed to parse final JSONL line: ${lineBuffer}`,
parseError
);
eventQueue.push({
type: 'error',
error: `Failed to parse output: ${lineBuffer}`,
});
}
lineBuffer = '';
}
streamEnded = true;
// Wake up consumer so it can exit the loop
if (notifyConsumer) {
notifyConsumer();
notifyConsumer = null;
}
});
childProcess.stdout.on('error', (error) => {
console.error('[SubprocessManager] stdout error:', error);
streamEnded = true;
if (notifyConsumer) {
notifyConsumer();
notifyConsumer = null;
}
});
try {
for await (const line of rl) {
resetTimeout();
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line);
yield parsed;
} catch (parseError) {
console.error(`[SubprocessManager] Failed to parse JSONL line: ${line}`, parseError);
// Yield error but continue processing
yield {
type: 'error',
error: `Failed to parse output: ${line}`,
};
// Yield events as they arrive, waiting only when the queue is empty
while (!streamEnded || eventQueue.length > 0) {
if (eventQueue.length > 0) {
yield eventQueue.shift()!;
} else {
// Wait for the next data event to push events into the queue
await new Promise<void>((resolve) => {
notifyConsumer = resolve;
});
}
}
} catch (error) {
console.error('[SubprocessManager] Error reading stdout:', error);
throw error;
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
rl.close();
cleanupAbortListener();
}
} else {
@@ -159,8 +270,15 @@ export async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGener
cleanupAbortListener();
}
// Wait for process to exit
// Wait for process to exit.
// If the process already exited (e.g., abort handler killed it while we were
// draining the stream), resolve immediately to avoid blocking forever.
const exitCode = await new Promise<number | null>((resolve) => {
if (processExited) {
resolve(childProcess.exitCode ?? null);
return;
}
childProcess.on('exit', (code) => {
console.log(`[SubprocessManager] Process exited with code: ${code}`);
resolve(code);
@@ -245,6 +363,17 @@ export async function spawnProcess(options: SubprocessOptions): Promise<Subproce
abortHandler = () => {
cleanupAbortListener();
childProcess.kill('SIGTERM');
// Escalate to SIGKILL after 3 seconds if process hasn't exited
const killTimer = setTimeout(() => {
try {
childProcess.kill('SIGKILL');
} catch {
// Process may have already exited
}
}, 3000);
childProcess.once('exit', () => clearTimeout(killTimer));
reject(new Error('Process aborted'));
};
abortController.signal.addEventListener('abort', abortHandler);

View File

@@ -11,7 +11,7 @@ import type { EnhancementExample } from '@automaker/types';
*/
export const ACCEPTANCE_SYSTEM_PROMPT = `You are a QA specialist skilled at defining testable acceptance criteria for software features.
Your task is to enhance a task description by adding clear acceptance criteria:
Your task is to generate ONLY the acceptance criteria that will be appended below the user's original description. Do NOT rewrite or include the original description in your output.
1. UNDERSTAND the feature:
- Identify all user-facing behaviors
@@ -34,7 +34,7 @@ Your task is to enhance a task description by adding clear acceptance criteria:
- Avoid vague terms like "quickly" or "easily"
- Include specific values where applicable
Output the original description followed by a clear "Acceptance Criteria:" section with numbered, testable criteria. Do not include explanations about your process.`;
IMPORTANT: Output ONLY the acceptance criteria section. Do NOT repeat or rewrite the original description - it will be preserved automatically. Start your output with "Acceptance Criteria:" followed by the numbered criteria.`;
/**
* Few-shot examples for the "acceptance" enhancement mode
@@ -42,11 +42,7 @@ Output the original description followed by a clear "Acceptance Criteria:" secti
export const ACCEPTANCE_EXAMPLES: EnhancementExample[] = [
{
input: 'Add password reset functionality',
output: `Add Password Reset Functionality
Allow users to reset their password via email when they forget it.
Acceptance Criteria:
output: `Acceptance Criteria:
1. Given a user is on the login page, when they click "Forgot Password", then they should see a password reset form requesting their email.
@@ -62,11 +58,7 @@ Acceptance Criteria:
},
{
input: 'Shopping cart checkout',
output: `Shopping Cart Checkout
Implement the checkout flow for purchasing items in the shopping cart.
Acceptance Criteria:
output: `Acceptance Criteria:
1. Given a user has items in their cart, when they click "Checkout", then they should see an order summary with item details and total price.

View File

@@ -11,7 +11,7 @@ import type { EnhancementExample } from '@automaker/types';
*/
export const TECHNICAL_SYSTEM_PROMPT = `You are a senior software engineer skilled at adding technical depth to feature descriptions.
Your task is to enhance a task description with technical implementation details:
Your task is to generate ONLY the technical implementation details that will be appended below the user's original description. Do NOT rewrite or include the original description in your output.
1. ANALYZE the requirement:
- Understand the functional goal
@@ -34,7 +34,7 @@ Your task is to enhance a task description with technical implementation details
- Loading and empty states
- Boundary conditions
Output ONLY the enhanced technical description. Keep it concise but comprehensive. Do not include explanations about your reasoning.`;
IMPORTANT: Output ONLY the new technical details section. Do NOT repeat or rewrite the original description - it will be preserved automatically. Start your output with a heading like "Technical Implementation:" followed by the details.`;
/**
* Few-shot examples for the "technical" enhancement mode
@@ -42,11 +42,7 @@ Output ONLY the enhanced technical description. Keep it concise but comprehensiv
export const TECHNICAL_EXAMPLES: EnhancementExample[] = [
{
input: 'Add user profile page',
output: `Add User Profile Page
Create a dedicated profile page for viewing and editing user information.
Technical Implementation:
output: `Technical Implementation:
- Frontend: React component at /profile route with form validation
- API Endpoint: GET/PUT /api/users/:id for fetching and updating profile
- Data Model: Extend User schema with profile fields (avatar, bio, preferences)
@@ -63,11 +59,7 @@ Security: Ensure users can only edit their own profile (auth middleware)`,
},
{
input: 'Add search functionality',
output: `Add Search Functionality
Implement full-text search across application content.
Technical Implementation:
output: `Technical Implementation:
- Search Engine: Use Elasticsearch or PostgreSQL full-text search
- API: GET /api/search?q={query}&type={type}&page={page}
- Indexing: Create search index with relevant fields, update on content changes

View File

@@ -188,7 +188,7 @@ A comprehensive guide to creating exceptional user experiences and designs for m
## Your Task
Review the provided task description and enhance it by:
Generate ONLY the UX considerations section that will be appended below the user's original description. Do NOT rewrite or include the original description in your output.
1. **ANALYZE** the feature from a UX perspective:
- Identify user goals and pain points
@@ -216,7 +216,7 @@ Review the provided task description and enhance it by:
- User feedback and confirmation flows
- Accessibility compliance (WCAG AA minimum)
Output the enhanced task description with UX considerations integrated naturally. Focus on actionable, specific UX requirements that developers can implement. Do not include explanations about your process.`;
IMPORTANT: Output ONLY the new UX requirements section. Do NOT repeat or rewrite the original description - it will be preserved automatically. Start your output with "UX Requirements:" followed by the details. Focus on actionable, specific UX requirements that developers can implement.`;
/**
* Few-shot examples for the "ux-reviewer" enhancement mode
@@ -224,11 +224,7 @@ Output the enhanced task description with UX considerations integrated naturally
export const UX_REVIEWER_EXAMPLES: EnhancementExample[] = [
{
input: 'Add user profile page',
output: `Add User Profile Page
Create a dedicated profile page for viewing and editing user information with a focus on excellent user experience and accessibility.
UX Requirements:
output: `UX Requirements:
- **Layout**: Single-column layout on mobile, two-column layout on desktop (profile info left, edit form right)
- **Visual Hierarchy**: Profile header with avatar (120x120px), name (24px font), and edit button prominently displayed
- **Accessibility**:
@@ -268,12 +264,8 @@ UX Requirements:
},
{
input: 'Add search functionality',
output: `Add Search Functionality
Implement full-text search across application content with an intuitive, accessible interface.
UX Requirements:
- **Search Input**:
output: `UX Requirements:
- **Search Input**:
- Prominent search bar in header (desktop) or accessible via icon (mobile)
- Clear placeholder text: "Search..." with example query
- Debounced input (300ms) to reduce API calls

View File

@@ -128,6 +128,9 @@ export function getExamples(mode: EnhancementMode): EnhancementExample[] {
return EXAMPLES[mode];
}
/** Modes that append additional content rather than rewriting the description */
const ADDITIVE_MODES: EnhancementMode[] = ['technical', 'acceptance', 'ux-reviewer'];
/**
* Build a user prompt for enhancement with optional few-shot examples
*
@@ -142,9 +145,14 @@ export function buildUserPrompt(
includeExamples: boolean = true
): string {
const examples = includeExamples ? getExamples(mode) : [];
const isAdditive = ADDITIVE_MODES.includes(mode);
const instruction = isAdditive
? 'Generate ONLY the additional details section for the following task description. Do NOT rewrite or repeat the original description:'
: 'Please enhance the following task description:';
if (examples.length === 0) {
return `Please enhance the following task description:\n\n${text}`;
return `${instruction}\n\n${text}`;
}
// Build few-shot examples section
@@ -155,13 +163,17 @@ export function buildUserPrompt(
)
.join('\n\n---\n\n');
return `Here are some examples of how to enhance task descriptions:
const examplesIntro = isAdditive
? 'Here are examples of the additional details section to generate (note: these show ONLY the appended content, not the original description):'
: 'Here are some examples of how to enhance task descriptions:';
return `${examplesIntro}
${examplesSection}
---
Now, please enhance the following task description:
${instruction}
${text}`;
}

View File

@@ -10,10 +10,12 @@ import {
TECHNICAL_SYSTEM_PROMPT,
SIMPLIFY_SYSTEM_PROMPT,
ACCEPTANCE_SYSTEM_PROMPT,
UX_REVIEWER_SYSTEM_PROMPT,
IMPROVE_EXAMPLES,
TECHNICAL_EXAMPLES,
SIMPLIFY_EXAMPLES,
ACCEPTANCE_EXAMPLES,
UX_REVIEWER_EXAMPLES,
} from '../src/enhancement.js';
describe('enhancement.ts', () => {
@@ -45,6 +47,12 @@ describe('enhancement.ts', () => {
expect(ACCEPTANCE_SYSTEM_PROMPT).toContain('acceptance criteria');
expect(ACCEPTANCE_SYSTEM_PROMPT).toContain('testable');
});
it('should export UX_REVIEWER_SYSTEM_PROMPT', () => {
expect(UX_REVIEWER_SYSTEM_PROMPT).toBeDefined();
expect(typeof UX_REVIEWER_SYSTEM_PROMPT).toBe('string');
expect(UX_REVIEWER_SYSTEM_PROMPT).toContain('User Experience');
});
});
describe('Examples Constants', () => {
@@ -100,6 +108,19 @@ describe('enhancement.ts', () => {
});
});
it('should export UX_REVIEWER_EXAMPLES with valid structure', () => {
expect(UX_REVIEWER_EXAMPLES).toBeDefined();
expect(Array.isArray(UX_REVIEWER_EXAMPLES)).toBe(true);
expect(UX_REVIEWER_EXAMPLES.length).toBeGreaterThan(0);
UX_REVIEWER_EXAMPLES.forEach((example) => {
expect(example).toHaveProperty('input');
expect(example).toHaveProperty('output');
expect(typeof example.input).toBe('string');
expect(typeof example.output).toBe('string');
});
});
it('should have shorter outputs in SIMPLIFY_EXAMPLES', () => {
SIMPLIFY_EXAMPLES.forEach((example) => {
// Simplify examples should have shorter output than input
@@ -148,6 +169,15 @@ describe('enhancement.ts', () => {
expect(result.description).toContain('acceptance');
});
it("should return prompt config for 'ux-reviewer' mode", () => {
const result = getEnhancementPrompt('ux-reviewer');
expect(result).toHaveProperty('systemPrompt');
expect(result).toHaveProperty('description');
expect(result.systemPrompt).toBe(UX_REVIEWER_SYSTEM_PROMPT);
expect(result.description.toLowerCase()).toContain('user experience');
});
it('should handle uppercase mode', () => {
const result = getEnhancementPrompt('IMPROVE');
@@ -194,6 +224,11 @@ describe('enhancement.ts', () => {
const result = getSystemPrompt('acceptance');
expect(result).toBe(ACCEPTANCE_SYSTEM_PROMPT);
});
it("should return UX_REVIEWER_SYSTEM_PROMPT for 'ux-reviewer'", () => {
const result = getSystemPrompt('ux-reviewer');
expect(result).toBe(UX_REVIEWER_SYSTEM_PROMPT);
});
});
describe('getExamples', () => {
@@ -220,6 +255,12 @@ describe('enhancement.ts', () => {
expect(result).toBe(ACCEPTANCE_EXAMPLES);
expect(result.length).toBeGreaterThan(0);
});
it("should return UX_REVIEWER_EXAMPLES for 'ux-reviewer'", () => {
const result = getExamples('ux-reviewer');
expect(result).toBe(UX_REVIEWER_EXAMPLES);
expect(result.length).toBeGreaterThan(0);
});
});
describe('buildUserPrompt', () => {
@@ -239,7 +280,7 @@ describe('enhancement.ts', () => {
it("should include examples by default for 'technical' mode", () => {
const result = buildUserPrompt('technical', testText);
expect(result).toContain('Here are some examples');
expect(result).toContain('Here are examples of the additional details section');
expect(result).toContain('Example 1:');
expect(result).toContain(TECHNICAL_EXAMPLES[0].input);
expect(result).toContain(testText);
@@ -268,10 +309,10 @@ describe('enhancement.ts', () => {
expect(dividerCount).toBe(IMPROVE_EXAMPLES.length);
});
it("should include 'Now, please enhance' before user text", () => {
it("should include 'Please enhance' before user text", () => {
const result = buildUserPrompt('improve', testText);
expect(result).toContain('Now, please enhance the following');
expect(result).toContain('Please enhance the following task description:');
expect(result).toContain(testText);
});
});
@@ -295,7 +336,14 @@ describe('enhancement.ts', () => {
const result = buildUserPrompt('technical', testText, false);
expect(result).toContain(testText);
expect(result).toContain('Please enhance');
expect(result).toContain('Generate ONLY the additional details');
});
it('should use additive phrasing for ux-reviewer mode', () => {
const result = buildUserPrompt('ux-reviewer', testText, true);
expect(result).toContain(testText);
expect(result).toContain('Here are examples of the additional details section');
});
});
@@ -310,8 +358,8 @@ describe('enhancement.ts', () => {
it('should handle empty text', () => {
const result = buildUserPrompt('improve', '');
// With examples by default, it should contain "Now, please enhance"
expect(result).toContain('Now, please enhance');
// With examples by default, it should contain "Please enhance"
expect(result).toContain('Please enhance the following task description:');
expect(result).toContain('Here are some examples');
});
@@ -331,11 +379,12 @@ describe('enhancement.ts', () => {
describe('all modes', () => {
it('should work for all valid enhancement modes', () => {
const modes: Array<'improve' | 'technical' | 'simplify' | 'acceptance'> = [
const modes: Array<'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'> = [
'improve',
'technical',
'simplify',
'acceptance',
'ux-reviewer',
];
modes.forEach((mode) => {
@@ -366,6 +415,10 @@ describe('enhancement.ts', () => {
expect(isValidEnhancementMode('acceptance')).toBe(true);
});
it("should return true for 'ux-reviewer'", () => {
expect(isValidEnhancementMode('ux-reviewer')).toBe(true);
});
it('should return false for invalid mode', () => {
expect(isValidEnhancementMode('invalid')).toBe(false);
});

View File

@@ -352,10 +352,13 @@ export function getThinkingLevelsForModel(model: string): ThinkingLevel[] {
/**
* Get the default thinking level for a given model.
* Used when selecting a model via the primary button in the two-stage selector.
* Always returns 'none' — users can configure their preferred default
* via the defaultThinkingLevel setting in the model defaults page.
* Returns 'adaptive' for Opus models (which support adaptive thinking),
* and 'none' for all other models.
*/
export function getDefaultThinkingLevel(_model: string): ThinkingLevel {
export function getDefaultThinkingLevel(model: string): ThinkingLevel {
if (isAdaptiveThinkingModel(model)) {
return 'adaptive';
}
return 'none';
}
@@ -1203,7 +1206,7 @@ export interface GlobalSettings {
/** 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.
* Defaults to 10000. Range: 1-10000.
*
* Note: Currently supported by Claude (via SDK) and Codex (via CLI config).
* Gemini and OpenCode CLI providers do not support max turns configuration. */
@@ -1528,6 +1531,23 @@ export interface ProjectSettings {
*/
worktreeCopyFiles?: string[];
// Worktree Display Settings
/**
* Number of non-main worktrees to pin as tabs in the UI.
* The main worktree is always shown separately. Default: 0.
*/
pinnedWorktreesCount?: number;
/**
* Minimum number of worktrees before the list collapses into a compact dropdown selector.
* Must be >= pinnedWorktreesCount to avoid conflicting configurations. Default: 3.
*/
worktreeDropdownThreshold?: number;
/**
* When true, always show worktrees in a combined dropdown regardless of count.
* Overrides the dropdown threshold. Default: true.
*/
alwaysUseWorktreeDropdown?: boolean;
// Session Tracking
/** Last chat session selected in this project */
lastSelectedSessionId?: string;
@@ -1652,7 +1672,7 @@ export const DEFAULT_PHASE_MODELS: PhaseModelConfig = {
validationModel: { model: 'claude-sonnet' },
// Generation - use powerful models for quality
specGenerationModel: { model: 'claude-opus' },
specGenerationModel: { model: 'claude-opus', thinkingLevel: 'adaptive' },
featureGenerationModel: { model: 'claude-sonnet' },
backlogPlanningModel: { model: 'claude-sonnet' },
projectAnalysisModel: { model: 'claude-sonnet' },
@@ -1720,7 +1740,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
useWorktrees: true,
defaultPlanningMode: 'skip',
defaultRequirePlanApproval: false,
defaultFeatureModel: { model: 'claude-opus' }, // Use canonical ID
defaultFeatureModel: { model: 'claude-opus', thinkingLevel: 'adaptive' }, // Use canonical ID with adaptive thinking
muteDoneSound: false,
disableSplashScreen: false,
serverLogLevel: 'info',
@@ -1728,9 +1748,9 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
showQueryDevtools: true,
enableAiCommitMessages: true,
phaseModels: DEFAULT_PHASE_MODELS,
defaultThinkingLevel: 'none',
defaultThinkingLevel: 'adaptive',
defaultReasoningEffort: 'none',
defaultMaxTurns: 1000,
defaultMaxTurns: 10000,
enhancementModel: 'sonnet', // Legacy alias still supported
validationModel: 'opus', // Legacy alias still supported
enabledCursorModels: getAllCursorModelIds(), // Returns prefixed IDs

22
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "automaker",
"version": "0.13.0",
"version": "0.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "automaker",
"version": "0.13.0",
"version": "0.15.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -33,7 +33,7 @@
},
"apps/server": {
"name": "@automaker/server",
"version": "0.13.0",
"version": "0.15.0",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.2.32",
@@ -99,7 +99,7 @@
},
"apps/ui": {
"name": "@automaker/ui",
"version": "0.13.0",
"version": "0.15.0",
"hasInstallScript": true,
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
@@ -120,6 +120,7 @@
"@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/merge": "^6.12.0",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "6.1.3",
@@ -1464,6 +1465,19 @@
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/merge": {
"version": "6.12.0",
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.0.tgz",
"integrity": "sha512-o+36bbapcEHf4Ux75pZ4CKjMBUd14parA0uozvWVlacaT+uxaA3DDefEvWYjngsKU+qsrDe/HOOfsw0Q72pLjA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/highlight": "^1.0.0",
"style-mod": "^4.1.0"
}
},
"node_modules/@codemirror/search": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "automaker",
"version": "0.13.0",
"version": "0.15.0",
"license": "MIT",
"private": true,
"engines": {