From 0330c70261a7931410cae16976412065a675852a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 23 Feb 2026 20:31:25 -0800 Subject: [PATCH] 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 * 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 --- apps/server/package.json | 2 +- apps/server/src/lib/sdk-options.ts | 8 + apps/server/src/lib/settings-helpers.ts | 4 +- apps/server/src/providers/codex-provider.ts | 5 + apps/server/src/providers/gemini-provider.ts | 101 ++- apps/server/src/routes/features/index.ts | 6 +- .../src/routes/features/routes/update.ts | 15 +- apps/server/src/routes/worktree/index.ts | 7 + .../worktree/routes/update-pr-number.ts | 163 +++++ apps/server/src/services/agent-executor.ts | 2 +- apps/server/src/services/agent-service.ts | 54 +- apps/server/src/services/auto-mode/facade.ts | 2 + .../server/src/services/event-hook-service.ts | 58 ++ apps/server/src/services/execution-service.ts | 18 + .../src/services/pipeline-orchestrator.ts | 6 + apps/server/src/services/worktree-service.ts | 10 +- .../unit/lib/enhancement-prompts.test.ts | 2 +- .../unit/providers/gemini-provider.test.ts | 84 +++ .../tests/unit/services/agent-service.test.ts | 119 ++++ .../unit/services/event-hook-service.test.ts | 61 +- .../unit/services/execution-service.test.ts | 28 + .../unit/services/settings-service.test.ts | 14 +- apps/ui/nginx.conf | 2 + apps/ui/package.json | 3 +- apps/ui/playwright.config.ts | 1 + apps/ui/src/components/session-manager.tsx | 2 + .../components/ui/codemirror-diff-view.tsx | 220 +++++++ apps/ui/src/components/ui/git-diff-panel.tsx | 251 +------ apps/ui/src/components/views/board-view.tsx | 24 +- .../board-view/dialogs/add-feature-dialog.tsx | 39 +- .../dialogs/change-pr-number-dialog.tsx | 197 ++++++ .../board-view/dialogs/create-pr-dialog.tsx | 5 +- .../views/board-view/dialogs/index.ts | 1 + .../board-view/hooks/use-board-actions.ts | 60 +- .../shared/enhancement/enhance-with-ai.tsx | 35 +- .../enhancement/enhancement-constants.ts | 15 +- .../components/worktree-actions-dropdown.tsx | 53 ++ .../components/worktree-dropdown.tsx | 42 +- .../components/worktree-tab.tsx | 19 + .../views/board-view/worktree-panel/types.ts | 1 + .../worktree-panel/worktree-panel.tsx | 619 ++++++++++-------- .../components/code-editor.tsx | 411 +++++++----- .../components/editor-tabs.tsx | 137 ++-- .../file-editor-view/components/file-tree.tsx | 104 ++- .../file-editor-view/file-editor-view.tsx | 337 +++++++++- .../file-editor-view/use-file-editor-store.ts | 26 +- .../worktree-preferences-section.tsx | 88 ++- apps/ui/src/hooks/use-auto-mode.ts | 1 + apps/ui/src/hooks/use-electron-agent.ts | 10 +- .../src/hooks/use-project-settings-loader.ts | 26 + apps/ui/src/hooks/use-settings-migration.ts | 9 +- apps/ui/src/hooks/use-settings-sync.ts | 8 +- apps/ui/src/lib/codemirror-languages.ts | 155 +++++ apps/ui/src/lib/diff-utils.ts | 127 ++++ apps/ui/src/lib/electron.ts | 17 + apps/ui/src/lib/http-api-client.ts | 5 + apps/ui/src/store/app-store.ts | 85 ++- apps/ui/src/store/types/state-types.ts | 22 + apps/ui/src/types/electron.d.ts | 34 + .../tests/projects/overview-dashboard.spec.ts | 488 +++++++------- .../settings-startup-sync-race.spec.ts | 4 +- apps/ui/tests/utils/navigation/views.ts | 6 +- libs/git-utils/src/status.ts | 6 +- libs/platform/src/subprocess.ts | 181 ++++- .../src/enhancement-modes/acceptance.ts | 16 +- .../src/enhancement-modes/technical.ts | 16 +- .../src/enhancement-modes/ux-reviewer.ts | 18 +- libs/prompts/src/enhancement.ts | 18 +- libs/prompts/tests/enhancement.test.ts | 67 +- libs/types/src/settings.ts | 36 +- package-lock.json | 22 +- package.json | 2 +- 72 files changed, 3667 insertions(+), 1173 deletions(-) create mode 100644 apps/server/src/routes/worktree/routes/update-pr-number.ts create mode 100644 apps/ui/src/components/ui/codemirror-diff-view.tsx create mode 100644 apps/ui/src/components/views/board-view/dialogs/change-pr-number-dialog.tsx create mode 100644 apps/ui/src/lib/codemirror-languages.ts diff --git a/apps/server/package.json b/apps/server/package.json index 4a7a75a8..75818b18 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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", diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index ed91a3ef..7044221e 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -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; diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index e155ced1..48c06383 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -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. diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 94a91a33..63d41036 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -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; diff --git a/apps/server/src/providers/gemini-provider.ts b/apps/server/src/providers/gemini-provider.ts index e4e6f9dc..4d230477 100644 --- a/apps/server/src/providers/gemini-provider.ts +++ b/apps/server/src/providers/gemini-provider.ts @@ -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 { + 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 */ diff --git a/apps/server/src/routes/features/index.ts b/apps/server/src/routes/features/index.ts index 0c92a446..a4ea03b4 100644 --- a/apps/server/src/routes/features/index.ts +++ b/apps/server/src/routes/features/index.ts @@ -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'), diff --git a/apps/server/src/routes/features/routes/update.ts b/apps/server/src/routes/features/routes/update.ts index 4d5e7a00..89e2dde0 100644 --- a/apps/server/src/routes/features/routes/update.ts +++ b/apps/server/src/routes/features/routes/update.ts @@ -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 => { 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) { diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 492264cd..2525c831 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -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'), diff --git a/apps/server/src/routes/worktree/routes/update-pr-number.ts b/apps/server/src/routes/worktree/routes/update-pr-number.ts new file mode 100644 index 00000000..b39508f9 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/update-pr-number.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/services/agent-executor.ts b/apps/server/src/services/agent-executor.ts index a478450a..a3c88f6c 100644 --- a/apps/server/src/services/agent-executor.ts +++ b/apps/server/src/services/agent-executor.ts @@ -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; diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index d62b3442..443fff04 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -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(); 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, + }, + }, + }); } } } diff --git a/apps/server/src/services/auto-mode/facade.ts b/apps/server/src/services/auto-mode/facade.ts index 241333de..af660ea5 100644 --- a/apps/server/src/services/auto-mode/facade.ts +++ b/apps/server/src/services/auto-mode/facade.ts @@ -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, diff --git a/apps/server/src/services/event-hook-service.ts b/apps/server/src/services/event-hook-service.ts index ddef051d..376da964 100644 --- a/apps/server/src/services/event-hook-service.ts +++ b/apps/server/src/services/event-hook-service.ts @@ -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 { + 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 */ diff --git a/apps/server/src/services/execution-service.ts b/apps/server/src/services/execution-service.ts index e5fb4028..6e23405a 100644 --- a/apps/server/src/services/execution-service.ts +++ b/apps/server/src/services/execution-service.ts @@ -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 { 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; diff --git a/apps/server/src/services/pipeline-orchestrator.ts b/apps/server/src/services/pipeline-orchestrator.ts index 719d9261..c8564b18 100644 --- a/apps/server/src/services/pipeline-orchestrator.ts +++ b/apps/server/src/services/pipeline-orchestrator.ts @@ -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, diff --git a/apps/server/src/services/worktree-service.ts b/apps/server/src/services/worktree-service.ts index 57ce5b40..0cb7a251 100644 --- a/apps/server/src/services/worktree-service.ts +++ b/apps/server/src/services/worktree-service.ts @@ -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()) { diff --git a/apps/server/tests/unit/lib/enhancement-prompts.test.ts b/apps/server/tests/unit/lib/enhancement-prompts.test.ts index 13d61555..77d118d3 100644 --- a/apps/server/tests/unit/lib/enhancement-prompts.test.ts +++ b/apps/server/tests/unit/lib/enhancement-prompts.test.ts @@ -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', () => { diff --git a/apps/server/tests/unit/providers/gemini-provider.test.ts b/apps/server/tests/unit/providers/gemini-provider.test.ts index a2d410d4..c63a5a60 100644 --- a/apps/server/tests/unit/providers/gemini-provider.test.ts +++ b/apps/server/tests/unit/providers/gemini-provider.test.ts @@ -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'); + }); }); }); diff --git a/apps/server/tests/unit/services/agent-service.test.ts b/apps/server/tests/unit/services/agent-service.test.ts index c8ae1cba..22ab6383 100644 --- a/apps/server/tests/unit/services/agent-service.test.ts +++ b/apps/server/tests/unit/services/agent-service.test.ts @@ -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', diff --git a/apps/server/tests/unit/services/event-hook-service.test.ts b/apps/server/tests/unit/services/event-hook-service.test.ts index 2ef9b246..ab06f9c1 100644 --- a/apps/server/tests/unit/services/event-hook-service.test.ts +++ b/apps/server/tests/unit/services/event-hook-service.test.ts @@ -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).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', diff --git a/apps/server/tests/unit/services/execution-service.test.ts b/apps/server/tests/unit/services/execution-service.test.ts index a5d001f8..22aa4ed5 100644 --- a/apps/server/tests/unit/services/execution-service.test.ts +++ b/apps/server/tests/unit/services/execution-service.test.ts @@ -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', () => { diff --git a/apps/server/tests/unit/services/settings-service.test.ts b/apps/server/tests/unit/services/settings-service.test.ts index 70511af8..e54358fc 100644 --- a/apps/server/tests/unit/services/settings-service.test.ts +++ b/apps/server/tests/unit/services/settings-service.test.ts @@ -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 () => { diff --git a/apps/ui/nginx.conf b/apps/ui/nginx.conf index 6c50a157..da56165d 100644 --- a/apps/ui/nginx.conf +++ b/apps/ui/nginx.conf @@ -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 / { diff --git a/apps/ui/package.json b/apps/ui/package.json index b93fd7c6..7b2c35f1 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -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", diff --git a/apps/ui/playwright.config.ts b/apps/ui/playwright.config.ts index f301fa30..813a8017 100644 --- a/apps/ui/playwright.config.ts +++ b/apps/ui/playwright.config.ts @@ -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'), diff --git a/apps/ui/src/components/session-manager.tsx b/apps/ui/src/components/session-manager.tsx index 7ac28da0..49958c95 100644 --- a/apps/ui/src/components/session-manager.tsx +++ b/apps/ui/src/components/session-manager.tsx @@ -310,6 +310,8 @@ export function SessionManager({ }); if (activeSessionsList.length > 0) { onSelectSession(activeSessionsList[0].id); + } else { + onSelectSession(null); } } } diff --git a/apps/ui/src/components/ui/codemirror-diff-view.tsx b/apps/ui/src/components/ui/codemirror-diff-view.tsx new file mode 100644 index 00000000..703bf0b4 --- /dev/null +++ b/apps/ui/src/components/ui/codemirror-diff-view.tsx @@ -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(null); + const viewRef = useRef(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 ( +
+ ); +} diff --git a/apps/ui/src/components/ui/git-diff-panel.tsx b/apps/ui/src/components/ui/git-diff-panel.tsx index cd57cfe7..12c4ae05 100644 --- a/apps/ui/src/components/ui/git-diff-panel.tsx +++ b/apps/ui/src/components/ui/git-diff-panel.tsx @@ -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 ( -
- {content} -
- ); - } - - return ( -
- - {lineNumber?.old ?? ''} - - - {lineNumber?.new ?? ''} - - - {prefix[type]} - - - {content || '\u00A0'} - -
- ); -} - 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({ )}
- {isExpanded && ( -
- {fileDiff.hunks.map((hunk, hunkIndex) => ( -
- {hunk.lines.map((line, lineIndex) => ( - - ))} -
- ))} + {isExpanded && rawDiff && ( +
+
)}
@@ -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(); + 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 (
toggleFile(fileDiff.filePath)} fileStatus={fileStatusMap.get(fileDiff.filePath)} diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 213f778d..7cb79a13 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -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 */} + { + setWorktreeRefreshKey((k) => k + 1); + setSelectedWorktreeForAction(null); + }} + /> + {/* Create Branch Dialog */} 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(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 ( + { + if (!isLoading) { + onOpenChange(isOpen); + } + }} + > + + + + + Change Tracked PR Number + + + Update which pull request number is tracked for{' '} + {worktree.branch}. + {worktree.pr && ( + + Currently tracking PR #{worktree.pr.number} + + )} + + + +
+
+ +
+ # + { + setPrNumberInput(e.target.value); + setError(null); + }} + disabled={isLoading} + autoFocus + className="flex-1" + /> +
+

+ Enter the GitHub PR number to associate with this worktree. The PR info will be + fetched from GitHub if available. +

+
+ + {error &&

{error}

} +
+ + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx index a50d547f..b7f6bf6c 100644 --- a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx @@ -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 ( - + diff --git a/apps/ui/src/components/views/board-view/dialogs/index.ts b/apps/ui/src/components/views/board-view/dialogs/index.ts index 0c636c70..80775196 100644 --- a/apps/ui/src/components/views/board-view/dialogs/index.ts +++ b/apps/ui/src/components/views/board-view/dialogs/index.ts @@ -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, diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index 8c87b7da..6bc7251a 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -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 diff --git a/apps/ui/src/components/views/board-view/shared/enhancement/enhance-with-ai.tsx b/apps/ui/src/components/views/board-view/shared/enhancement/enhance-with-ai.tsx index 3429584b..8ab1bb50 100644 --- a/apps/ui/src/components/views/board-view/shared/enhancement/enhance-with-ai.tsx +++ b/apps/ui/src/components/views/board-view/shared/enhancement/enhance-with-ai.tsx @@ -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({ - {(Object.entries(ENHANCEMENT_MODE_LABELS) as [EnhancementMode, string][]).map( - ([mode, label]) => ( - setEnhancementMode(mode)}> - {label} - - ) - )} + Rewrite + {REWRITE_MODES.map((mode) => ( + setEnhancementMode(mode)}> + {ENHANCEMENT_MODE_LABELS[mode]} + + ))} + + Append Details + {ADDITIVE_MODES.map((mode) => ( + setEnhancementMode(mode)}> + {ENHANCEMENT_MODE_LABELS[mode]} + + ))} diff --git a/apps/ui/src/components/views/board-view/shared/enhancement/enhancement-constants.ts b/apps/ui/src/components/views/board-view/shared/enhancement/enhancement-constants.ts index 7338ea8b..e0db0ca4 100644 --- a/apps/ui/src/components/views/board-view/shared/enhancement/enhancement-constants.ts +++ b/apps/ui/src/components/views/board-view/shared/enhancement/enhancement-constants.ts @@ -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 = { @@ -18,3 +18,14 @@ export const ENHANCEMENT_MODE_DESCRIPTIONS: Record = { 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); +} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index a6e0554a..993c6463 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -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({ Address PR Comments + {onChangePRNumber && ( + onChangePRNumber(worktree)} className="text-xs"> + + Change PR Number + + )} )} @@ -1359,6 +1382,36 @@ export function WorktreeActionsDropdown({ )} + {/* 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 && ( + + + + Swap Worktree + + + {availableWorktreesForSwap + .filter((wt) => wt.branch !== worktree.branch) + .map((wt) => { + const isPinned = pinnedBranches?.includes(wt.branch); + return ( + onSwapWorktree(slotIndex, wt.branch)} + className="flex items-center gap-2 cursor-pointer font-mono text-xs" + > + {wt.branch} + {isPinned && } + + ); + })} + + + )} {!worktree.isMain && ( onDeleteWorktree(worktree)} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown.tsx index 45e85e4d..b0610e55 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown.tsx @@ -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( () => ( ), - [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 && ( 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} />
); diff --git a/apps/ui/src/components/views/board-view/worktree-panel/types.ts b/apps/ui/src/components/views/board-view/worktree-panel/types.ts index 24aac6cd..9fc8da72 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/types.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/types.ts @@ -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; diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index 1114a3e0..a59f2c21 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -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(); + + 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 ( -
- - - {useDropdownLayout ? 'Worktree:' : 'Branch:'} - +
+ + Worktree: - {/* Dropdown layout for 3+ worktrees */} - {useDropdownLayout ? ( - <> - 0 ? ( + + ) : pinnedWorktreesCount === 0 ? ( + /* Only main worktree, no others exist - render main tab without highlight */ + mainWorktree && ( + - - {useWorktreesEnabled && ( - <> - - - - - )} - + ) ) : ( - /* 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 ( + = 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 && ( <> -
- {mainWorktree && ( - - )} -
+ - {/* Worktrees section - only show if enabled and not using dropdown layout */} - {useWorktreesEnabled && ( - <> -
- - Worktrees: - -
- {nonMainWorktrees.map((worktree) => { - const cardCount = branchCardCounts?.[worktree.branch]; - return ( - - ); - })} - - - - -
- - )} + )} diff --git a/apps/ui/src/components/views/file-editor-view/components/code-editor.tsx b/apps/ui/src/components/views/file-editor-view/components/code-editor.tsx index 1967c2a1..1fd63372 100644 --- a/apps/ui/src/components/views/file-editor-view/components/code-editor.tsx +++ b/apps/ui/src/components/views/file-editor-view/components/code-editor.tsx @@ -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; + /** + * 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; +} + +/** Parse a unified diff to extract added lines and groups of deleted lines */ +function parseUnifiedDiff(diffContent: string): DiffInfo { + const addedLines = new Set(); + const deletedGroups = new Map(); + 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(); + 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(); + 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(function onSave, className, scrollCursorIntoView = false, + diffContent, + onSelectionChange, }, ref ) { @@ -347,12 +416,17 @@ export const CodeEditor = forwardRef(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(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(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(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 (
diff --git a/apps/ui/src/components/views/file-editor-view/components/editor-tabs.tsx b/apps/ui/src/components/views/file-editor-view/components/editor-tabs.tsx index ebfec9a4..5215e617 100644 --- a/apps/ui/src/components/views/file-editor-view/components/editor-tabs.tsx +++ b/apps/ui/src/components/views/file-editor-view/components/editor-tabs.tsx @@ -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(null); + const activeTabRef = useRef(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 ( -
- {tabs.map((tab) => { - const isActive = tab.id === activeTabId; - const fileColor = getFileColor(tab.fileName); +
+ {/* Scroll left arrow */} + - return ( -
onTabSelect(tab.id)} - title={tab.filePath} - > - {/* Dirty indicator */} - {tab.isDirty ? ( - - ) : ( - - )} + {/* Scrollable tab area */} +
+ {tabs.map((tab) => { + const isActive = tab.id === activeTabId; + const fileColor = getFileColor(tab.fileName); - {/* File name */} - {tab.fileName} - - {/* Close button */} - -
- ); - })} + {/* Dirty indicator */} + {tab.isDirty ? ( + + ) : ( + + )} + + {/* File name */} + {tab.fileName} + + {/* Close button */} + +
+ ); + })} +
+ + {/* Scroll right arrow */} + {/* Tab actions: save button (mobile) + close-all dropdown */} -
+
{/* Save button — shown in the tab bar on mobile */} {showSaveButton && onSave && ( )} + {/* Desktop: Inline Diff toggle */} + {activeTab && + !activeTab.isBinary && + !activeTab.isTooLarge && + !(isMobile && mobileBrowserVisible) && ( + + )} + + {/* Desktop: Create Feature from selection */} + {hasEditorSelection && + activeTab && + !activeTab.isBinary && + !activeTab.isTooLarge && + !(isMobile && mobileBrowserVisible) && ( + + )} + {/* Editor Settings popover */} @@ -1415,6 +1662,37 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) { )} + {/* Inline Diff toggle */} + {activeTab && !activeTab.isBinary && !activeTab.isTooLarge && ( + + )} + + {/* Create Feature from selection */} + {hasEditorSelection && activeTab && !activeTab.isBinary && !activeTab.isTooLarge && ( + + )} + {/* File info */} {activeTab && !activeTab.isBinary && !activeTab.isTooLarge && (
@@ -1478,6 +1756,27 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) { )} + + {/* Add Feature Dialog - opened from code selection */} + { + 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" + />
); } diff --git a/apps/ui/src/components/views/file-editor-view/use-file-editor-store.ts b/apps/ui/src/components/views/file-editor-view/use-file-editor-store.ts index ef64fccc..bec7e2b3 100644 --- a/apps/ui/src/components/views/file-editor-view/use-file-editor-store.ts +++ b/apps/ui/src/components/views/file-editor-view/use-file-editor-store.ts @@ -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) => void; toggleSelectedPath: (path: string) => void; @@ -159,6 +168,8 @@ const initialState = { enhancedGitStatusMap: new Map(), gitBranch: '', activeFileGitDetails: null as GitFileDetailsInfo | null, + showInlineDiff: false, + activeFileDiff: null as string | null, dragState: { draggedPaths: [], dropTargetPath: null } as DragState, selectedPaths: new Set(), }; @@ -206,8 +217,18 @@ export const useFileEditorStore = create()( 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()( 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) => { diff --git a/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx b/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx index dae2885e..86ce4595 100644 --- a/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx +++ b/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx @@ -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(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 */}
+ {/* Worktree Display Settings */} +
+
+ + +
+

+ Control how worktrees are presented in the panel. Pinned worktrees appear as tabs, and + remaining worktrees are available in a combined overflow dropdown. +

+ + {/* Pinned Worktrees Count */} +
+
+ +
+
+
+ + + {pinnedWorktreesCount} + +
+

+ Number of worktree tabs to pin (excluding the main worktree, which is always shown). +

+ { + // 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" + /> +
+
+
+ + {/* Separator */} +
+ {/* Copy Files Section */}
diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts index 13012379..902d1107 100644 --- a/apps/ui/src/hooks/use-auto-mode.ts +++ b/apps/ui/src/hooks/use-auto-mode.ts @@ -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, diff --git a/apps/ui/src/hooks/use-electron-agent.ts b/apps/ui/src/hooks/use-electron-agent.ts index be13069c..b31050b7 100644 --- a/apps/ui/src/hooks/use-electron-agent.ts +++ b/apps/ui/src/hooks/use-electron-agent.ts @@ -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([]); 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( diff --git a/apps/ui/src/hooks/use-project-settings-loader.ts b/apps/ui/src/hooks/use-project-settings-loader.ts index 73006b34..e07051cc 100644 --- a/apps/ui/src/hooks/use-project-settings-loader.ts +++ b/apps/ui/src/hooks/use-project-settings-loader.ts @@ -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, ]); } diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index da2386d0..daa17cbe 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -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 ?? '', diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index 021d7119..25cb2ad8 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -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 { 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 { ...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, diff --git a/apps/ui/src/lib/codemirror-languages.ts b/apps/ui/src/lib/codemirror-languages.ts new file mode 100644 index 00000000..42a01027 --- /dev/null +++ b/apps/ui/src/lib/codemirror-languages.ts @@ -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 + } +} diff --git a/apps/ui/src/lib/diff-utils.ts b/apps/ui/src/lib/diff-utils.ts index fd141b46..21bd077f 100644 --- a/apps/ui/src/lib/diff-utils.ts +++ b/apps/ui/src/lib/diff-utils.ts @@ -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; +} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 5cca4c57..bc0c5fa0 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -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 { diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 4a019119..85f03e66 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -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; }; diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index c4d666fc..4d2275a9 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -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()((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()((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()((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 }), diff --git a/apps/ui/src/store/types/state-types.ts b/apps/ui/src/store/types/state-types.ts index c48673b1..e4f246a3 100644 --- a/apps/ui/src/store/types/state-types.ts +++ b/apps/ui/src/store/types/state-types.ts @@ -370,6 +370,17 @@ export interface AppState { // List of relative file paths to copy from project root into new worktrees worktreeCopyFilesByProject: Record; + // Worktree Display Settings (per-project, keyed by project path) + // Number of worktrees always visible (pinned) without expanding a dropdown (default: 1) + pinnedWorktreesCountByProject: Record; + // 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; + // Minimum number of worktrees before the list collapses into a dropdown (default: 3) + worktreeDropdownThresholdByProject: Record; + // Always use dropdown layout regardless of worktree count (default: false) + alwaysUseWorktreeDropdownByProject: Record; + // 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; diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 39d4ff6c..76805b88 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -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; diff --git a/apps/ui/tests/projects/overview-dashboard.spec.ts b/apps/ui/tests/projects/overview-dashboard.spec.ts index 4c986547..86a3c116 100644 --- a/apps/ui/tests/projects/overview-dashboard.spec.ts +++ b/apps/ui/tests/projects/overview-dashboard.spec.ts @@ -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; + 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 diff --git a/apps/ui/tests/settings/settings-startup-sync-race.spec.ts b/apps/ui/tests/settings/settings-startup-sync-race.spec.ts index c8543ff0..dfec69d9 100644 --- a/apps/ui/tests/settings/settings-startup-sync-race.spec.ts +++ b/apps/ui/tests/settings/settings-startup-sync-race.spec.ts @@ -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 }); diff --git a/apps/ui/tests/utils/navigation/views.ts b/apps/ui/tests/utils/navigation/views.ts index 5bc96062..28bb07cf 100644 --- a/apps/ui/tests/utils/navigation/views.ts +++ b/apps/ui/tests/utils/navigation/views.ts @@ -168,9 +168,11 @@ export async function navigateToWelcome(page: Page): Promise { // 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 }); } diff --git a/libs/git-utils/src/status.ts b/libs/git-utils/src/status.ts index 22ad9334..8cc5cfe2 100644 --- a/libs/git-utils/src/status.ts +++ b/libs/git-utils/src/status.ts @@ -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 { 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((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((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 { 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); diff --git a/libs/prompts/src/enhancement-modes/acceptance.ts b/libs/prompts/src/enhancement-modes/acceptance.ts index 5ac43c60..8dd48464 100644 --- a/libs/prompts/src/enhancement-modes/acceptance.ts +++ b/libs/prompts/src/enhancement-modes/acceptance.ts @@ -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. diff --git a/libs/prompts/src/enhancement-modes/technical.ts b/libs/prompts/src/enhancement-modes/technical.ts index 443ee359..aa266bc4 100644 --- a/libs/prompts/src/enhancement-modes/technical.ts +++ b/libs/prompts/src/enhancement-modes/technical.ts @@ -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 diff --git a/libs/prompts/src/enhancement-modes/ux-reviewer.ts b/libs/prompts/src/enhancement-modes/ux-reviewer.ts index f53ab68f..c144b870 100644 --- a/libs/prompts/src/enhancement-modes/ux-reviewer.ts +++ b/libs/prompts/src/enhancement-modes/ux-reviewer.ts @@ -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 diff --git a/libs/prompts/src/enhancement.ts b/libs/prompts/src/enhancement.ts index b79a3af4..32eaaa36 100644 --- a/libs/prompts/src/enhancement.ts +++ b/libs/prompts/src/enhancement.ts @@ -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}`; } diff --git a/libs/prompts/tests/enhancement.test.ts b/libs/prompts/tests/enhancement.test.ts index ee1f16aa..5177a0ea 100644 --- a/libs/prompts/tests/enhancement.test.ts +++ b/libs/prompts/tests/enhancement.test.ts @@ -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); }); diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 91e44d27..759caedf 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -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 diff --git a/package-lock.json b/package-lock.json index ceb868d4..703bd1b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c58b16fa..ed9285a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "automaker", - "version": "0.13.0", + "version": "0.15.0", "license": "MIT", "private": true, "engines": {