From 68c0ce1ecc3762e0767934652737e1e1bf750ec9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 23 Feb 2026 00:17:23 -0800 Subject: [PATCH 01/15] Changes from feature/worktree-view-customization --- apps/server/package.json | 2 +- apps/server/src/lib/sdk-options.ts | 8 + apps/server/src/providers/codex-provider.ts | 5 + apps/server/src/routes/features/index.ts | 6 +- .../src/routes/features/routes/update.ts | 17 +- apps/server/src/services/agent-service.ts | 14 +- apps/server/src/services/auto-mode/facade.ts | 2 + .../server/src/services/event-hook-service.ts | 55 ++ apps/server/src/services/execution-service.ts | 2 + .../src/services/pipeline-orchestrator.ts | 6 + .../unit/services/event-hook-service.test.ts | 59 +- apps/ui/package.json | 3 +- .../components/ui/codemirror-diff-view.tsx | 220 +++++++ apps/ui/src/components/ui/git-diff-panel.tsx | 251 +------- .../shared/enhancement/enhance-with-ai.tsx | 35 +- .../enhancement/enhancement-constants.ts | 11 + .../components/worktree-actions-dropdown.tsx | 44 ++ .../components/worktree-dropdown.tsx | 37 +- .../components/worktree-tab.tsx | 16 + .../worktree-panel/worktree-panel.tsx | 595 ++++++++++-------- .../components/code-editor.tsx | 408 +++++++----- .../components/editor-tabs.tsx | 135 ++-- .../file-editor-view/components/file-tree.tsx | 101 ++- .../file-editor-view/file-editor-view.tsx | 336 +++++++++- .../file-editor-view/use-file-editor-store.ts | 34 +- .../worktree-preferences-section.tsx | 78 +++ .../src/hooks/use-project-settings-loader.ts | 26 + apps/ui/src/hooks/use-settings-migration.ts | 2 +- apps/ui/src/lib/codemirror-languages.ts | 155 +++++ apps/ui/src/lib/diff-utils.ts | 127 ++++ apps/ui/src/lib/http-api-client.ts | 3 + apps/ui/src/store/app-store.ts | 60 +- apps/ui/src/store/types/state-types.ts | 22 + .../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/types/src/settings.ts | 32 +- package-lock.json | 22 +- package.json | 2 +- 40 files changed, 2202 insertions(+), 797 deletions(-) create mode 100644 apps/ui/src/components/ui/codemirror-diff-view.tsx create mode 100644 apps/ui/src/lib/codemirror-languages.ts diff --git a/apps/server/package.json b/apps/server/package.json index 4a7a75a8b..75818b182 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 915b55c2d..ec20cf3ab 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/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 10146591c..45e37d317 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/routes/features/index.ts b/apps/server/src/routes/features/index.ts index 0c92a4467..a4ea03b45 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 4d5e7a00e..4e3203f25 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,6 +55,20 @@ export function createUpdateHandler(featureLoader: FeatureLoader) { preEnhancementDescription ); + // Manual feature updates do not emit auto_mode_feature_complete. + // Emit a dedicated completion event when status transitions into a completed state. + 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', + }); + } + // Trigger sync to app_spec.txt when status changes to verified or completed if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) { try { diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 8d2275e54..ec725f343 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -474,7 +474,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 diff --git a/apps/server/src/services/auto-mode/facade.ts b/apps/server/src/services/auto-mode/facade.ts index c00dfdb81..7d194e720 100644 --- a/apps/server/src/services/auto-mode/facade.ts +++ b/apps/server/src/services/auto-mode/facade.ts @@ -761,6 +761,7 @@ export class AutoModeServiceFacade { featureId, featureName: feature?.title, branchName: feature?.branchName ?? null, + executionMode: 'auto', passes: allPassed, message: allPassed ? 'All verification checks passed' @@ -823,6 +824,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 ff14a993f..990116979 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; @@ -75,6 +76,18 @@ interface FeatureCreatedPayload { projectPath: string; } +/** + * Feature completed event payload structure + */ +interface FeatureCompletedPayload { + featureId: string; + featureName?: string; + projectPath: string; + passes?: boolean; + message?: string; + executionMode?: 'auto' | 'manual'; +} + /** * Event Hook Service * @@ -108,6 +121,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); } }); @@ -139,6 +154,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'; break; case 'auto_mode_error': @@ -187,6 +205,43 @@ 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; + + 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 c98e9d895..a95b1ea2b 100644 --- a/apps/server/src/services/execution-service.ts +++ b/apps/server/src/services/execution-service.ts @@ -446,6 +446,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, @@ -462,6 +463,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, diff --git a/apps/server/src/services/pipeline-orchestrator.ts b/apps/server/src/services/pipeline-orchestrator.ts index 2e79b24b0..fe2e28528 100644 --- a/apps/server/src/services/pipeline-orchestrator.ts +++ b/apps/server/src/services/pipeline-orchestrator.ts @@ -232,6 +232,7 @@ export class PipelineOrchestrator { featureId, featureName: feature.title, branchName: feature.branchName ?? null, + executionMode: 'auto', passes: true, message: 'Pipeline step no longer exists', projectPath, @@ -281,6 +282,7 @@ export class PipelineOrchestrator { featureId, featureName: feature.title, branchName: feature.branchName ?? null, + executionMode: 'auto', passes: true, message: 'Pipeline completed (remaining steps excluded)', projectPath, @@ -306,6 +308,7 @@ export class PipelineOrchestrator { featureId, featureName: feature.title, branchName: feature.branchName ?? null, + executionMode: 'auto', passes: true, message: 'Pipeline completed (all steps excluded)', projectPath, @@ -384,6 +387,7 @@ export class PipelineOrchestrator { featureId, featureName: feature.title, branchName: feature.branchName ?? null, + executionMode: 'auto', passes: true, message: 'Pipeline resumed successfully', projectPath, @@ -397,6 +401,7 @@ export class PipelineOrchestrator { featureId, featureName: feature.title, branchName: feature.branchName ?? null, + executionMode: 'auto', passes: false, message: 'Pipeline stopped by user', projectPath, @@ -556,6 +561,7 @@ export class PipelineOrchestrator { featureId, featureName: feature.title, branchName, + executionMode: 'auto', passes: true, message: 'Pipeline completed and merged', projectPath, 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 1448fb809..e01570716 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, @@ -561,6 +617,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/ui/package.json b/apps/ui/package.json index b93fd7c60..7b2c35f1b 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/src/components/ui/codemirror-diff-view.tsx b/apps/ui/src/components/ui/codemirror-diff-view.tsx new file mode 100644 index 000000000..703bf0b42 --- /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 cd57cfe75..12c4ae05c 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/shared/enhancement/enhance-with-ai.tsx b/apps/ui/src/components/views/board-view/shared/enhancement/enhance-with-ai.tsx index 3429584b2..8ab1bb502 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 7338ea8b5..bb5aeaf83 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 @@ -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 560ff807e..1a8ff7c91 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 @@ -42,6 +42,8 @@ import { XCircle, CheckCircle, Settings2, + ArrowLeftRight, + Check, } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; @@ -138,6 +140,14 @@ interface WorktreeActionsDropdownProps { onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void; /** Callback to open the script editor UI */ onEditScripts?: () => void; + /** 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 WorktreeActionsDropdown({ @@ -198,6 +208,10 @@ export function WorktreeActionsDropdown({ terminalScripts, onRunTerminalScript, onEditScripts, + availableWorktreesForSwap, + slotIndex, + onSwapWorktree, + pinnedBranches, }: WorktreeActionsDropdownProps) { // Get available editors for the "Open In" submenu const { editors } = useAvailableEditors(); @@ -1161,6 +1175,36 @@ export function WorktreeActionsDropdown({ Delete Worktree )} + {/* 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 && } + + ); + })} + + + )} ); 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 a791ae63e..dd4e7eaa0 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 @@ -138,6 +138,8 @@ export interface WorktreeDropdownProps { onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void; /** Callback to open the script editor UI */ onEditScripts?: () => void; + /** When false, the trigger button uses a subdued style instead of the primary highlight. Defaults to true. */ + highlightTrigger?: boolean; } /** @@ -230,10 +232,11 @@ export function WorktreeDropdown({ terminalScripts, onRunTerminalScript, onEditScripts, + 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} more`; const { truncated: truncatedBranch, isTruncated: isBranchNameTruncated } = truncateBranchName( displayBranch, MAX_TRIGGER_BRANCH_NAME_LENGTH @@ -277,15 +280,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 @@ -475,7 +498,7 @@ export function WorktreeDropdown({ {selectedWorktree?.isMain && ( void; /** Callback to open the script editor UI */ onEditScripts?: () => void; + /** 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({ @@ -181,6 +189,10 @@ export function WorktreeTab({ terminalScripts, onRunTerminalScript, onEditScripts, + availableWorktreesForSwap, + slotIndex, + onSwapWorktree, + pinnedBranches, }: WorktreeTabProps) { // Make the worktree tab a drop target for feature cards const { setNodeRef, isOver } = useDroppable({ @@ -550,6 +562,10 @@ export function WorktreeTab({ terminalScripts={terminalScripts} onRunTerminalScript={onRunTerminalScript} onEditScripts={onEditScripts} + availableWorktreesForSwap={availableWorktreesForSwap} + slotIndex={slotIndex} + onSwapWorktree={onSwapWorktree} + pinnedBranches={pinnedBranches} />
); 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 a85ace575..b5f1ee4be 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, @@ -99,7 +100,6 @@ export function WorktreePanel({ aheadCount, behindCount, hasRemoteBranch, - trackingRemote, getTrackingRemote, isLoadingBranches, branchFilter, @@ -135,13 +135,114 @@ 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) => { + // slotIndex here is the index in the "other" slots (0-based, excluding main) + const currentPinned = [...pinnedWorktreeBranches]; + + // Ensure the array is long enough + const otherSlotCount = Math.max(0, pinnedWorktreesCount); + while (currentPinned.length < otherSlotCount) { + // Fill with current defaults + const mainWt = worktrees.find((w) => w.isMain); + const otherWts = worktrees.filter((w) => !w.isMain); + const usedBranches = new Set(currentPinned.filter(Boolean)); + if (mainWt) usedBranches.add(mainWt.branch); + const available = otherWts.find((w) => !usedBranches.has(w.branch)); + currentPinned.push(available?.branch ?? ''); + } + + swapPinnedWorktreeBranch(projectPath, slotIndex, newBranch); + }, + [pinnedWorktreeBranches, pinnedWorktreesCount, worktrees, 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 && + (!storedBranches || storedBranches.length === 0) + ) { + // Initialize with default ordering + const defaultBranches = otherWts.slice(0, otherSlotCount).map((w) => w.branch); + setPinnedWorktreeBranches(projectPath, defaultBranches); + } + }, [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 => { @@ -871,7 +972,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) { @@ -1124,62 +1224,129 @@ 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:'} - - - {/* Dropdown layout for 3+ worktrees */} - {useDropdownLayout ? ( - <> - + + Worktree: + + {/* When only 1 pinned slot (main only) and there are other worktrees, + use a compact dropdown to switch between them without highlighting main */} + {pinnedWorktreesCount === 0 && availableWorktreesForSwap.length > 0 ? ( + + ) : 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)} + /> + ); + }) + )} + + {/* 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 1967c2a17..70cbe4175 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,217 @@ 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(); + if (!line.startsWith('\\')) { + // Skip "\ No newline at end of file" + 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: DeletedLinesWidget) { + 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 (let i = 1; i <= doc.lines; i++) { + if (addedLines.has(i)) { + const linePos = doc.line(i).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 <= 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 +407,8 @@ export const CodeEditor = forwardRef(function onSave, className, scrollCursorIntoView = false, + diffContent, + onSelectionChange, }, ref ) { @@ -347,12 +418,16 @@ 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); useEffect(() => { onSaveRef.current = onSave; }, [onSave]); useEffect(() => { onCursorChangeRef.current = onCursorChange; }, [onCursorChange]); + useEffect(() => { + onSelectionChangeRef.current = onSelectionChange; + }, [onSelectionChange]); // Expose imperative methods to parent components useImperativeHandle( @@ -381,6 +456,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 +622,16 @@ 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; + onSelectionChangeRef.current(from !== to); + } } }), ]; @@ -572,8 +663,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 ebfec9a4b..4c8f4d1c9 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,103 @@ 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); - - return ( -
onTabSelect(tab.id)} - title={tab.filePath} - > - {/* Dirty indicator */} - {tab.isDirty ? ( - - ) : ( - - )} +
+ {/* Scroll left arrow */} + - {/* File name */} - {tab.fileName} + {/* Scrollable tab area */} +
+ {tabs.map((tab) => { + const isActive = tab.id === activeTabId; + const fileColor = getFileColor(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 +1661,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 +1755,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 ef64fcccf..65dbc9ca1 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 @@ -65,6 +65,14 @@ export interface DragState { dropTargetPath: string | null; } +/** Inline diff line information */ +export interface DiffLine { + type: 'added' | 'removed' | 'unchanged'; + content: string; + oldLineNumber?: number; + newLineNumber?: number; +} + interface FileEditorState { // File tree state fileTree: FileTreeNode[]; @@ -101,6 +109,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 +149,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 +176,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 +225,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 +311,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 dae2885ee..5567ec497 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 @@ -3,6 +3,7 @@ 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,12 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti const copyFiles = copyFilesFromStore ?? EMPTY_FILES; const setWorktreeCopyFiles = useAppStore((s) => s.setWorktreeCopyFiles); + // Worktree display settings + const pinnedWorktreesCount = useAppStore( + (s) => s.pinnedWorktreesCountByProject[project.path] ?? 0 + ); + const setPinnedWorktreesCount = useAppStore((s) => s.setPinnedWorktreesCount); + // Get effective worktrees setting (project override or global fallback) const effectiveUseWorktrees = projectUseWorktrees ?? globalUseWorktrees; @@ -115,6 +124,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 +147,7 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti setDefaultDeleteBranch, setAutoDismissInitScriptIndicator, setWorktreeCopyFiles, + setPinnedWorktreesCount, ]); // Load init script content when project changes @@ -507,6 +520,71 @@ 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). +

+ { + // Update local state immediately for visual feedback + const newValue = value[0] ?? 1; + setPinnedWorktreesCount(project.path, newValue); + }} + onValueCommit={async (value) => { + const newValue = value[0] ?? 1; + + // 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'); + } + }} + className="w-full" + /> +
+
+
+ + {/* Separator */} +
+ {/* Copy Files Section */}
diff --git a/apps/ui/src/hooks/use-project-settings-loader.ts b/apps/ui/src/hooks/use-project-settings-loader.ts index 73006b341..e07051cc9 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 9bed05b8a..91b8388c6 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -747,7 +747,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { enhancementModel: settings.enhancementModel ?? 'claude-sonnet', validationModel: settings.validationModel ?? 'claude-opus', phaseModels: 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, diff --git a/apps/ui/src/lib/codemirror-languages.ts b/apps/ui/src/lib/codemirror-languages.ts new file mode 100644 index 000000000..979363f7d --- /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 fd141b46c..21bd077f4 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/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 8e6ebb05d..37f9c91a3 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -2720,6 +2720,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 902ed8857..6a15dba21 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -367,7 +367,7 @@ 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, pendingPlanApproval: null, @@ -392,6 +392,10 @@ const initialState: AppState = { autoDismissInitScriptIndicatorByProject: {}, useWorktreesByProject: {}, worktreeCopyFilesByProject: {}, + pinnedWorktreesCountByProject: {}, + pinnedWorktreeBranchesByProject: {}, + worktreeDropdownThresholdByProject: {}, + alwaysUseWorktreeDropdownByProject: {}, worktreePanelCollapsed: false, lastProjectDir: '', recentFolders: [], @@ -2536,6 +2540,60 @@ 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 current = [...(state.pinnedWorktreeBranchesByProject[projectPath] ?? [])]; + // If the new branch is already in another slot, swap them + const existingIndex = current.indexOf(newBranch); + 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 aeeaa2a8b..c0a212a08 100644 --- a/apps/ui/src/store/types/state-types.ts +++ b/apps/ui/src/store/types/state-types.ts @@ -363,6 +363,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; @@ -794,6 +805,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/libs/prompts/src/enhancement-modes/acceptance.ts b/libs/prompts/src/enhancement-modes/acceptance.ts index 5ac43c603..8dd484647 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 443ee3593..aa266bc47 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 f53ab68f1..c144b870d 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 b79a3af40..32eaaa36c 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/types/src/settings.ts b/libs/types/src/settings.ts index 6af9faf50..6a5f3f765 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -271,10 +271,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'; } @@ -1434,6 +1437,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; @@ -1556,7 +1576,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' }, @@ -1622,7 +1642,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', @@ -1630,7 +1650,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { showQueryDevtools: true, enableAiCommitMessages: true, phaseModels: DEFAULT_PHASE_MODELS, - defaultThinkingLevel: 'none', + defaultThinkingLevel: 'adaptive', defaultReasoningEffort: 'none', defaultMaxTurns: 1000, enhancementModel: 'sonnet', // Legacy alias still supported diff --git a/package-lock.json b/package-lock.json index ceb868d4f..703bd1b98 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 c58b16fa8..ed9285a0d 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": { From 478a0ffef77c9b3cb382c82eca2d5734e91d78e0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 21 Feb 2026 13:56:48 -0800 Subject: [PATCH 02/15] Fix Docker Compose CORS issues with nginx API proxying (#793) * Changes from fix/docker-compose-cors-error * Update apps/server/src/index.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Fix: Delete Worktree Crash + PR Comments + Dev Server UX Improvements (#792) * Changes from fix/delete-worktree-hotifx * fix: Improve bot detection and prevent UI overflow issues - Include GitHub app-initiated comments in bot detection - Wrap handleQuickCreateSession with useCallback to fix dependency issues - Truncate long branch names in agent header to prevent layout overflow * feat: Support GitHub App comments in PR review and fix session filtering * feat: Return invalidation result from delete session handler * fix: Improve CORS origin validation to handle wildcard correctly * fix: Correct IPv6 localhost parsing and improve responsive UI layouts * Changes from fix/pwa-cache-fix (#794) * fix: Add type checking to prevent crashes from malformed cache entries --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- Dockerfile | 7 ++- apps/server/src/index.ts | 58 +++++++++++-------- apps/ui/nginx.conf | 19 ++++++ .../dialogs/pr-comment-resolution-dialog.tsx | 6 +- apps/ui/src/components/session-manager.tsx | 7 ++- apps/ui/src/components/views/board-view.tsx | 11 +++- .../board-view/hooks/use-board-actions.ts | 5 +- .../components/worktree-actions-dropdown.tsx | 21 +++---- .../worktree-panel/hooks/use-dev-servers.ts | 3 +- apps/ui/src/store/ui-cache-store.ts | 8 ++- docker-compose.yml | 6 +- 11 files changed, 99 insertions(+), 52 deletions(-) diff --git a/Dockerfile b/Dockerfile index a68901e4d..7d48e15fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -209,9 +209,10 @@ COPY libs ./libs COPY apps/ui ./apps/ui # Build packages in dependency order, then build UI -# VITE_SERVER_URL tells the UI where to find the API server -# Use ARG to allow overriding at build time: --build-arg VITE_SERVER_URL=http://api.example.com -ARG VITE_SERVER_URL=http://localhost:3008 +# When VITE_SERVER_URL is empty, the UI uses relative URLs (e.g., /api/...) which nginx proxies +# to the server container. This avoids CORS issues entirely in Docker Compose setups. +# Override at build time if needed: --build-arg VITE_SERVER_URL=http://api.example.com +ARG VITE_SERVER_URL= ENV VITE_SKIP_ELECTRON=true ENV VITE_SERVER_URL=${VITE_SERVER_URL} RUN npm run build:packages && npm run build --workspace=apps/ui diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 0acea6c9b..dcd45da80 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -267,6 +267,26 @@ app.use( // CORS configuration // When using credentials (cookies), origin cannot be '*' // We dynamically allow the requesting origin for local development + +// Check if origin is a local/private network address +function isLocalOrigin(origin: string): boolean { + try { + const url = new URL(origin); + const hostname = url.hostname; + return ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '[::1]' || + hostname === '0.0.0.0' || + hostname.startsWith('192.168.') || + hostname.startsWith('10.') || + /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) + ); + } catch { + return false; + } +} + app.use( cors({ origin: (origin, callback) => { @@ -277,35 +297,25 @@ app.use( } // If CORS_ORIGIN is set, use it (can be comma-separated list) - const allowedOrigins = process.env.CORS_ORIGIN?.split(',').map((o) => o.trim()); - if (allowedOrigins && allowedOrigins.length > 0 && allowedOrigins[0] !== '*') { + const allowedOrigins = process.env.CORS_ORIGIN?.split(',') + .map((o) => o.trim()) + .filter(Boolean); + if (allowedOrigins && allowedOrigins.length > 0) { + if (allowedOrigins.includes('*')) { + callback(null, true); + return; + } if (allowedOrigins.includes(origin)) { callback(null, origin); - } else { - callback(new Error('Not allowed by CORS')); + return; } - return; + // Fall through to local network check below } - // For local development, allow all localhost/loopback origins (any port) - try { - const url = new URL(origin); - const hostname = url.hostname; - - if ( - hostname === 'localhost' || - hostname === '127.0.0.1' || - hostname === '::1' || - hostname === '0.0.0.0' || - hostname.startsWith('192.168.') || - hostname.startsWith('10.') || - hostname.startsWith('172.') - ) { - callback(null, origin); - return; - } - } catch { - // Ignore URL parsing errors + // Allow all localhost/loopback/private network origins (any port) + if (isLocalOrigin(origin)) { + callback(null, origin); + return; } // Reject other origins by default for security diff --git a/apps/ui/nginx.conf b/apps/ui/nginx.conf index 2d96d1589..6c50a1570 100644 --- a/apps/ui/nginx.conf +++ b/apps/ui/nginx.conf @@ -1,9 +1,28 @@ +# Map for conditional WebSocket upgrade header +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; + # Proxy API and WebSocket requests to the backend server container + # Handles both HTTP API calls and WebSocket upgrades (/api/events, /api/terminal/ws) + location /api/ { + proxy_pass http://server:3008; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx b/apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx index fa54ffcdc..b0f86aa1d 100644 --- a/apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx +++ b/apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx @@ -914,7 +914,7 @@ export function PRCommentResolutionDialog({ {!loading && !error && allComments.length > 0 && ( <> {/* Controls Bar */} -
+
{/* Select All - only interactive when there are visible comments */}
-
+
{/* Show/Hide Resolved Filter Toggle - always visible */}
diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index dc87cad0e..aea380387 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -116,6 +116,14 @@ interface WorktreeTabProps { onSwapWorktree?: (slotIndex: number, newBranch: string) => void; /** List of currently pinned branch names (to show which are pinned in the swap dropdown) */ pinnedBranches?: string[]; + /** Whether sync is in progress */ + isSyncing?: boolean; + /** Sync (pull + push) callback */ + onSync?: (worktree: WorktreeInfo) => void; + /** Sync with a specific remote */ + onSyncWithRemote?: (worktree: WorktreeInfo, remote: string) => void; + /** Set tracking branch to a specific remote */ + onSetTracking?: (worktree: WorktreeInfo, remote: string) => void; } export function WorktreeTab({ @@ -193,6 +201,10 @@ export function WorktreeTab({ slotIndex, onSwapWorktree, pinnedBranches, + isSyncing = false, + onSync, + onSyncWithRemote, + onSetTracking, }: WorktreeTabProps) { // Make the worktree tab a drop target for feature cards const { setNodeRef, isOver } = useDroppable({ @@ -566,6 +578,10 @@ export function WorktreeTab({ slotIndex={slotIndex} onSwapWorktree={onSwapWorktree} pinnedBranches={pinnedBranches} + isSyncing={isSyncing} + onSync={onSync} + onSyncWithRemote={onSyncWithRemote} + onSetTracking={onSetTracking} />
); diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts index afc7df78e..9b36317a1 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts @@ -8,6 +8,8 @@ import { useSwitchBranch, usePullWorktree, usePushWorktree, + useSyncWorktree, + useSetTracking, useOpenInEditor, } from '@/hooks/mutations'; import type { WorktreeInfo } from '../types'; @@ -51,6 +53,8 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) { }); const pullMutation = usePullWorktree(); const pushMutation = usePushWorktree(); + const syncMutation = useSyncWorktree(); + const setTrackingMutation = useSetTracking(); const openInEditorMutation = useOpenInEditor(); /** @@ -150,6 +154,28 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) { [pushMutation] ); + const handleSync = useCallback( + async (worktree: WorktreeInfo, remote?: string) => { + if (syncMutation.isPending) return; + syncMutation.mutate({ + worktreePath: worktree.path, + remote, + }); + }, + [syncMutation] + ); + + const handleSetTracking = useCallback( + async (worktree: WorktreeInfo, remote: string) => { + if (setTrackingMutation.isPending) return; + setTrackingMutation.mutate({ + worktreePath: worktree.path, + remote, + }); + }, + [setTrackingMutation] + ); + const handleOpenInIntegratedTerminal = useCallback( (worktree: WorktreeInfo, mode?: 'tab' | 'split') => { // Navigate to the terminal view with the worktree path and branch name @@ -215,12 +241,15 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) { return { isPulling: pullMutation.isPending, isPushing: pushMutation.isPending, + isSyncing: syncMutation.isPending, isSwitching: switchBranchMutation.isPending, isActivating, setIsActivating, handleSwitchBranch, handlePull, handlePush, + handleSync, + handleSetTracking, handleOpenInIntegratedTerminal, handleRunTerminalScript, handleOpenInEditor, 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 b5f1ee4be..be251a7f2 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 @@ -113,11 +113,14 @@ export function WorktreePanel({ const { isPulling, isPushing, + isSyncing, isSwitching, isActivating, handleSwitchBranch, handlePull: _handlePull, handlePush, + handleSync, + handleSetTracking, handleOpenInIntegratedTerminal, handleRunTerminalScript, handleOpenInEditor, @@ -929,6 +932,30 @@ export function WorktreePanel({ [handlePush, fetchBranches, fetchWorktrees] ); + // Handle sync (pull + push) with optional remote selection + const handleSyncWithRemoteSelection = useCallback( + (worktree: WorktreeInfo) => { + handleSync(worktree); + }, + [handleSync] + ); + + // Handle sync with a specific remote selected from the submenu + const handleSyncWithSpecificRemote = useCallback( + (worktree: WorktreeInfo, remote: string) => { + handleSync(worktree, remote); + }, + [handleSync] + ); + + // Handle set tracking branch for a specific remote + const handleSetTrackingForRemote = useCallback( + (worktree: WorktreeInfo, remote: string) => { + handleSetTracking(worktree, remote); + }, + [handleSetTracking] + ); + // Handle confirming the push to remote dialog const handleConfirmPushToRemote = useCallback( async (worktree: WorktreeInfo, remote: string) => { @@ -1036,6 +1063,10 @@ export function WorktreePanel({ onPushNewBranch={handlePushNewBranch} onPullWithRemote={handlePullWithSpecificRemote} onPushWithRemote={handlePushWithSpecificRemote} + isSyncing={isSyncing} + onSync={handleSyncWithRemoteSelection} + onSyncWithRemote={handleSyncWithSpecificRemote} + onSetTracking={handleSetTrackingForRemote} onOpenInEditor={handleOpenInEditor} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal} @@ -1347,6 +1378,10 @@ export function WorktreePanel({ onPullWithRemote={handlePullWithSpecificRemote} onPushWithRemote={handlePushWithSpecificRemote} remotes={remotesCache[mainWorktree.path]} + isSyncing={isSyncing} + onSync={handleSyncWithRemoteSelection} + onSyncWithRemote={handleSyncWithSpecificRemote} + onSetTracking={handleSetTrackingForRemote} onOpenInEditor={handleOpenInEditor} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal} @@ -1468,6 +1503,10 @@ export function WorktreePanel({ slotIndex={slotIndex >= 0 ? slotIndex : undefined} onSwapWorktree={slotIndex >= 0 ? handleSwapWorktreeSlot : undefined} pinnedBranches={pinnedWorktrees.map((w) => w.branch)} + isSyncing={isSyncing} + onSync={handleSyncWithRemoteSelection} + onSyncWithRemote={handleSyncWithSpecificRemote} + onSetTracking={handleSetTrackingForRemote} /> ); }) diff --git a/apps/ui/src/hooks/mutations/index.ts b/apps/ui/src/hooks/mutations/index.ts index e0d591bf3..50a537adc 100644 --- a/apps/ui/src/hooks/mutations/index.ts +++ b/apps/ui/src/hooks/mutations/index.ts @@ -46,6 +46,8 @@ export { useCommitWorktree, usePushWorktree, usePullWorktree, + useSyncWorktree, + useSetTracking, useCreatePullRequest, useMergeWorktree, useSwitchBranch, diff --git a/apps/ui/src/hooks/mutations/use-worktree-mutations.ts b/apps/ui/src/hooks/mutations/use-worktree-mutations.ts index 10dab0acf..467f6606d 100644 --- a/apps/ui/src/hooks/mutations/use-worktree-mutations.ts +++ b/apps/ui/src/hooks/mutations/use-worktree-mutations.ts @@ -197,6 +197,76 @@ export function usePullWorktree() { }); } +/** + * Sync worktree branch (pull then push) + * + * @returns Mutation for syncing changes + */ +export function useSyncWorktree() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ worktreePath, remote }: { worktreePath: string; remote?: string }) => { + const api = getElectronAPI(); + if (!api.worktree) throw new Error('Worktree API not available'); + const result = await api.worktree.sync(worktreePath, remote); + if (!result.success) { + throw new Error(result.error || 'Failed to sync'); + } + return result.result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['worktrees'] }); + toast.success('Branch synced with remote'); + }, + onError: (error: Error) => { + toast.error('Failed to sync', { + description: error.message, + }); + }, + }); +} + +/** + * Set upstream tracking branch + * + * @returns Mutation for setting tracking branch + */ +export function useSetTracking() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + worktreePath, + remote, + branch, + }: { + worktreePath: string; + remote: string; + branch?: string; + }) => { + const api = getElectronAPI(); + if (!api.worktree) throw new Error('Worktree API not available'); + const result = await api.worktree.setTracking(worktreePath, remote, branch); + if (!result.success) { + throw new Error(result.error || 'Failed to set tracking branch'); + } + return result.result; + }, + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ['worktrees'] }); + toast.success('Tracking branch set', { + description: result?.message, + }); + }, + onError: (error: Error) => { + toast.error('Failed to set tracking branch', { + description: error.message, + }); + }, + }); +} + /** * Create a pull request from a worktree * diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index cecaf3bf1..b93c2dca1 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -2268,7 +2268,12 @@ function createMockWorktreeAPI(): WorktreeAPI { }; }, - push: async (worktreePath: string, force?: boolean, remote?: string) => { + push: async ( + worktreePath: string, + force?: boolean, + remote?: string, + _autoResolve?: boolean + ) => { const targetRemote = remote || 'origin'; console.log('[Mock] Pushing worktree:', { worktreePath, force, remote: targetRemote }); return { @@ -2281,6 +2286,38 @@ function createMockWorktreeAPI(): WorktreeAPI { }; }, + sync: async (worktreePath: string, remote?: string) => { + const targetRemote = remote || 'origin'; + console.log('[Mock] Syncing worktree:', { worktreePath, remote: targetRemote }); + return { + success: true, + result: { + branch: 'feature-branch', + pulled: true, + pushed: true, + message: `Synced with ${targetRemote}`, + }, + }; + }, + + setTracking: async (worktreePath: string, remote: string, branch?: string) => { + const targetBranch = branch || 'feature-branch'; + console.log('[Mock] Setting tracking branch:', { + worktreePath, + remote, + branch: targetBranch, + }); + return { + success: true, + result: { + branch: targetBranch, + remote, + upstream: `${remote}/${targetBranch}`, + message: `Set tracking branch to ${remote}/${targetBranch}`, + }, + }; + }, + createPR: async (worktreePath: string, options?: CreatePROptions) => { console.log('[Mock] Creating PR:', { worktreePath, options }); return { diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 37f9c91a3..c2c63f26b 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -2208,8 +2208,12 @@ export class HttpApiClient implements ElectronAPI { this.post('/api/worktree/generate-commit-message', { worktreePath }), generatePRDescription: (worktreePath: string, baseBranch?: string) => this.post('/api/worktree/generate-pr-description', { worktreePath, baseBranch }), - push: (worktreePath: string, force?: boolean, remote?: string) => - this.post('/api/worktree/push', { worktreePath, force, remote }), + push: (worktreePath: string, force?: boolean, remote?: string, autoResolve?: boolean) => + this.post('/api/worktree/push', { worktreePath, force, remote, autoResolve }), + sync: (worktreePath: string, remote?: string) => + this.post('/api/worktree/sync', { worktreePath, remote }), + setTracking: (worktreePath: string, remote: string, branch?: string) => + this.post('/api/worktree/set-tracking', { worktreePath, remote, branch }), createPR: (worktreePath: string, options?: CreatePROptions) => this.post('/api/worktree/create-pr', { worktreePath, ...options }), getDiffs: (projectPath: string, featureId: string) => diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index b2a346008..e29e48715 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -980,18 +980,61 @@ export interface WorktreeAPI { push: ( worktreePath: string, force?: boolean, - remote?: string + remote?: string, + autoResolve?: boolean ) => Promise<{ success: boolean; result?: { branch: string; pushed: boolean; + diverged?: boolean; + autoResolved?: boolean; message: string; }; error?: string; + diverged?: boolean; + hasConflicts?: boolean; + conflictFiles?: string[]; code?: 'NOT_GIT_REPO' | 'NO_COMMITS'; }>; + // Sync a worktree branch (pull then push) + sync: ( + worktreePath: string, + remote?: string + ) => Promise<{ + success: boolean; + result?: { + branch: string; + pulled: boolean; + pushed: boolean; + isFastForward?: boolean; + isMerge?: boolean; + autoResolved?: boolean; + message: string; + }; + error?: string; + hasConflicts?: boolean; + conflictFiles?: string[]; + conflictSource?: 'pull' | 'stash'; + }>; + + // Set the upstream tracking branch + setTracking: ( + worktreePath: string, + remote: string, + branch?: string + ) => Promise<{ + success: boolean; + result?: { + branch: string; + remote: string; + upstream: string; + message: string; + }; + error?: string; + }>; + // Create a pull request from a worktree createPR: ( worktreePath: string, From 0d2bc80a3e28ea6357de7d90e85d8303ff71a805 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 21 Feb 2026 23:58:09 -0800 Subject: [PATCH 05/15] Fix Codex CLI timeout handling and improve CI workflows (#797) * Changes from fix/codex-cli-timeout * test: Clarify timeout values and multipliers in codex-provider tests * refactor: Rename useWorktreesEnabled to worktreesEnabled for clarity --- apps/server/src/providers/codex-provider.ts | 3 +- .../unit/providers/codex-provider.test.ts | 12 ++++--- .../board-view/hooks/use-board-actions.ts | 36 ++++++++++--------- .../board-view/hooks/use-board-drag-drop.ts | 7 +++- .../board-view/hooks/use-board-persistence.ts | 6 +++- 5 files changed, 39 insertions(+), 25 deletions(-) diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 45e37d317..22767d3a8 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -33,7 +33,6 @@ import { supportsReasoningEffort, validateBareModelId, calculateReasoningTimeout, - DEFAULT_TIMEOUT_MS, type CodexApprovalPolicy, type CodexSandboxMode, type CodexAuthStatus, @@ -98,7 +97,7 @@ const TEXT_ENCODING = 'utf-8'; * * @see calculateReasoningTimeout from @automaker/types */ -const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS; +const CODEX_CLI_TIMEOUT_MS = 120000; // 2 minutes — matches CLI provider base timeout const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation const SYSTEM_PROMPT_SEPARATOR = '\n\n'; const CODEX_INSTRUCTIONS_DIR = '.codex'; diff --git a/apps/server/tests/unit/providers/codex-provider.test.ts b/apps/server/tests/unit/providers/codex-provider.test.ts index 03cd5591d..0121fd170 100644 --- a/apps/server/tests/unit/providers/codex-provider.test.ts +++ b/apps/server/tests/unit/providers/codex-provider.test.ts @@ -320,8 +320,10 @@ describe('codex-provider.ts', () => { ); const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; - // High reasoning effort should have 3x the default timeout (90000ms) - expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high); + // High reasoning effort should have 3x the CLI base timeout (120000ms) + // CODEX_CLI_TIMEOUT_MS = 120000, multiplier for 'high' = 3.0 → 360000ms + const CODEX_CLI_TIMEOUT_MS = 120000; + expect(call.timeout).toBe(CODEX_CLI_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high); }); it('passes extended timeout for xhigh reasoning effort', async () => { @@ -357,8 +359,10 @@ describe('codex-provider.ts', () => { ); const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; - // No reasoning effort should use the default timeout - expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS); + // No reasoning effort should use the CLI base timeout (2 minutes) + // CODEX_CLI_TIMEOUT_MS = 120000ms, no multiplier applied + const CODEX_CLI_TIMEOUT_MS = 120000; + expect(call.timeout).toBe(CODEX_CLI_TIMEOUT_MS); }); }); 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 b04983be9..8c87b7da2 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 @@ -84,17 +84,19 @@ export function useBoardActions({ onWorktreeAutoSelect, currentWorktreeBranch, }: UseBoardActionsProps) { - const { - addFeature, - updateFeature, - removeFeature, - moveFeature, - useWorktrees, - enableDependencyBlocking, - skipVerificationInAutoMode, - isPrimaryWorktreeBranch, - getPrimaryWorktreeBranch, - } = useAppStore(); + // IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent + // subscribing to the entire store. Bare useAppStore() causes the host component + // (BoardView) to re-render on EVERY store change, which cascades through effects + // and triggers React error #185 (maximum update depth exceeded). + const addFeature = useAppStore((s) => s.addFeature); + const updateFeature = useAppStore((s) => s.updateFeature); + const removeFeature = useAppStore((s) => s.removeFeature); + const moveFeature = useAppStore((s) => s.moveFeature); + const worktreesEnabled = useAppStore((s) => s.useWorktrees); + const enableDependencyBlocking = useAppStore((s) => s.enableDependencyBlocking); + const skipVerificationInAutoMode = useAppStore((s) => s.skipVerificationInAutoMode); + const isPrimaryWorktreeBranch = useAppStore((s) => s.isPrimaryWorktreeBranch); + const getPrimaryWorktreeBranch = useAppStore((s) => s.getPrimaryWorktreeBranch); const autoMode = useAutoMode(); // React Query mutations for feature operations @@ -549,7 +551,7 @@ export function useBoardActions({ const result = await api.autoMode.runFeature( currentProject.path, feature.id, - useWorktrees + worktreesEnabled // No worktreePath - server derives from feature.branchName ); @@ -560,7 +562,7 @@ export function useBoardActions({ throw new Error(result.error || 'Failed to start feature'); } }, - [currentProject, useWorktrees] + [currentProject, worktreesEnabled] ); const handleStartImplementation = useCallback( @@ -693,9 +695,9 @@ export function useBoardActions({ logger.error('No current project'); return; } - resumeFeatureMutation.mutate({ featureId: feature.id, useWorktrees }); + resumeFeatureMutation.mutate({ featureId: feature.id, useWorktrees: worktreesEnabled }); }, - [currentProject, resumeFeatureMutation, useWorktrees] + [currentProject, resumeFeatureMutation, worktreesEnabled] ); const handleManualVerify = useCallback( @@ -780,7 +782,7 @@ export function useBoardActions({ followUpFeature.id, followUpPrompt, imagePaths, - useWorktrees + worktreesEnabled ); if (!result.success) { @@ -818,7 +820,7 @@ export function useBoardActions({ setFollowUpPrompt, setFollowUpImagePaths, setFollowUpPreviewMap, - useWorktrees, + worktreesEnabled, ]); const handleCommitFeature = useCallback( diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts b/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts index b313c7629..4b98806da 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts @@ -33,7 +33,12 @@ export function useBoardDragDrop({ const [pendingDependencyLink, setPendingDependencyLink] = useState( null ); - const { moveFeature, updateFeature } = useAppStore(); + // IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent + // subscribing to the entire store. Bare useAppStore() causes the host component + // (BoardView) to re-render on EVERY store change, which cascades through effects + // and triggers React error #185 (maximum update depth exceeded). + const moveFeature = useAppStore((s) => s.moveFeature); + const updateFeature = useAppStore((s) => s.updateFeature); const autoMode = useAutoMode(); // Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts index 3bbe0a152..e5b896b3a 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts @@ -14,7 +14,11 @@ interface UseBoardPersistenceProps { } export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps) { - const { updateFeature } = useAppStore(); + // IMPORTANT: Use individual selector instead of bare useAppStore() to prevent + // subscribing to the entire store. Bare useAppStore() causes the host component + // (BoardView) to re-render on EVERY store change, which cascades through effects + // and triggers React error #185 (maximum update depth exceeded). + const updateFeature = useAppStore((s) => s.updateFeature); const queryClient = useQueryClient(); // Persist feature update to API (replaces saveFeatures) From 023ade30ba86b489528f05afb81144b4710a8594 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 22 Feb 2026 00:27:39 -0800 Subject: [PATCH 06/15] Improve pull request prompt and generation handling (#800) * Changes from fix/improve-pull-request-prompt * Update apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../src/routes/worktree/routes/generate-pr-description.ts | 4 +++- .../views/board-view/dialogs/create-pr-dialog.tsx | 6 +++--- libs/prompts/src/defaults.ts | 1 + 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/server/src/routes/worktree/routes/generate-pr-description.ts b/apps/server/src/routes/worktree/routes/generate-pr-description.ts index 4f42b47ea..4e9e57162 100644 --- a/apps/server/src/routes/worktree/routes/generate-pr-description.ts +++ b/apps/server/src/routes/worktree/routes/generate-pr-description.ts @@ -53,7 +53,9 @@ Rules: - Focus on the user-facing impact when possible - If there are breaking changes, mention them prominently - The diff may include both committed changes and uncommitted working directory changes. Treat all changes as part of the PR since uncommitted changes will be committed when the PR is created -- Do NOT distinguish between committed and uncommitted changes in the output - describe all changes as a unified set of PR changes`; +- Do NOT distinguish between committed and uncommitted changes in the output - describe all changes as a unified set of PR changes +- EXCLUDE any files that are gitignored (e.g., node_modules, dist, build, .env files, lock files, generated files, binary artifacts, coverage reports, cache directories). These should not be mentioned in the description even if they appear in the diff +- Focus only on meaningful source code changes that are tracked by git and relevant to reviewers`; /** * Wraps an async generator with a timeout. 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 950fd2f3a..a88b111fa 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 @@ -491,7 +491,7 @@ export function CreatePRDialog({ return ( - + @@ -565,7 +565,7 @@ export function CreatePRDialog({
) : ( <> -
+
{worktree.hasChanges && (
- + diff --git a/libs/prompts/src/defaults.ts b/libs/prompts/src/defaults.ts index bcbc6febd..330afe0ce 100644 --- a/libs/prompts/src/defaults.ts +++ b/libs/prompts/src/defaults.ts @@ -490,6 +490,7 @@ Rules: - Focus on WHAT changed and WHY (if clear from the diff), not HOW - No quotes, backticks, or extra formatting - If there are multiple changes, provide a brief summary on the first line +- Ignore changes to gitignored files (e.g., node_modules, dist, build, .env files, lock files, generated files, binary artifacts, coverage reports, cache directories). Focus only on meaningful source code changes that are tracked by git Examples: - feat: Add dark mode toggle to settings From 1a07cb271f36929dd7e01488e6179943bb105a1f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 22 Feb 2026 00:36:08 -0800 Subject: [PATCH 07/15] Fix event hooks not persisting across server syncs (#799) * Changes from fix/event-hook-persistence * feat: Add explicit permission escape hatch for clearing eventHooks and improve error handling in UI --- apps/server/src/services/settings-service.ts | 11 +++++ .../event-hooks/event-hooks-section.tsx | 45 +++++++++++++------ apps/ui/src/hooks/use-settings-migration.ts | 9 ++++ apps/ui/src/routes/__root.tsx | 15 +++++++ apps/ui/src/store/app-store.ts | 10 ++++- apps/ui/src/store/types/state-types.ts | 2 +- libs/types/src/settings.ts | 2 + 7 files changed, 79 insertions(+), 15 deletions(-) diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 6a3d804eb..ebf8556fc 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -573,6 +573,17 @@ export class SettingsService { ignoreEmptyArrayOverwrite('claudeApiProfiles'); // Note: claudeCompatibleProviders intentionally NOT guarded - users should be able to delete all providers + // Check for explicit permission to clear eventHooks (escape hatch for intentional clearing) + const allowEmptyEventHooks = + (sanitizedUpdates as Record).__allowEmptyEventHooks === true; + // Remove the flag so it doesn't get persisted + delete (sanitizedUpdates as Record).__allowEmptyEventHooks; + + // Only guard eventHooks if explicit permission wasn't granted + if (!allowEmptyEventHooks) { + ignoreEmptyArrayOverwrite('eventHooks'); + } + // Empty object overwrite guard const ignoreEmptyObjectOverwrite = (key: K): void => { const nextVal = sanitizedUpdates[key] as unknown; diff --git a/apps/ui/src/components/views/settings-view/event-hooks/event-hooks-section.tsx b/apps/ui/src/components/views/settings-view/event-hooks/event-hooks-section.tsx index 519ca3709..21c18f683 100644 --- a/apps/ui/src/components/views/settings-view/event-hooks/event-hooks-section.tsx +++ b/apps/ui/src/components/views/settings-view/event-hooks/event-hooks-section.tsx @@ -9,6 +9,10 @@ import type { EventHook, EventHookTrigger } from '@automaker/types'; import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types'; import { EventHookDialog } from './event-hook-dialog'; import { EventHistoryView } from './event-history-view'; +import { toast } from 'sonner'; +import { createLogger } from '@automaker/utils/logger'; + +const logger = createLogger('EventHooks'); export function EventHooksSection() { const { eventHooks, setEventHooks } = useAppStore(); @@ -26,24 +30,39 @@ export function EventHooksSection() { setDialogOpen(true); }; - const handleDeleteHook = (hookId: string) => { - setEventHooks(eventHooks.filter((h) => h.id !== hookId)); + const handleDeleteHook = async (hookId: string) => { + try { + await setEventHooks(eventHooks.filter((h) => h.id !== hookId)); + } catch (error) { + logger.error('Failed to delete event hook:', error); + toast.error('Failed to delete event hook'); + } }; - const handleToggleHook = (hookId: string, enabled: boolean) => { - setEventHooks(eventHooks.map((h) => (h.id === hookId ? { ...h, enabled } : h))); + const handleToggleHook = async (hookId: string, enabled: boolean) => { + try { + await setEventHooks(eventHooks.map((h) => (h.id === hookId ? { ...h, enabled } : h))); + } catch (error) { + logger.error('Failed to toggle event hook:', error); + toast.error('Failed to update event hook'); + } }; - const handleSaveHook = (hook: EventHook) => { - if (editingHook) { - // Update existing - setEventHooks(eventHooks.map((h) => (h.id === hook.id ? hook : h))); - } else { - // Add new - setEventHooks([...eventHooks, hook]); + const handleSaveHook = async (hook: EventHook) => { + try { + if (editingHook) { + // Update existing + await setEventHooks(eventHooks.map((h) => (h.id === hook.id ? hook : h))); + } else { + // Add new + await setEventHooks([...eventHooks, hook]); + } + setDialogOpen(false); + setEditingHook(null); + } catch (error) { + logger.error('Failed to save event hook:', error); + toast.error('Failed to save event hook'); } - setDialogOpen(false); - setEditingHook(null); }; // Group hooks by trigger type for better organization diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 91b8388c6..291dce887 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -363,6 +363,15 @@ export function mergeSettings( merged.claudeCompatibleProviders = localSettings.claudeCompatibleProviders; } + // Event hooks - preserve from localStorage if server is empty + if ( + (!serverSettings.eventHooks || serverSettings.eventHooks.length === 0) && + localSettings.eventHooks && + localSettings.eventHooks.length > 0 + ) { + merged.eventHooks = localSettings.eventHooks; + } + // Preserve new settings fields from localStorage if server has defaults // Use nullish coalescing to accept stored falsy values (e.g. false) if (localSettings.enableAiCommitMessages != null && merged.enableAiCommitMessages == null) { diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index ac19d2a47..d133b37f3 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -594,6 +594,21 @@ function RootLayoutContent() { logger.info( '[FAST_HYDRATE] Background reconcile: cache updated (store untouched)' ); + + // Selectively reconcile event hooks from server. + // Unlike projects/theme, eventHooks aren't rendered on the main view, + // so updating them won't cause a visible re-render flash. + const serverHooks = (finalSettings as GlobalSettings).eventHooks ?? []; + const currentHooks = useAppStore.getState().eventHooks; + if ( + JSON.stringify(serverHooks) !== JSON.stringify(currentHooks) && + serverHooks.length > 0 + ) { + logger.info( + `[FAST_HYDRATE] Reconciling eventHooks from server (server=${serverHooks.length}, store=${currentHooks.length})` + ); + useAppStore.setState({ eventHooks: serverHooks }); + } } catch (e) { logger.debug('[FAST_HYDRATE] Failed to update cache:', e); } diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 6a15dba21..f62b84e95 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -1419,7 +1419,15 @@ export const useAppStore = create()((set, get) => ({ }, // Event Hook actions - setEventHooks: (hooks) => set({ eventHooks: hooks }), + setEventHooks: async (hooks) => { + set({ eventHooks: hooks }); + try { + const httpApi = getHttpApiClient(); + await httpApi.settings.updateGlobal({ eventHooks: hooks }); + } catch (error) { + logger.error('Failed to sync event hooks:', error); + } + }, // Claude-Compatible Provider actions (new system) addClaudeCompatibleProvider: async (provider) => { diff --git a/apps/ui/src/store/types/state-types.ts b/apps/ui/src/store/types/state-types.ts index c0a212a08..fecd197cd 100644 --- a/apps/ui/src/store/types/state-types.ts +++ b/apps/ui/src/store/types/state-types.ts @@ -645,7 +645,7 @@ export interface AppActions { setPromptCustomization: (customization: PromptCustomization) => Promise; // Event Hook actions - setEventHooks: (hooks: EventHook[]) => void; + setEventHooks: (hooks: EventHook[]) => Promise; // Claude-Compatible Provider actions (new system) addClaudeCompatibleProvider: (provider: ClaudeCompatibleProvider) => Promise; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 6a5f3f765..fc2aa5c87 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -1692,6 +1692,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { skillsSources: ['user', 'project'], enableSubagents: true, subagentsSources: ['user', 'project'], + // Event hooks + eventHooks: [], // New provider system claudeCompatibleProviders: [], // Deprecated - kept for migration From 43de62c244da83a48c540e4afec369c64694e9a5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 22 Feb 2026 00:58:00 -0800 Subject: [PATCH 08/15] Fix deleting worktree crash and improve UX (#798) * Changes from fix/deleting-worktree * fix: Improve worktree deletion safety and branch cleanup logic * fix: Improve error handling and async operations across auto-mode and worktree services * Update apps/server/src/routes/auto-mode/routes/analyze-project.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../auto-mode/routes/analyze-project.ts | 9 +- .../src/routes/worktree/routes/delete.ts | 72 +++++++- apps/server/src/services/auto-mode/facade.ts | 27 ++- .../src/services/auto-mode/global-service.ts | 2 +- apps/ui/src/components/views/board-view.tsx | 165 ++++++++++-------- .../dialogs/delete-worktree-dialog.tsx | 12 +- .../worktree-panel/hooks/use-worktrees.ts | 18 +- apps/ui/src/hooks/use-cursor-status-init.ts | 15 +- apps/ui/src/hooks/use-provider-auth-init.ts | 27 +-- apps/ui/src/hooks/use-settings-migration.ts | 10 +- apps/ui/src/hooks/use-settings-sync.ts | 17 +- apps/ui/src/lib/settings-utils.ts | 27 +++ apps/ui/src/store/app-store.ts | 7 + apps/ui/src/store/types/state-types.ts | 2 + libs/types/src/feature.ts | 2 +- 15 files changed, 301 insertions(+), 111 deletions(-) create mode 100644 apps/ui/src/lib/settings-utils.ts diff --git a/apps/server/src/routes/auto-mode/routes/analyze-project.ts b/apps/server/src/routes/auto-mode/routes/analyze-project.ts index 9ba22c50c..cae70c368 100644 --- a/apps/server/src/routes/auto-mode/routes/analyze-project.ts +++ b/apps/server/src/routes/auto-mode/routes/analyze-project.ts @@ -19,10 +19,11 @@ export function createAnalyzeProjectHandler(autoModeService: AutoModeServiceComp return; } - // Start analysis in background - autoModeService.analyzeProject(projectPath).catch((error) => { - logger.error(`[AutoMode] Project analysis error:`, error); - }); + // Kick off analysis in the background; attach a rejection handler so + // unhandled-promise warnings don't surface and errors are at least logged. + // Synchronous throws (e.g. "not implemented") still propagate here. + const analysisPromise = autoModeService.analyzeProject(projectPath); + analysisPromise.catch((err) => logError(err, 'Background analyzeProject failed')); res.json({ success: true, message: 'Project analysis started' }); } catch (error) { diff --git a/apps/server/src/routes/worktree/routes/delete.ts b/apps/server/src/routes/worktree/routes/delete.ts index 06703ff13..fcb42f590 100644 --- a/apps/server/src/routes/worktree/routes/delete.ts +++ b/apps/server/src/routes/worktree/routes/delete.ts @@ -5,6 +5,7 @@ import type { Request, Response } from 'express'; import { exec } from 'child_process'; import { promisify } from 'util'; +import fs from 'fs/promises'; import { isGitRepo } from '@automaker/git-utils'; import { getErrorMessage, logError, isValidBranchName } from '../common.js'; import { execGitCommand } from '../../../lib/git.js'; @@ -46,20 +47,79 @@ export function createDeleteHandler() { }); branchName = stdout.trim(); } catch { - // Could not get branch name + // Could not get branch name - worktree directory may already be gone + logger.debug('Could not determine branch for worktree, directory may be missing'); } // Remove the worktree (using array arguments to prevent injection) + let removeSucceeded = false; try { await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath); - } catch { - // Try with prune if remove fails - await execGitCommand(['worktree', 'prune'], projectPath); + removeSucceeded = true; + } catch (removeError) { + // `git worktree remove` can fail if the directory is already missing + // or in a bad state. Try pruning stale worktree entries as a fallback. + logger.debug('git worktree remove failed, trying prune', { + error: getErrorMessage(removeError), + }); + try { + await execGitCommand(['worktree', 'prune'], projectPath); + + // Verify the specific worktree is no longer registered after prune. + // `git worktree prune` exits 0 even if worktreePath was never registered, + // so we must explicitly check the worktree list to avoid false positives. + const { stdout: listOut } = await execAsync('git worktree list --porcelain', { + cwd: projectPath, + }); + // Parse porcelain output and check for an exact path match. + // Using substring .includes() can produce false positives when one + // worktree path is a prefix of another (e.g. /foo vs /foobar). + const stillRegistered = listOut + .split('\n') + .filter((line) => line.startsWith('worktree ')) + .map((line) => line.slice('worktree '.length).trim()) + .some((registeredPath) => registeredPath === worktreePath); + if (stillRegistered) { + // Prune didn't clean up our entry - treat as failure + throw removeError; + } + removeSucceeded = true; + } catch (pruneError) { + // If pruneError is the original removeError re-thrown, propagate it + if (pruneError === removeError) { + throw removeError; + } + logger.warn('git worktree prune also failed', { + error: getErrorMessage(pruneError), + }); + // If both remove and prune fail, still try to return success + // if the worktree directory no longer exists (it may have been + // manually deleted already). + let dirExists = false; + try { + await fs.access(worktreePath); + dirExists = true; + } catch { + // Directory doesn't exist + } + if (dirExists) { + // Directory still exists - this is a real failure + throw removeError; + } + // Directory is gone, treat as success + removeSucceeded = true; + } } - // Optionally delete the branch + // Optionally delete the branch (only if worktree was successfully removed) let branchDeleted = false; - if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') { + if ( + removeSucceeded && + deleteBranch && + branchName && + branchName !== 'main' && + branchName !== 'master' + ) { // Validate branch name to prevent command injection if (!isValidBranchName(branchName)) { logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`); diff --git a/apps/server/src/services/auto-mode/facade.ts b/apps/server/src/services/auto-mode/facade.ts index 7d194e720..f829ad53f 100644 --- a/apps/server/src/services/auto-mode/facade.ts +++ b/apps/server/src/services/auto-mode/facade.ts @@ -912,7 +912,7 @@ export class AutoModeServiceFacade { if (feature) { title = feature.title; description = feature.description; - branchName = feature.branchName; + branchName = feature.branchName ?? undefined; } } catch { // Silently ignore @@ -1142,10 +1142,31 @@ export class AutoModeServiceFacade { // =========================================================================== /** - * Save execution state for recovery + * Save execution state for recovery. + * + * Uses the active auto-loop config for each worktree so that the persisted + * state reflects the real branch and maxConcurrency values rather than the + * hard-coded fallbacks (null / DEFAULT_MAX_CONCURRENCY). */ private async saveExecutionState(): Promise { - return this.saveExecutionStateForProject(null, DEFAULT_MAX_CONCURRENCY); + const projectWorktrees = this.autoLoopCoordinator + .getActiveWorktrees() + .filter((w) => w.projectPath === this.projectPath); + + if (projectWorktrees.length === 0) { + // No active auto loops — save with defaults as a best-effort fallback. + return this.saveExecutionStateForProject(null, DEFAULT_MAX_CONCURRENCY); + } + + // Save state for every active worktree using its real config values. + for (const { branchName } of projectWorktrees) { + const config = this.autoLoopCoordinator.getAutoLoopConfigForProject( + this.projectPath, + branchName + ); + const maxConcurrency = config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY; + await this.saveExecutionStateForProject(branchName, maxConcurrency); + } } /** diff --git a/apps/server/src/services/auto-mode/global-service.ts b/apps/server/src/services/auto-mode/global-service.ts index 459562ebc..478f48b3f 100644 --- a/apps/server/src/services/auto-mode/global-service.ts +++ b/apps/server/src/services/auto-mode/global-service.ts @@ -159,7 +159,7 @@ export class GlobalAutoModeService { if (feature) { title = feature.title; description = feature.description; - branchName = feature.branchName; + branchName = feature.branchName ?? undefined; } } catch { // Silently ignore diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index e57e0201c..5d3a33cc7 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -99,6 +99,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { queryKeys } from '@/lib/query-keys'; import { useAutoModeQueryInvalidation } from '@/hooks/use-query-invalidation'; import { useUpdateGlobalSettings } from '@/hooks/mutations/use-settings-mutations'; +import { forceSyncSettingsToServer } from '@/hooks/use-settings-sync'; // Stable empty array to avoid infinite loop in selector const EMPTY_WORKTREES: ReturnType['getWorktrees']> = []; @@ -114,6 +115,7 @@ export function BoardView() { pendingPlanApproval, setPendingPlanApproval, updateFeature, + batchUpdateFeatures, getCurrentWorktree, setCurrentWorktree, getWorktrees, @@ -132,6 +134,7 @@ export function BoardView() { pendingPlanApproval: state.pendingPlanApproval, setPendingPlanApproval: state.setPendingPlanApproval, updateFeature: state.updateFeature, + batchUpdateFeatures: state.batchUpdateFeatures, getCurrentWorktree: state.getCurrentWorktree, setCurrentWorktree: state.setCurrentWorktree, getWorktrees: state.getWorktrees, @@ -411,25 +414,34 @@ export function BoardView() { currentProject, }); + // Shared helper: batch-reset branch assignment and persist for each affected feature. + // Used when worktrees are deleted or branches are removed during merge. + const batchResetBranchFeatures = useCallback( + (branchName: string) => { + const affectedIds = hookFeatures.filter((f) => f.branchName === branchName).map((f) => f.id); + if (affectedIds.length === 0) return; + const updates: Partial = { branchName: null }; + batchUpdateFeatures(affectedIds, updates); + for (const id of affectedIds) { + persistFeatureUpdate(id, updates).catch((err: unknown) => { + console.error( + `[batchResetBranchFeatures] Failed to persist update for feature ${id}:`, + err + ); + }); + } + }, + [hookFeatures, batchUpdateFeatures, persistFeatureUpdate] + ); + // Memoize the removed worktrees handler to prevent infinite loops const handleRemovedWorktrees = useCallback( (removedWorktrees: Array<{ path: string; branch: string }>) => { - // Reset features that were assigned to the removed worktrees (by branch) - hookFeatures.forEach((feature) => { - const matchesRemovedWorktree = removedWorktrees.some((removed) => { - // Match by branch name since worktreePath is no longer stored - return feature.branchName === removed.branch; - }); - - if (matchesRemovedWorktree) { - // Reset the feature's branch assignment - update both local state and persist - const updates = { branchName: null as unknown as string | undefined }; - updateFeature(feature.id, updates); - persistFeatureUpdate(feature.id, updates); - } - }); + for (const { branch } of removedWorktrees) { + batchResetBranchFeatures(branch); + } }, - [hookFeatures, updateFeature, persistFeatureUpdate] + [batchResetBranchFeatures] ); // Get current worktree info (path) for filtering features @@ -437,28 +449,6 @@ export function BoardView() { const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null; const currentWorktreePath = currentWorktreeInfo?.path ?? null; - // Track the previous worktree path to detect worktree switches - const prevWorktreePathRef = useRef(undefined); - - // When the active worktree changes, invalidate feature queries to ensure - // feature cards (especially their todo lists / planSpec tasks) render fresh data. - // Without this, cards that unmount when filtered out and remount when the user - // switches back may show stale or missing todo list data until the next polling cycle. - useEffect(() => { - // Skip the initial mount (prevWorktreePathRef starts as undefined) - if (prevWorktreePathRef.current === undefined) { - prevWorktreePathRef.current = currentWorktreePath; - return; - } - // Only invalidate when the worktree actually changed - if (prevWorktreePathRef.current !== currentWorktreePath && currentProject?.path) { - queryClient.invalidateQueries({ - queryKey: queryKeys.features.all(currentProject.path), - }); - } - prevWorktreePathRef.current = currentWorktreePath; - }, [currentWorktreePath, currentProject?.path, queryClient]); - // Select worktrees for the current project directly from the store. // Using a project-scoped selector prevents re-renders when OTHER projects' // worktrees change (the old selector subscribed to the entire worktreesByProject @@ -1603,17 +1593,7 @@ export function BoardView() { onStashPopConflict={handleStashPopConflict} onStashApplyConflict={handleStashApplyConflict} onBranchDeletedDuringMerge={(branchName) => { - // Reset features that were assigned to the deleted branch (same logic as onDeleted in DeleteWorktreeDialog) - hookFeatures.forEach((feature) => { - if (feature.branchName === branchName) { - // Reset the feature's branch assignment - update both local state and persist - const updates = { - branchName: null as unknown as string | undefined, - }; - updateFeature(feature.id, updates); - persistFeatureUpdate(feature.id, updates); - } - }); + batchResetBranchFeatures(branchName); setWorktreeRefreshKey((k) => k + 1); }} onRemovedWorktrees={handleRemovedWorktrees} @@ -1990,31 +1970,76 @@ export function BoardView() { } defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)} onDeleted={(deletedWorktree, _deletedBranch) => { - // If the deleted worktree was currently selected, immediately reset to main - // to prevent the UI from trying to render a non-existent worktree view - if ( - currentWorktreePath !== null && - pathsEqual(currentWorktreePath, deletedWorktree.path) - ) { - const mainBranch = worktrees.find((w) => w.isMain)?.branch || 'main'; - setCurrentWorktree(currentProject.path, null, mainBranch); - } - - // Reset features that were assigned to the deleted worktree (by branch) - hookFeatures.forEach((feature) => { - // Match by branch name since worktreePath is no longer stored - if (feature.branchName === deletedWorktree.branch) { - // Reset the feature's branch assignment - update both local state and persist - const updates = { - branchName: null as unknown as string | undefined, + // 1. Reset current worktree to main FIRST. This must happen + // BEFORE removing from the list to ensure downstream hooks + // (useAutoMode, useBoardFeatures) see a valid worktree and + // never try to render the deleted worktree. + const mainBranch = worktrees.find((w) => w.isMain)?.branch || 'main'; + setCurrentWorktree(currentProject.path, null, mainBranch); + + // 2. Immediately remove the deleted worktree from the store's + // worktree list so the UI never renders a stale tab/dropdown + // item that can be clicked and cause a crash. + const remainingWorktrees = worktrees.filter( + (w) => !pathsEqual(w.path, deletedWorktree.path) + ); + setWorktrees(currentProject.path, remainingWorktrees); + + // 3. Cancel any in-flight worktree queries, then optimistically + // update the React Query cache so the worktree disappears + // from the dropdown immediately. Cancelling first prevents a + // pending refetch from overwriting our optimistic update with + // stale server data. + const worktreeQueryKey = queryKeys.worktrees.all(currentProject.path); + void queryClient.cancelQueries({ queryKey: worktreeQueryKey }); + queryClient.setQueryData( + worktreeQueryKey, + ( + old: + | { + worktrees: WorktreeInfo[]; + removedWorktrees: Array<{ path: string; branch: string }>; + } + | undefined + ) => { + if (!old) return old; + return { + ...old, + worktrees: old.worktrees.filter( + (w: WorktreeInfo) => !pathsEqual(w.path, deletedWorktree.path) + ), }; - updateFeature(feature.id, updates); - persistFeatureUpdate(feature.id, updates); } - }); - - setWorktreeRefreshKey((k) => k + 1); + ); + + // 4. Batch-reset features assigned to the deleted worktree in one + // store mutation to avoid N individual updateFeature calls that + // cascade into React error #185. + batchResetBranchFeatures(deletedWorktree.branch); + + // 5. Do NOT trigger setWorktreeRefreshKey here. The optimistic + // cache update (step 3) already removed the worktree from + // both the Zustand store and React Query cache. Incrementing + // the refresh key would cause invalidateQueries → server + // refetch, and if the server's .worktrees/ directory scan + // finds remnants of the deleted worktree, it would re-add + // it to the dropdown. The 30-second polling interval in + // WorktreePanel will eventually reconcile with the server. setSelectedWorktreeForAction(null); + + // 6. Force-sync settings immediately so the reset worktree + // selection is persisted before any potential page reload. + // Without this, the debounced sync (1s) may not complete + // in time and the stale worktree path survives in + // server settings, causing the deleted worktree to + // reappear on next load. + forceSyncSettingsToServer().then((ok) => { + if (!ok) { + logger.warn( + 'forceSyncSettingsToServer failed after worktree deletion; stale path may reappear on reload' + ); + } + }); }} /> diff --git a/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx index e366b03ec..050e2dbad 100644 --- a/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx @@ -72,9 +72,19 @@ export function DeleteWorktreeDialog({ ? `Branch "${worktree.branch}" was also deleted` : `Branch "${worktree.branch}" was kept`, }); - onDeleted(worktree, deleteBranch); + // Close the dialog first, then notify the parent. + // This ensures the dialog unmounts before the parent + // triggers potentially heavy state updates (feature branch + // resets, worktree refresh), reducing concurrent re-renders + // that can cascade into React error #185. onOpenChange(false); setDeleteBranch(false); + try { + onDeleted(worktree, deleteBranch); + } catch (error) { + // Prevent errors in onDeleted from propagating to the error boundary + console.error('onDeleted callback failed:', error); + } } else { toast.error('Failed to delete worktree', { description: result.error, diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts index 829c3ce68..1a1c7ceeb 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts @@ -111,13 +111,17 @@ export function useWorktrees({ setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch); - // Invalidate feature queries when switching worktrees to ensure fresh data. - // Without this, feature cards that remount after the worktree switch may have stale - // or missing planSpec/task data, causing todo lists to appear empty until the next - // polling cycle or user interaction triggers a re-render. - queryClient.invalidateQueries({ - queryKey: queryKeys.features.all(projectPath), - }); + // Defer feature query invalidation so the store update and client-side + // re-filtering happen in the current render cycle first. The features + // list is the same regardless of worktree (filtering is client-side), + // so the board updates instantly. The deferred invalidation ensures + // feature card details (planSpec, todo lists) are refreshed in the + // background without blocking the worktree switch. + setTimeout(() => { + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(projectPath), + }); + }, 0); }, [projectPath, setCurrentWorktree, queryClient, currentWorktreePath] ); diff --git a/apps/ui/src/hooks/use-cursor-status-init.ts b/apps/ui/src/hooks/use-cursor-status-init.ts index 79e277c74..a1100f225 100644 --- a/apps/ui/src/hooks/use-cursor-status-init.ts +++ b/apps/ui/src/hooks/use-cursor-status-init.ts @@ -8,12 +8,21 @@ import { getHttpApiClient } from '@/lib/http-api-client'; * before the user opens feature dialogs. */ export function useCursorStatusInit() { - const { setCursorCliStatus, cursorCliStatus } = useSetupStore(); + // Use individual selectors instead of bare useSetupStore() to prevent + // re-rendering on every setup store mutation during initialization. + const setCursorCliStatus = useSetupStore((s) => s.setCursorCliStatus); const initialized = useRef(false); useEffect(() => { // Only initialize once per session - if (initialized.current || cursorCliStatus !== null) { + if (initialized.current) { + return; + } + // Check current status at call time rather than via dependency to avoid + // re-renders when other setup store fields change during initialization. + const currentStatus = useSetupStore.getState().cursorCliStatus; + if (currentStatus !== null) { + initialized.current = true; return; } initialized.current = true; @@ -42,5 +51,5 @@ export function useCursorStatusInit() { }; initCursorStatus(); - }, [setCursorCliStatus, cursorCliStatus]); + }, [setCursorCliStatus]); } diff --git a/apps/ui/src/hooks/use-provider-auth-init.ts b/apps/ui/src/hooks/use-provider-auth-init.ts index 6d2470cf9..04b8a743e 100644 --- a/apps/ui/src/hooks/use-provider-auth-init.ts +++ b/apps/ui/src/hooks/use-provider-auth-init.ts @@ -17,17 +17,16 @@ const logger = createLogger('ProviderAuthInit'); * without needing to visit the settings page first. */ export function useProviderAuthInit() { - const { - setClaudeAuthStatus, - setCodexAuthStatus, - setZaiAuthStatus, - setGeminiCliStatus, - setGeminiAuthStatus, - claudeAuthStatus, - codexAuthStatus, - zaiAuthStatus, - geminiAuthStatus, - } = useSetupStore(); + // IMPORTANT: Use individual selectors instead of bare useSetupStore() to prevent + // re-rendering on every setup store mutation. The bare call subscribes to the ENTIRE + // store, which during initialization causes cascading re-renders as multiple status + // setters fire in rapid succession. With enough rapid mutations, React hits the + // maximum update depth limit (error #185). + const setClaudeAuthStatus = useSetupStore((s) => s.setClaudeAuthStatus); + const setCodexAuthStatus = useSetupStore((s) => s.setCodexAuthStatus); + const setZaiAuthStatus = useSetupStore((s) => s.setZaiAuthStatus); + const setGeminiCliStatus = useSetupStore((s) => s.setGeminiCliStatus); + const setGeminiAuthStatus = useSetupStore((s) => s.setGeminiAuthStatus); const initialized = useRef(false); const refreshStatuses = useCallback(async () => { @@ -219,5 +218,9 @@ export function useProviderAuthInit() { // Always call refreshStatuses() to background re-validate on app restart, // even when statuses are pre-populated from persisted storage (cache case). void refreshStatuses(); - }, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus, geminiAuthStatus]); + // Only depend on the callback ref. The status values were previously included + // but they are outputs of refreshStatuses(), not inputs — including them caused + // cascading re-renders during initialization that triggered React error #185 + // (maximum update depth exceeded) on first run. + }, [refreshStatuses]); } diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 291dce887..519e0fbe0 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -26,6 +26,7 @@ import { useEffect, useState, useRef } from 'react'; import { createLogger } from '@automaker/utils/logger'; import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client'; import { getItem, setItem } from '@/lib/storage'; +import { sanitizeWorktreeByProject } from '@/lib/settings-utils'; import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { @@ -794,7 +795,14 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { projectHistory: settings.projectHistory ?? [], projectHistoryIndex: settings.projectHistoryIndex ?? -1, lastSelectedSessionByProject: settings.lastSelectedSessionByProject ?? {}, - currentWorktreeByProject: settings.currentWorktreeByProject ?? {}, + // Sanitize currentWorktreeByProject: only restore entries where path is null + // (main branch). Non-null paths point to worktree directories that may have + // been deleted while the app was closed. Restoring a stale path causes the + // board to render an invalid worktree selection, triggering a crash loop + // (error boundary reloads → restores same bad path → crash again). + // The use-worktrees validation effect will re-discover valid worktrees + // from the server once they load. + currentWorktreeByProject: sanitizeWorktreeByProject(settings.currentWorktreeByProject), // UI State 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 9f2e3b4b3..302b694df 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -19,6 +19,7 @@ import { useAppStore, type ThemeMode, THEME_STORAGE_KEY } from '@/store/app-stor import { useSetupStore } from '@/store/setup-store'; import { useAuthStore } from '@/store/auth-store'; import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration'; +import { sanitizeWorktreeByProject } from '@/lib/settings-utils'; import { DEFAULT_OPENCODE_MODEL, DEFAULT_GEMINI_MODEL, @@ -584,6 +585,15 @@ export async function forceSyncSettingsToServer(): Promise { updates[field] = setupState[field as keyof typeof setupState]; } + // Update localStorage cache immediately so a page reload before the + // server response arrives still sees the latest state (e.g. after + // deleting a worktree, the stale worktree path won't survive in cache). + try { + setItem('automaker-settings-cache', JSON.stringify(updates)); + } catch (storageError) { + logger.warn('Failed to update localStorage cache during force sync:', storageError); + } + const result = await api.settings.updateGlobal(updates); return result.success; } catch (error) { @@ -796,8 +806,11 @@ export async function refreshSettingsFromServer(): Promise { projectHistory: serverSettings.projectHistory, projectHistoryIndex: serverSettings.projectHistoryIndex, lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject, - currentWorktreeByProject: - serverSettings.currentWorktreeByProject ?? currentAppState.currentWorktreeByProject, + // Sanitize: only restore entries with path === null (main branch). + // Non-null paths may reference deleted worktrees, causing crash loops. + currentWorktreeByProject: sanitizeWorktreeByProject( + serverSettings.currentWorktreeByProject ?? currentAppState.currentWorktreeByProject + ), // UI State (previously in localStorage) worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false, lastProjectDir: serverSettings.lastProjectDir ?? '', diff --git a/apps/ui/src/lib/settings-utils.ts b/apps/ui/src/lib/settings-utils.ts new file mode 100644 index 000000000..96fab28c6 --- /dev/null +++ b/apps/ui/src/lib/settings-utils.ts @@ -0,0 +1,27 @@ +/** + * Shared settings utility functions + */ + +/** + * Drop currentWorktreeByProject entries with non-null paths. + * Non-null paths reference worktree directories that may have been deleted, + * and restoring them causes crash loops (board renders invalid worktree + * -> error boundary reloads -> restores same stale path). + */ +export function sanitizeWorktreeByProject( + raw: Record | undefined +): Record { + if (!raw) return {}; + const sanitized: Record = {}; + for (const [projectPath, worktree] of Object.entries(raw)) { + if ( + typeof worktree === 'object' && + worktree !== null && + 'path' in worktree && + worktree.path === null + ) { + sanitized[projectPath] = worktree; + } + } + return sanitized; +} diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index f62b84e95..b018a37f9 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -882,6 +882,13 @@ export const useAppStore = create()((set, get) => ({ set((state) => ({ features: state.features.map((f) => (f.id === id ? { ...f, ...updates } : f)), })), + batchUpdateFeatures: (ids, updates) => { + if (ids.length === 0) return; + const idSet = new Set(ids); + set((state) => ({ + features: state.features.map((f) => (idSet.has(f.id) ? { ...f, ...updates } : f)), + })); + }, addFeature: (feature) => { const id = feature.id ?? `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`; const newFeature = { ...feature, id } as Feature; diff --git a/apps/ui/src/store/types/state-types.ts b/apps/ui/src/store/types/state-types.ts index fecd197cd..2c1d9adc9 100644 --- a/apps/ui/src/store/types/state-types.ts +++ b/apps/ui/src/store/types/state-types.ts @@ -451,6 +451,8 @@ export interface AppActions { // Feature actions setFeatures: (features: Feature[]) => void; updateFeature: (id: string, updates: Partial) => void; + /** Apply the same updates to multiple features in a single store mutation. */ + batchUpdateFeatures: (ids: string[], updates: Partial) => void; addFeature: (feature: Omit & Partial>) => Feature; removeFeature: (id: string) => void; moveFeature: (id: string, newStatus: Feature['status']) => void; diff --git a/libs/types/src/feature.ts b/libs/types/src/feature.ts index a053345bf..2b983cfe5 100644 --- a/libs/types/src/feature.ts +++ b/libs/types/src/feature.ts @@ -91,7 +91,7 @@ export interface Feature { imagePaths?: Array; textFilePaths?: FeatureTextFilePath[]; // Branch info - worktree path is derived at runtime from branchName - branchName?: string; // Name of the feature branch (undefined = use current worktree) + branchName?: string | null; // Name of the feature branch (undefined/null = use current worktree) skipTests?: boolean; excludedPipelineSteps?: string[]; // Array of pipeline step IDs to skip for this feature thinkingLevel?: ThinkingLevel; From d8bd1e0c84aa90c7f2da63f59198f97f08ac34f1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 22 Feb 2026 10:45:45 -0800 Subject: [PATCH 09/15] Fix: Restore views properly, model selection for commit and pr and speed up some cli models with session resume (#801) * Changes from fix/restoring-view * feat: Add resume query safety checks and optimize store selectors * feat: Improve session management and model normalization * refactor: Extract prompt building logic and handle file path parsing for renames --- apps/server/src/providers/codex-provider.ts | 45 ++++-- apps/server/src/providers/copilot-provider.ts | 63 ++++++-- apps/server/src/providers/cursor-provider.ts | 5 + apps/server/src/providers/gemini-provider.ts | 5 + .../routes/worktree/routes/discard-changes.ts | 51 +++++- .../src/services/agent-executor-types.ts | 1 + apps/server/src/services/agent-executor.ts | 15 ++ apps/server/src/services/agent-service.ts | 20 ++- .../unit/providers/codex-provider.test.ts | 24 +++ .../unit/providers/copilot-provider.test.ts | 81 +++++++++- .../unit/providers/cursor-provider.test.ts | 39 +++++ .../unit/providers/gemini-provider.test.ts | 35 +++++ .../tests/unit/services/agent-service.test.ts | 30 ++++ apps/ui/src/components/views/agent-view.tsx | 13 +- .../agent-view/hooks/use-agent-session.ts | 98 ++++++++++-- apps/ui/src/components/views/board-view.tsx | 17 +- .../dialogs/commit-worktree-dialog.tsx | 148 +++++++++++------- .../board-view/dialogs/create-pr-dialog.tsx | 70 ++++++--- apps/ui/src/hooks/use-settings-sync.ts | 19 +++ apps/ui/src/lib/http-api-client.ts | 30 +++- apps/ui/src/lib/settings-utils.ts | 18 ++- apps/ui/src/store/app-store.ts | 25 ++- apps/ui/src/store/types/state-types.ts | 5 + apps/ui/src/store/ui-cache-store.ts | 89 ++++++----- apps/ui/src/types/electron.d.ts | 12 +- libs/types/src/settings.ts | 6 + 26 files changed, 761 insertions(+), 203 deletions(-) create mode 100644 apps/server/tests/unit/providers/cursor-provider.test.ts create mode 100644 apps/server/tests/unit/providers/gemini-provider.test.ts diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 22767d3a8..63d410362 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -51,6 +51,7 @@ import { CODEX_MODELS } from './codex-models.js'; const CODEX_COMMAND = 'codex'; const CODEX_EXEC_SUBCOMMAND = 'exec'; +const CODEX_RESUME_SUBCOMMAND = 'resume'; const CODEX_JSON_FLAG = '--json'; const CODEX_MODEL_FLAG = '--model'; const CODEX_VERSION_FLAG = '--version'; @@ -360,9 +361,14 @@ function resolveSystemPrompt(systemPrompt?: unknown): string | null { return null; } +function buildPromptText(options: ExecuteOptions): string { + return typeof options.prompt === 'string' + ? options.prompt + : extractTextFromContent(options.prompt); +} + function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string | null): string { - const promptText = - typeof options.prompt === 'string' ? options.prompt : extractTextFromContent(options.prompt); + const promptText = buildPromptText(options); const historyText = options.conversationHistory ? formatHistoryAsText(options.conversationHistory) : ''; @@ -375,6 +381,11 @@ function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string return `${historyText}${systemSection}${HISTORY_HEADER}${promptText}`; } +function buildResumePrompt(options: ExecuteOptions): string { + const promptText = buildPromptText(options); + return `${HISTORY_HEADER}${promptText}`; +} + function formatConfigValue(value: string | number | boolean): string { return String(value); } @@ -798,16 +809,22 @@ export class CodexProvider extends BaseProvider { } const searchEnabled = codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools); - const schemaPath = await writeOutputSchemaFile(options.cwd, options.outputFormat); - const imageBlocks = codexSettings.enableImages ? extractImageBlocks(options.prompt) : []; - const imagePaths = await writeImageFiles(options.cwd, imageBlocks); + const isResumeQuery = Boolean(options.sdkSessionId); + const schemaPath = isResumeQuery + ? null + : await writeOutputSchemaFile(options.cwd, options.outputFormat); + const imageBlocks = + !isResumeQuery && codexSettings.enableImages ? extractImageBlocks(options.prompt) : []; + const imagePaths = isResumeQuery ? [] : await writeImageFiles(options.cwd, imageBlocks); const approvalPolicy = hasMcpServers && options.mcpAutoApproveTools !== undefined ? options.mcpAutoApproveTools ? 'never' : 'on-request' : codexSettings.approvalPolicy; - const promptText = buildCombinedPrompt(options, combinedSystemPrompt); + const promptText = isResumeQuery + ? buildResumePrompt(options) + : buildCombinedPrompt(options, combinedSystemPrompt); const commandPath = executionPlan.cliPath || CODEX_COMMAND; // Build config overrides for max turns and reasoning effort @@ -837,21 +854,30 @@ export class CodexProvider extends BaseProvider { const preExecArgs: string[] = []; // Add additional directories with write access - if (codexSettings.additionalDirs && codexSettings.additionalDirs.length > 0) { + if ( + !isResumeQuery && + codexSettings.additionalDirs && + codexSettings.additionalDirs.length > 0 + ) { for (const dir of codexSettings.additionalDirs) { preExecArgs.push(CODEX_ADD_DIR_FLAG, dir); } } - // If images were written to disk, add the image directory so the CLI can access them + // If images were written to disk, add the image directory so the CLI can access them. + // Note: imagePaths is set to [] when isResumeQuery is true, so this check is sufficient. if (imagePaths.length > 0) { const imageDir = path.join(options.cwd, CODEX_INSTRUCTIONS_DIR, IMAGE_TEMP_DIR); preExecArgs.push(CODEX_ADD_DIR_FLAG, imageDir); } // Model is already bare (no prefix) - validated by executeQuery + const codexCommand = isResumeQuery + ? [CODEX_EXEC_SUBCOMMAND, CODEX_RESUME_SUBCOMMAND] + : [CODEX_EXEC_SUBCOMMAND]; + const args = [ - CODEX_EXEC_SUBCOMMAND, + ...codexCommand, CODEX_YOLO_FLAG, CODEX_SKIP_GIT_REPO_CHECK_FLAG, ...preExecArgs, @@ -860,6 +886,7 @@ export class CodexProvider extends BaseProvider { CODEX_JSON_FLAG, ...configOverrideArgs, ...(schemaPath ? [CODEX_OUTPUT_SCHEMA_FLAG, schemaPath] : []), + ...(options.sdkSessionId ? [options.sdkSessionId] : []), '-', // Read prompt from stdin to avoid shell escaping issues ]; diff --git a/apps/server/src/providers/copilot-provider.ts b/apps/server/src/providers/copilot-provider.ts index 34cfcbce2..5ccdfbf0d 100644 --- a/apps/server/src/providers/copilot-provider.ts +++ b/apps/server/src/providers/copilot-provider.ts @@ -30,6 +30,7 @@ import { type CopilotRuntimeModel, } from '@automaker/types'; import { createLogger, isAbortError } from '@automaker/utils'; +import { resolveModelString } from '@automaker/model-resolver'; import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk'; import { normalizeTodos, @@ -116,6 +117,12 @@ export interface CopilotError extends Error { suggestion?: string; } +type CopilotSession = Awaited>; +type CopilotSessionOptions = Parameters[0]; +type ResumableCopilotClient = CopilotClient & { + resumeSession?: (sessionId: string, options: CopilotSessionOptions) => Promise; +}; + // ============================================================================= // Tool Name Normalization // ============================================================================= @@ -516,7 +523,11 @@ export class CopilotProvider extends CliProvider { } const promptText = this.extractPromptText(options); - const bareModel = options.model || DEFAULT_BARE_MODEL; + // resolveModelString may return dash-separated canonical names (e.g. "claude-sonnet-4-6"), + // but the Copilot SDK expects dot-separated version suffixes (e.g. "claude-sonnet-4.6"). + // Normalize by converting the last dash-separated numeric pair to dot notation. + const resolvedModel = resolveModelString(options.model || DEFAULT_BARE_MODEL); + const bareModel = resolvedModel.replace(/-(\d+)-(\d+)$/, '-$1.$2'); const workingDirectory = options.cwd || process.cwd(); logger.debug( @@ -554,12 +565,14 @@ export class CopilotProvider extends CliProvider { }); }; + // Declare session outside try so it's accessible in the catch block for cleanup. + let session: CopilotSession | undefined; + try { await client.start(); logger.debug(`CopilotClient started with cwd: ${workingDirectory}`); - // Create session with streaming enabled for real-time events - const session = await client.createSession({ + const sessionOptions: CopilotSessionOptions = { model: bareModel, streaming: true, // AUTONOMOUS MODE: Auto-approve all permission requests. @@ -572,13 +585,33 @@ export class CopilotProvider extends CliProvider { logger.debug(`Permission request: ${request.kind}`); return { kind: 'approved' }; }, - }); + }; + + // Resume the previous Copilot session when possible; otherwise create a fresh one. + const resumableClient = client as ResumableCopilotClient; + let sessionResumed = false; + if (options.sdkSessionId && typeof resumableClient.resumeSession === 'function') { + try { + session = await resumableClient.resumeSession(options.sdkSessionId, sessionOptions); + sessionResumed = true; + logger.debug(`Resumed Copilot session: ${session.sessionId}`); + } catch (resumeError) { + logger.warn( + `Failed to resume Copilot session "${options.sdkSessionId}", creating a new session: ${resumeError}` + ); + session = await client.createSession(sessionOptions); + } + } else { + session = await client.createSession(sessionOptions); + } - const sessionId = session.sessionId; - logger.debug(`Session created: ${sessionId}`); + // session is always assigned by this point (both branches above assign it) + const activeSession = session!; + const sessionId = activeSession.sessionId; + logger.debug(`Session ${sessionResumed ? 'resumed' : 'created'}: ${sessionId}`); // Set up event handler to push events to queue - session.on((event: SdkEvent) => { + activeSession.on((event: SdkEvent) => { logger.debug(`SDK event: ${event.type}`); if (event.type === 'session.idle') { @@ -596,7 +629,7 @@ export class CopilotProvider extends CliProvider { }); // Send the prompt (non-blocking) - await session.send({ prompt: promptText }); + await activeSession.send({ prompt: promptText }); // Process events as they arrive while (!sessionComplete || eventQueue.length > 0) { @@ -604,7 +637,7 @@ export class CopilotProvider extends CliProvider { // Check for errors first (before processing events to avoid race condition) if (sessionError) { - await session.destroy(); + await activeSession.destroy(); await client.stop(); throw sessionError; } @@ -624,11 +657,19 @@ export class CopilotProvider extends CliProvider { } // Cleanup - await session.destroy(); + await activeSession.destroy(); await client.stop(); logger.debug('CopilotClient stopped successfully'); } catch (error) { - // Ensure client is stopped on error + // Ensure session is destroyed and client is stopped on error to prevent leaks. + // The session may have been created/resumed before the error occurred. + if (session) { + try { + await session.destroy(); + } catch (sessionCleanupError) { + logger.debug(`Failed to destroy session during cleanup: ${sessionCleanupError}`); + } + } try { await client.stop(); } catch (cleanupError) { diff --git a/apps/server/src/providers/cursor-provider.ts b/apps/server/src/providers/cursor-provider.ts index 8684417a2..450b3a74a 100644 --- a/apps/server/src/providers/cursor-provider.ts +++ b/apps/server/src/providers/cursor-provider.ts @@ -450,6 +450,11 @@ export class CursorProvider extends CliProvider { cliArgs.push('--model', model); } + // Resume an existing chat when a provider session ID is available + if (options.sdkSessionId) { + cliArgs.push('--resume', options.sdkSessionId); + } + // Use '-' to indicate reading prompt from stdin cliArgs.push('-'); diff --git a/apps/server/src/providers/gemini-provider.ts b/apps/server/src/providers/gemini-provider.ts index 764c57eba..e4e6f9dcf 100644 --- a/apps/server/src/providers/gemini-provider.ts +++ b/apps/server/src/providers/gemini-provider.ts @@ -270,6 +270,11 @@ export class GeminiProvider extends CliProvider { cliArgs.push('--include-directories', options.cwd); } + // Resume an existing Gemini session when one is available + if (options.sdkSessionId) { + cliArgs.push('--resume', options.sdkSessionId); + } + // Note: Gemini CLI doesn't have a --thinking-level flag. // Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro). // The model handles thinking internally based on the task complexity. diff --git a/apps/server/src/routes/worktree/routes/discard-changes.ts b/apps/server/src/routes/worktree/routes/discard-changes.ts index 914eff677..eb2c9399d 100644 --- a/apps/server/src/routes/worktree/routes/discard-changes.ts +++ b/apps/server/src/routes/worktree/routes/discard-changes.ts @@ -5,12 +5,12 @@ * 1. Discard ALL changes (when no files array is provided) * - Resets staged changes (git reset HEAD) * - Discards modified tracked files (git checkout .) - * - Removes untracked files and directories (git clean -fd) + * - Removes untracked files and directories (git clean -ffd) * * 2. Discard SELECTED files (when files array is provided) * - Unstages selected staged files (git reset HEAD -- ) * - Reverts selected tracked file changes (git checkout -- ) - * - Removes selected untracked files (git clean -fd -- ) + * - Removes selected untracked files (git clean -ffd -- ) * * Note: Git repository validation (isGitRepo) is handled by * the requireGitRepoOnly middleware in index.ts @@ -52,6 +52,22 @@ function validateFilePath(filePath: string, worktreePath: string): boolean { } } +/** + * Parse a file path from git status --porcelain output, handling renames. + * For renamed files (R status), git reports "old_path -> new_path" and + * we need the new path to match what parseGitStatus() returns in git-utils. + */ +function parseFilePath(rawPath: string, indexStatus: string, workTreeStatus: string): string { + const trimmedPath = rawPath.trim(); + if (indexStatus === 'R' || workTreeStatus === 'R') { + const arrowIndex = trimmedPath.indexOf(' -> '); + if (arrowIndex !== -1) { + return trimmedPath.slice(arrowIndex + 4); + } + } + return trimmedPath; +} + export function createDiscardChangesHandler() { return async (req: Request, res: Response): Promise => { try { @@ -91,11 +107,16 @@ export function createDiscardChangesHandler() { // Parse the status output to categorize files // Git --porcelain format: XY PATH where X=index status, Y=worktree status - // Preserve the exact two-character XY status (no trim) to keep index vs worktree info + // For renamed files: XY OLD_PATH -> NEW_PATH const statusLines = status.trim().split('\n').filter(Boolean); const allFiles = statusLines.map((line) => { const fileStatus = line.substring(0, 2); - const filePath = line.slice(3).trim(); + const rawPath = line.slice(3); + const indexStatus = fileStatus.charAt(0); + const workTreeStatus = fileStatus.charAt(1); + // Parse path consistently with parseGitStatus() in git-utils, + // which extracts the new path for renames + const filePath = parseFilePath(rawPath, indexStatus, workTreeStatus); return { status: fileStatus, path: filePath }; }); @@ -122,8 +143,12 @@ export function createDiscardChangesHandler() { const untrackedFiles: string[] = []; // Untracked files (?) const warnings: string[] = []; + // Track which requested files were matched so we can handle unmatched ones + const matchedFiles = new Set(); + for (const file of allFiles) { if (!filesToDiscard.has(file.path)) continue; + matchedFiles.add(file.path); // file.status is the raw two-character XY git porcelain status (no trim) // X = index/staging status, Y = worktree status @@ -151,6 +176,16 @@ export function createDiscardChangesHandler() { } } + // Handle files from the UI that didn't match any entry in allFiles. + // This can happen due to timing differences between the UI loading diffs + // and the discard request, or path format differences. + // Attempt to clean unmatched files directly as untracked files. + for (const requestedFile of files) { + if (!matchedFiles.has(requestedFile)) { + untrackedFiles.push(requestedFile); + } + } + // 1. Unstage selected staged files (using execFile to bypass shell) if (stagedFiles.length > 0) { try { @@ -174,9 +209,10 @@ export function createDiscardChangesHandler() { } // 3. Remove selected untracked files + // Use -ffd (double force) to also handle nested git repositories if (untrackedFiles.length > 0) { try { - await execGitCommand(['clean', '-fd', '--', ...untrackedFiles], worktreePath); + await execGitCommand(['clean', '-ffd', '--', ...untrackedFiles], worktreePath); } catch (error) { const msg = getErrorMessage(error); logError(error, `Failed to clean untracked files: ${msg}`); @@ -234,11 +270,12 @@ export function createDiscardChangesHandler() { } // 3. Remove untracked files and directories + // Use -ffd (double force) to also handle nested git repositories try { - await execGitCommand(['clean', '-fd'], worktreePath); + await execGitCommand(['clean', '-ffd', '--'], worktreePath); } catch (error) { const msg = getErrorMessage(error); - logError(error, `git clean -fd failed: ${msg}`); + logError(error, `git clean -ffd failed: ${msg}`); warnings.push(`Failed to remove untracked files: ${msg}`); } diff --git a/apps/server/src/services/agent-executor-types.ts b/apps/server/src/services/agent-executor-types.ts index d449a25e0..56a9086c0 100644 --- a/apps/server/src/services/agent-executor-types.ts +++ b/apps/server/src/services/agent-executor-types.ts @@ -29,6 +29,7 @@ export interface AgentExecutionOptions { credentials?: Credentials; claudeCompatibleProvider?: ClaudeCompatibleProvider; mcpServers?: Record; + sdkSessionId?: string; sdkOptions?: { maxTurns?: number; allowedTools?: string[]; diff --git a/apps/server/src/services/agent-executor.ts b/apps/server/src/services/agent-executor.ts index 5f45c6002..1ef7bed92 100644 --- a/apps/server/src/services/agent-executor.ts +++ b/apps/server/src/services/agent-executor.ts @@ -93,6 +93,7 @@ export class AgentExecutor { credentials, claudeCompatibleProvider, mcpServers, + sdkSessionId, sdkOptions, } = options; const { content: promptContent } = await buildPromptWithImages( @@ -129,6 +130,7 @@ export class AgentExecutor { thinkingLevel: options.thinkingLevel, credentials, claudeCompatibleProvider, + sdkSessionId, }; const featureDirForOutput = getFeatureDir(projectPath, featureId); const outputPath = path.join(featureDirForOutput, 'agent-output.md'); @@ -217,6 +219,9 @@ export class AgentExecutor { try { const stream = provider.executeQuery(executeOptions); streamLoop: for await (const msg of stream) { + if (msg.session_id && msg.session_id !== options.sdkSessionId) { + options.sdkSessionId = msg.session_id; + } receivedAnyStreamMessage = true; appendRawEvent(msg); if (abortController.signal.aborted) { @@ -385,6 +390,9 @@ export class AgentExecutor { taskCompleteDetected = false; for await (const msg of taskStream) { + if (msg.session_id && msg.session_id !== options.sdkSessionId) { + options.sdkSessionId = msg.session_id; + } if (msg.type === 'assistant' && msg.message?.content) { for (const b of msg.message.content) { if (b.type === 'text') { @@ -599,6 +607,9 @@ export class AgentExecutor { for await (const msg of provider.executeQuery( this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS) )) { + if (msg.session_id && msg.session_id !== options.sdkSessionId) { + options.sdkSessionId = msg.session_id; + } if (msg.type === 'assistant' && msg.message?.content) for (const b of msg.message.content) if (b.type === 'text') { @@ -698,6 +709,7 @@ export class AgentExecutor { : undefined, credentials: o.credentials, claudeCompatibleProvider: o.claudeCompatibleProvider, + sdkSessionId: o.sdkSessionId, }; } @@ -717,6 +729,9 @@ export class AgentExecutor { for await (const msg of provider.executeQuery( this.buildExecOpts(options, contPrompt, options.sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS) )) { + if (msg.session_id && msg.session_id !== options.sdkSessionId) { + options.sdkSessionId = msg.session_id; + } if (msg.type === 'assistant' && msg.message?.content) for (const b of msg.message.content) { if (b.type === 'text') { diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index ec725f343..0e40e316e 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -329,12 +329,6 @@ export class AgentService { timestamp: new Date().toISOString(), }; - // Build conversation history from existing messages BEFORE adding current message - const conversationHistory = session.messages.map((msg) => ({ - role: msg.role, - content: msg.content, - })); - session.messages.push(userMessage); session.isRunning = true; session.abortController = new AbortController(); @@ -406,6 +400,7 @@ export class AgentService { } } + let combinedSystemPrompt: string | undefined; // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files // Use the user's message as task context for smart memory selection const contextResult = await loadContextFiles({ @@ -423,7 +418,7 @@ export class AgentService { // Build combined system prompt with base prompt and context files const baseSystemPrompt = await this.getSystemPrompt(); - const combinedSystemPrompt = contextFilesPrompt + combinedSystemPrompt = contextFilesPrompt ? `${contextFilesPrompt}\n\n${baseSystemPrompt}` : baseSystemPrompt; @@ -525,6 +520,14 @@ export class AgentService { : stripProviderPrefix(effectiveModel); // Build options for provider + const conversationHistory = session.messages + .slice(0, -1) + .map((msg) => ({ + role: msg.role, + content: msg.content, + })) + .filter((msg) => msg.content.trim().length > 0); + const options: ExecuteOptions = { prompt: '', // Will be set below based on images model: bareModel, // Bare model ID (e.g., "gpt-5.1-codex-max", "composer-1") @@ -534,7 +537,8 @@ export class AgentService { maxTurns: maxTurns, allowedTools: allowedTools, abortController: session.abortController!, - conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, + conversationHistory: + conversationHistory && conversationHistory.length > 0 ? conversationHistory : undefined, settingSources: settingSources.length > 0 ? settingSources : undefined, sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration diff --git a/apps/server/tests/unit/providers/codex-provider.test.ts b/apps/server/tests/unit/providers/codex-provider.test.ts index 0121fd170..1e150ee16 100644 --- a/apps/server/tests/unit/providers/codex-provider.test.ts +++ b/apps/server/tests/unit/providers/codex-provider.test.ts @@ -170,6 +170,30 @@ describe('codex-provider.ts', () => { expect(call.args).toContain('--json'); }); + it('uses exec resume when sdkSessionId is provided', async () => { + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Continue', + model: 'gpt-5.2', + cwd: '/tmp', + sdkSessionId: 'codex-session-123', + outputFormat: { type: 'json_schema', schema: { type: 'object', properties: {} } }, + codexSettings: { additionalDirs: ['/extra/dir'] }, + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.args[0]).toBe('exec'); + expect(call.args[1]).toBe('resume'); + expect(call.args).toContain('codex-session-123'); + expect(call.args).toContain('--json'); + // Resume queries must not include --output-schema or --add-dir + expect(call.args).not.toContain('--output-schema'); + expect(call.args).not.toContain('--add-dir'); + }); + it('overrides approval policy when MCP auto-approval is enabled', async () => { // Note: With full-permissions always on (--dangerously-bypass-approvals-and-sandbox), // approval policy is bypassed, not configured via --config diff --git a/apps/server/tests/unit/providers/copilot-provider.test.ts b/apps/server/tests/unit/providers/copilot-provider.test.ts index ccd7ae28a..552e75304 100644 --- a/apps/server/tests/unit/providers/copilot-provider.test.ts +++ b/apps/server/tests/unit/providers/copilot-provider.test.ts @@ -1,17 +1,35 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { CopilotProvider, CopilotErrorCode } from '@/providers/copilot-provider.js'; +import { collectAsyncGenerator } from '../../utils/helpers.js'; +import { CopilotClient } from '@github/copilot-sdk'; + +const createSessionMock = vi.fn(); +const resumeSessionMock = vi.fn(); + +function createMockSession(sessionId = 'test-session') { + let eventHandler: ((event: any) => void) | null = null; + return { + sessionId, + send: vi.fn().mockImplementation(async () => { + if (eventHandler) { + eventHandler({ type: 'assistant.message', data: { content: 'hello' } }); + eventHandler({ type: 'session.idle' }); + } + }), + destroy: vi.fn().mockResolvedValue(undefined), + on: vi.fn().mockImplementation((handler: (event: any) => void) => { + eventHandler = handler; + }), + }; +} // Mock the Copilot SDK vi.mock('@github/copilot-sdk', () => ({ CopilotClient: vi.fn().mockImplementation(() => ({ start: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined), - createSession: vi.fn().mockResolvedValue({ - sessionId: 'test-session', - send: vi.fn().mockResolvedValue(undefined), - destroy: vi.fn().mockResolvedValue(undefined), - on: vi.fn(), - }), + createSession: createSessionMock, + resumeSession: resumeSessionMock, })), })); @@ -49,6 +67,16 @@ describe('copilot-provider.ts', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(CopilotClient).mockImplementation(function () { + return { + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + createSession: createSessionMock, + resumeSession: resumeSessionMock, + } as any; + }); + createSessionMock.mockResolvedValue(createMockSession()); + resumeSessionMock.mockResolvedValue(createMockSession('resumed-session')); // Mock fs.existsSync for CLI path validation vi.mocked(fs.existsSync).mockReturnValue(true); @@ -514,4 +542,45 @@ describe('copilot-provider.ts', () => { expect(todoInput.todos[0].status).toBe('completed'); }); }); + + describe('executeQuery resume behavior', () => { + it('uses resumeSession when sdkSessionId is provided', async () => { + const results = await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Hello', + model: 'claude-sonnet-4.6', + cwd: '/tmp/project', + sdkSessionId: 'session-123', + }) + ); + + expect(resumeSessionMock).toHaveBeenCalledWith( + 'session-123', + expect.objectContaining({ model: 'claude-sonnet-4.6', streaming: true }) + ); + expect(createSessionMock).not.toHaveBeenCalled(); + expect(results.some((msg) => msg.session_id === 'resumed-session')).toBe(true); + }); + + it('falls back to createSession when resumeSession fails', async () => { + resumeSessionMock.mockRejectedValueOnce(new Error('session not found')); + createSessionMock.mockResolvedValueOnce(createMockSession('fresh-session')); + + const results = await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Hello', + model: 'claude-sonnet-4.6', + cwd: '/tmp/project', + sdkSessionId: 'stale-session', + }) + ); + + expect(resumeSessionMock).toHaveBeenCalledWith( + 'stale-session', + expect.objectContaining({ model: 'claude-sonnet-4.6', streaming: true }) + ); + expect(createSessionMock).toHaveBeenCalledTimes(1); + expect(results.some((msg) => msg.session_id === 'fresh-session')).toBe(true); + }); + }); }); diff --git a/apps/server/tests/unit/providers/cursor-provider.test.ts b/apps/server/tests/unit/providers/cursor-provider.test.ts new file mode 100644 index 000000000..9eff5d306 --- /dev/null +++ b/apps/server/tests/unit/providers/cursor-provider.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { CursorProvider } from '@/providers/cursor-provider.js'; + +describe('cursor-provider.ts', () => { + describe('buildCliArgs', () => { + it('adds --resume when sdkSessionId is provided', () => { + const provider = Object.create(CursorProvider.prototype) as CursorProvider & { + cliPath?: string; + }; + provider.cliPath = '/usr/local/bin/cursor-agent'; + + const args = provider.buildCliArgs({ + prompt: 'Continue the task', + model: 'gpt-5', + cwd: '/tmp/project', + sdkSessionId: 'cursor-session-123', + }); + + const resumeIndex = args.indexOf('--resume'); + expect(resumeIndex).toBeGreaterThan(-1); + expect(args[resumeIndex + 1]).toBe('cursor-session-123'); + }); + + it('does not add --resume when sdkSessionId is omitted', () => { + const provider = Object.create(CursorProvider.prototype) as CursorProvider & { + cliPath?: string; + }; + provider.cliPath = '/usr/local/bin/cursor-agent'; + + const args = provider.buildCliArgs({ + prompt: 'Start a new task', + model: 'gpt-5', + cwd: '/tmp/project', + }); + + expect(args).not.toContain('--resume'); + }); + }); +}); diff --git a/apps/server/tests/unit/providers/gemini-provider.test.ts b/apps/server/tests/unit/providers/gemini-provider.test.ts new file mode 100644 index 000000000..a2d410d45 --- /dev/null +++ b/apps/server/tests/unit/providers/gemini-provider.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { GeminiProvider } from '@/providers/gemini-provider.js'; + +describe('gemini-provider.ts', () => { + let provider: GeminiProvider; + + beforeEach(() => { + provider = new GeminiProvider(); + }); + + describe('buildCliArgs', () => { + it('should include --resume when sdkSessionId is provided', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: '2.5-flash', + cwd: '/tmp/project', + sdkSessionId: 'gemini-session-123', + }); + + const resumeIndex = args.indexOf('--resume'); + expect(resumeIndex).toBeGreaterThan(-1); + expect(args[resumeIndex + 1]).toBe('gemini-session-123'); + }); + + it('should not include --resume when sdkSessionId is missing', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: '2.5-flash', + cwd: '/tmp/project', + }); + + expect(args).not.toContain('--resume'); + }); + }); +}); diff --git a/apps/server/tests/unit/services/agent-service.test.ts b/apps/server/tests/unit/services/agent-service.test.ts index 96090d2b9..c8ae1cba4 100644 --- a/apps/server/tests/unit/services/agent-service.test.ts +++ b/apps/server/tests/unit/services/agent-service.test.ts @@ -303,6 +303,36 @@ describe('agent-service.ts', () => { expect(fs.writeFile).toHaveBeenCalled(); }); + + it('should include context/history preparation for Gemini requests', async () => { + let capturedOptions: any; + const mockProvider = { + getName: () => 'gemini', + executeQuery: async function* (options: any) { + capturedOptions = options; + yield { + type: 'result', + subtype: 'success', + }; + }, + }; + + vi.mocked(ProviderFactory.getProviderForModelName).mockReturnValue('gemini'); + 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', + model: 'gemini-2.5-flash', + }); + + expect(contextLoader.loadContextFiles).toHaveBeenCalled(); + expect(capturedOptions).toBeDefined(); + }); }); describe('stopExecution', () => { diff --git a/apps/ui/src/components/views/agent-view.tsx b/apps/ui/src/components/views/agent-view.tsx index 51c8f7fbb..967cafd44 100644 --- a/apps/ui/src/components/views/agent-view.tsx +++ b/apps/ui/src/components/views/agent-view.tsx @@ -1,6 +1,5 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { useAppStore } from '@/store/app-store'; -import type { PhaseModelEntry } from '@automaker/types'; import { useElectronAgent } from '@/hooks/use-electron-agent'; import { SessionManager } from '@/components/session-manager'; @@ -46,8 +45,6 @@ export function AgentView() { return () => window.removeEventListener('resize', updateVisibility); }, []); - const [modelSelection, setModelSelection] = useState({ model: 'claude-sonnet' }); - // Input ref for auto-focus const inputRef = useRef(null); @@ -57,10 +54,12 @@ export function AgentView() { const createSessionInFlightRef = useRef(false); // Session management hook - scoped to current worktree - const { currentSessionId, handleSelectSession } = useAgentSession({ - projectPath: currentProject?.path, - workingDirectory: effectiveWorkingDirectory, - }); + // Also handles model selection persistence per session + const { currentSessionId, handleSelectSession, modelSelection, setModelSelection } = + useAgentSession({ + projectPath: currentProject?.path, + workingDirectory: effectiveWorkingDirectory, + }); // Use the Electron agent hook (only if we have a session) const { diff --git a/apps/ui/src/components/views/agent-view/hooks/use-agent-session.ts b/apps/ui/src/components/views/agent-view/hooks/use-agent-session.ts index a159cee4a..f4c2d2a04 100644 --- a/apps/ui/src/components/views/agent-view/hooks/use-agent-session.ts +++ b/apps/ui/src/components/views/agent-view/hooks/use-agent-session.ts @@ -1,9 +1,14 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { createLogger } from '@automaker/utils/logger'; +import type { PhaseModelEntry } from '@automaker/types'; import { useAppStore } from '@/store/app-store'; +import { useShallow } from 'zustand/react/shallow'; const logger = createLogger('AgentSession'); +// Default model selection when none is persisted +const DEFAULT_MODEL_SELECTION: PhaseModelEntry = { model: 'claude-sonnet' }; + interface UseAgentSessionOptions { projectPath: string | undefined; workingDirectory?: string; // Current worktree path for per-worktree session persistence @@ -12,14 +17,31 @@ interface UseAgentSessionOptions { interface UseAgentSessionResult { currentSessionId: string | null; handleSelectSession: (sessionId: string | null) => void; + // Model selection persistence + modelSelection: PhaseModelEntry; + setModelSelection: (model: PhaseModelEntry) => void; } export function useAgentSession({ projectPath, workingDirectory, }: UseAgentSessionOptions): UseAgentSessionResult { - const { setLastSelectedSession, getLastSelectedSession } = useAppStore(); + const { + setLastSelectedSession, + getLastSelectedSession, + setAgentModelForSession, + getAgentModelForSession, + } = useAppStore( + useShallow((state) => ({ + setLastSelectedSession: state.setLastSelectedSession, + getLastSelectedSession: state.getLastSelectedSession, + setAgentModelForSession: state.setAgentModelForSession, + getAgentModelForSession: state.getAgentModelForSession, + })) + ); const [currentSessionId, setCurrentSessionId] = useState(null); + const [modelSelection, setModelSelectionState] = + useState(DEFAULT_MODEL_SELECTION); // Track if initial session has been loaded const initialSessionLoadedRef = useRef(false); @@ -27,6 +49,22 @@ export function useAgentSession({ // Use workingDirectory as the persistence key so sessions are scoped per worktree const persistenceKey = workingDirectory || projectPath; + /** + * Fetch persisted model for sessionId and update local state, or fall back to default. + */ + const restoreModelForSession = useCallback( + (sessionId: string) => { + const persistedModel = getAgentModelForSession(sessionId); + if (persistedModel) { + logger.debug('Restoring model selection for session:', sessionId, persistedModel); + setModelSelectionState(persistedModel); + } else { + setModelSelectionState(DEFAULT_MODEL_SELECTION); + } + }, + [getAgentModelForSession] + ); + // Handle session selection with persistence const handleSelectSession = useCallback( (sessionId: string | null) => { @@ -35,16 +73,52 @@ export function useAgentSession({ if (persistenceKey) { setLastSelectedSession(persistenceKey, sessionId); } + // Restore model selection for this session if available + if (sessionId) { + restoreModelForSession(sessionId); + } + }, + [persistenceKey, setLastSelectedSession, restoreModelForSession] + ); + + // Wrapper for setModelSelection that also persists + const setModelSelection = useCallback( + (model: PhaseModelEntry) => { + setModelSelectionState(model); + // Persist model selection for current session. + // If currentSessionId is null (no active session), we only update local state + // and skip persistence — this is intentional because the model picker should be + // disabled (or hidden) in the UI whenever there is no active session, so this + // path is only reached if the UI allows selection before a session is established. + if (currentSessionId) { + setAgentModelForSession(currentSessionId, model); + } }, - [persistenceKey, setLastSelectedSession] + [currentSessionId, setAgentModelForSession] ); + // Track the previous persistence key to detect actual changes + const prevPersistenceKeyRef = useRef(persistenceKey); + // Restore last selected session when switching to Agent view or when worktree changes useEffect(() => { - if (!persistenceKey) { - // No project, reset - setCurrentSessionId(null); + // Detect if persistenceKey actually changed (worktree/project switch) + const persistenceKeyChanged = prevPersistenceKeyRef.current !== persistenceKey; + + if (persistenceKeyChanged) { + // Reset state when switching worktree/project + prevPersistenceKeyRef.current = persistenceKey; initialSessionLoadedRef.current = false; + setCurrentSessionId(null); + setModelSelectionState(DEFAULT_MODEL_SELECTION); + + if (!persistenceKey) { + // No project, nothing to restore + return; + } + } + + if (!persistenceKey) { return; } @@ -54,19 +128,17 @@ export function useAgentSession({ const lastSessionId = getLastSelectedSession(persistenceKey); if (lastSessionId) { - logger.info('Restoring last selected session:', lastSessionId); + logger.debug('Restoring last selected session:', lastSessionId); setCurrentSessionId(lastSessionId); + // Also restore model selection for this session + restoreModelForSession(lastSessionId); } - }, [persistenceKey, getLastSelectedSession]); - - // Reset when worktree/project changes - clear current session and allow restore - useEffect(() => { - initialSessionLoadedRef.current = false; - setCurrentSessionId(null); - }, [persistenceKey]); + }, [persistenceKey, getLastSelectedSession, restoreModelForSession]); return { currentSessionId, handleSelectSession, + modelSelection, + setModelSelection, }; } diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 5d3a33cc7..5f2f919ac 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -116,7 +116,6 @@ export function BoardView() { setPendingPlanApproval, updateFeature, batchUpdateFeatures, - getCurrentWorktree, setCurrentWorktree, getWorktrees, setWorktrees, @@ -135,7 +134,6 @@ export function BoardView() { setPendingPlanApproval: state.setPendingPlanApproval, updateFeature: state.updateFeature, batchUpdateFeatures: state.batchUpdateFeatures, - getCurrentWorktree: state.getCurrentWorktree, setCurrentWorktree: state.setCurrentWorktree, getWorktrees: state.getWorktrees, setWorktrees: state.setWorktrees, @@ -444,9 +442,17 @@ export function BoardView() { [batchResetBranchFeatures] ); - // Get current worktree info (path) for filtering features - // This needs to be before useBoardActions so we can pass currentWorktreeBranch - const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null; + const currentProjectPath = currentProject?.path; + + // Get current worktree info (path/branch) for filtering features. + // Subscribe to the selected project's current worktree value directly so worktree + // switches trigger an immediate re-render and instant kanban/list re-filtering. + const currentWorktreeInfo = useAppStore( + useCallback( + (s) => (currentProjectPath ? (s.currentWorktreeByProject[currentProjectPath] ?? null) : null), + [currentProjectPath] + ) + ); const currentWorktreePath = currentWorktreeInfo?.path ?? null; // Select worktrees for the current project directly from the store. @@ -455,7 +461,6 @@ export function BoardView() { // object, causing unnecessary re-renders that cascaded into selectedWorktree → // useAutoMode → refreshStatus → setAutoModeRunning → store update → re-render loop // that could trigger React error #185 on initial project open). - const currentProjectPath = currentProject?.path; const worktrees = useAppStore( useCallback( (s) => diff --git a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx index c31ff482d..10ed992b2 100644 --- a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { Dialog, DialogContent, @@ -30,13 +30,17 @@ import { ChevronDown, ChevronRight, Upload, + RefreshCw, } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; +import { getHttpApiClient } from '@/lib/http-api-client'; import { toast } from 'sonner'; import { useAppStore } from '@/store/app-store'; +import { resolveModelString } from '@automaker/model-resolver'; import { cn } from '@/lib/utils'; import { TruncatedFilePath } from '@/components/ui/truncated-file-path'; +import { ModelOverrideTrigger, useModelOverride } from '@/components/shared'; import type { FileStatus, MergeStateInfo } from '@/types/electron'; import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils'; @@ -206,6 +210,11 @@ export function CommitWorktreeDialog({ const [error, setError] = useState(null); const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages); + // Commit message model override + const commitModelOverride = useModelOverride({ phase: 'commitMessageModel' }); + const { effectiveModel: commitEffectiveModel, effectiveModelEntry: commitEffectiveModelEntry } = + commitModelOverride; + // File selection state const [files, setFiles] = useState([]); const [diffContent, setDiffContent] = useState(''); @@ -532,6 +541,46 @@ export function CommitWorktreeDialog({ } }; + // Generate AI commit message + const generateCommitMessage = useCallback(async () => { + if (!worktree) return; + + setIsGenerating(true); + try { + const resolvedCommitModel = resolveModelString(commitEffectiveModel); + const api = getHttpApiClient(); + const result = await api.worktree.generateCommitMessage( + worktree.path, + resolvedCommitModel, + commitEffectiveModelEntry?.thinkingLevel, + commitEffectiveModelEntry?.providerId + ); + + if (result.success && result.message) { + setMessage(result.message); + } else { + console.warn('Failed to generate commit message:', result.error); + toast.error('Failed to generate commit message', { + description: result.error || 'Unknown error', + }); + } + } catch (err) { + console.warn('Error generating commit message:', err); + toast.error('Failed to generate commit message', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + } finally { + setIsGenerating(false); + } + }, [worktree, commitEffectiveModel, commitEffectiveModelEntry]); + + // Keep a stable ref to generateCommitMessage so the open-dialog effect + // doesn't re-fire (and erase user edits) when the model override changes. + const generateCommitMessageRef = useRef(generateCommitMessage); + useEffect(() => { + generateCommitMessageRef.current = generateCommitMessage; + }); + // Generate AI commit message when dialog opens (if enabled) useEffect(() => { if (open && worktree) { @@ -543,45 +592,7 @@ export function CommitWorktreeDialog({ return; } - setIsGenerating(true); - let cancelled = false; - - const generateMessage = async () => { - try { - const api = getElectronAPI(); - if (!api?.worktree?.generateCommitMessage) { - if (!cancelled) { - setIsGenerating(false); - } - return; - } - - const result = await api.worktree.generateCommitMessage(worktree.path); - - if (cancelled) return; - - if (result.success && result.message) { - setMessage(result.message); - } else { - console.warn('Failed to generate commit message:', result.error); - setMessage(''); - } - } catch (err) { - if (cancelled) return; - console.warn('Error generating commit message:', err); - setMessage(''); - } finally { - if (!cancelled) { - setIsGenerating(false); - } - } - }; - - generateMessage(); - - return () => { - cancelled = true; - }; + generateCommitMessageRef.current(); } }, [open, worktree, enableAiCommitMessages]); @@ -589,12 +600,12 @@ export function CommitWorktreeDialog({ const allSelected = selectedFiles.size === files.length && files.length > 0; - // Prevent the dialog from being dismissed while a push is in progress. + // Prevent the dialog from being dismissed while a push or generation is in progress. // Overlay clicks and Escape key both route through onOpenChange(false); we - // intercept those here so the UI stays open until the push completes. + // intercept those here so the UI stays open until the operation completes. const handleOpenChange = (nextOpen: boolean) => { - if (!nextOpen && isPushing) { - // Ignore close requests during an active push. + if (!nextOpen && (isLoading || isPushing || isGenerating)) { + // Ignore close requests during an active commit, push, or generation. return; } onOpenChange(nextOpen); @@ -813,15 +824,46 @@ export function CommitWorktreeDialog({ {/* Commit Message */}
- +
+ +
+ {enableAiCommitMessages && ( + <> + + + + )} +
+