mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
163
apps/server/src/routes/worktree/routes/update-pr-number.ts
Normal file
163
apps/server/src/routes/worktree/routes/update-pr-number.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 / {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -310,6 +310,8 @@ export function SessionManager({
|
||||
});
|
||||
if (activeSessionsList.length > 0) {
|
||||
onSelectSession(activeSessionsList[0].id);
|
||||
} else {
|
||||
onSelectSession(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
220
apps/ui/src/components/ui/codemirror-diff-view.tsx
Normal file
220
apps/ui/src/components/ui/codemirror-diff-view.tsx
Normal 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 }} />
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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,
|
||||
|
||||
155
apps/ui/src/lib/codemirror-languages.ts
Normal file
155
apps/ui/src/lib/codemirror-languages.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
34
apps/ui/src/types/electron.d.ts
vendored
34
apps/ui/src/types/electron.d.ts
vendored
@@ -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>;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
22
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "automaker",
|
||||
"version": "0.13.0",
|
||||
"version": "0.15.0",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"engines": {
|
||||
|
||||
Reference in New Issue
Block a user