auto-claude: 143-fix-github-integration-ui-refresh-issues (#1467)

* auto-claude: subtask-1-1 - Create project-env-store.ts Zustand store for project envConfig state

- Add ProjectEnvState interface with envConfig, projectId, isLoading, error state
- Implement setEnvConfig, updateEnvConfig, clearEnvConfig, setLoading, setError actions
- Add selector functions: isGitHubEnabled, isGitLabEnabled, isLinearEnabled, getGitHubRepo
- Export loadProjectEnvConfig async function for IPC data fetching
- Export standalone setProjectEnvConfig and clearProjectEnvConfig functions
- Follow existing patterns from sync-status-store.ts and settings-store.ts

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

* auto-claude: subtask-2-1 - Update useProjectSettings.ts to update project-env-store

- Import setProjectEnvConfig from project-env-store
- Update loadEnvConfig effect to call setProjectEnvConfig after loading
- Update updateEnvConfig function to call setProjectEnvConfig after local state update

This enables other components (like Sidebar) to react immediately when
envConfig changes in the project settings dialog.

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

* auto-claude: subtask-3-1 - Update Sidebar.tsx to subscribe to project-env-store

- Import useProjectEnvStore from project-env-store
- Replace local envConfig state with reactive store subscription
- Remove manual useEffect that loaded env config via IPC
- Subscribe to githubEnabled and gitlabEnabled from store
- Update visibleNavItems useMemo to use store-derived values
- Remove unused ProjectEnvConfig type import

This enables reactive UI updates when GitHub/GitLab settings change.

* auto-claude: subtask-3-2 - Fix IntegrationSettings.tsx branch loading effect

- Add envConfig.githubEnabled and envConfig.githubRepo to effect dependencies
- Add gitHubConnectionStatus.connected to trigger branch reload on connection success
- Add guard clauses to prevent branch loading without valid GitHub config
- Remove eslint-disable comment - dependencies are now properly handled

This ensures the branch dropdown populates automatically when:
1. User enters a GitHub repository
2. GitHub connection is successful
3. User has expanded the GitHub section

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

* auto-claude: subtask-4-1 - E2E verification fixes for branch loading

- Wrap loadBranches in useCallback to fix useExhaustiveDependencies warning
- Fix TypeScript error with null/undefined type handling for mainBranch detection
- All TypeScript compilation checks pass
- All relevant lint warnings resolved

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

* fix: add store initialization on project switch (qa-requested)

Fixes:
- Missing store initialization on project switch and app startup

The implementation was missing the initialization logic that ensures
the project-env-store is populated when:
1. The app first loads and a project is already selected
2. The user switches to a different project

Added a useEffect that calls loadProjectEnvConfig() when the selected
project changes, ensuring GitHub/GitLab tabs appear correctly without
needing to open the Project Settings dialog first.

Verified:
- TypeScript compiles without errors
- All existing tests pass (2081 passed)
- ESLint passes (only pre-existing warnings)

QA Fix Session: 1

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

* fix(frontend): address PR review issues for GitHub integration UI

- Fix error state being immediately cleared after set in project-env-store
  by adding setEnvConfigOnly method that doesn't touch error state
- Add race condition handling in loadProjectEnvConfig using request IDs
  to ignore stale responses when projects change rapidly
- Add cancellation mechanism in Sidebar useEffect for envConfig loading
- Fix double-execution of branch loading by using ref to track initial
  detection and removing settings.mainBranch from callback dependencies
- Document selector methods as intentional for encapsulation

Addresses: HIGH - Error state cleared, MEDIUM - Race conditions,
LOW - Double execution, unused selectors

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

* fix(frontend): prevent UI state update when backend save fails

Add early returns in updateEnvConfig after save failures to prevent
UI/storage data inconsistency. Previously, local state and shared
store were updated even when the backend save failed or threw.

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

* fix(frontend): address remaining PR review findings

- Add setProjectEnvConfig call in handleInitialize to sync global store
  after project initialization (MEDIUM - blocking)
- Remove unused selector methods from project-env-store per YAGNI (LOW)
- Replace storeProjectId subscription with ref to prevent extra effect
  runs when other components update the store (LOW)

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

* fix(frontend): add user feedback for env config save failures and fix HMR state

- Add setEnvError() calls in updateEnvConfig when backend save fails
- Clear error on successful save to provide proper feedback
- Move currentRequestId from module scope into Zustand store state
- Add incrementRequestId action for proper state management
- Fixes silent failure issue and improves HMR/testing behavior

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

* fix(frontend): resolve stale closure and ref reset issues in branch detection

- Use mainBranchRef to avoid stale closure in loadBranches callback
- Reset hasDetectedMainBranch when GitHub repo changes (not just project)
- Add clarifying comment for intentional dual-state pattern in useProjectSettings

Fixes branch auto-detection not triggering when changing GitHub repos within
the same project.

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

* fix(frontend): sync global store after Claude setup completes

Add missing setProjectEnvConfig() call in handleClaudeSetup to maintain
consistency with handleInitialize and loadEnvConfig. This ensures Sidebar
and other components reflect updated env config after Claude authentication.

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

* fix(frontend): prevent race conditions in branch detection and env config updates

- Add re-check after async detectMainBranch to respect user branch selection
  made during the detection operation
- Use committedEnvConfigRef to handle concurrent updateEnvConfig calls
  correctly, preventing rapid updates from losing changes due to stale
  state reads

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

* fix(frontend): fix race condition with optimistic ref update and remove dead code

- Update committedEnvConfigRef BEFORE await to eliminate race window between
  concurrent updateEnvConfig calls - ensures rapid toggles don't lose changes
- Remove unused handleSaveEnv function and isSavingEnv state (dead code)
- Remove unused updateEnvConfig action from project-env-store (dead code)

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

* fix(frontend): use consistent fresh store state after async and update docs

- Use fresh useProjectEnvStore.getState() after async operations instead of
  captured store reference for consistency
- Update README to remove references to removed isSavingEnv and saveEnvConfig

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

* fix(tests): rename Chunk/ChunkStatus to Subtask/SubtaskStatus in tests

The test file used 'Chunk' and 'ChunkStatus' but the implementation_plan
module exports 'Subtask' and 'SubtaskStatus', causing NameError failures.

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

* fix(tests): mock getGitHubTokenForSubprocess to prevent Windows CI timeout

The runner-env.test.ts was timing out on Windows CI because
getGitHubTokenForSubprocess was not mocked, causing the test to
actually call the gh CLI which can hang or be slow on Windows.

Added:
- Mock for getGitHubTokenForSubprocess that returns null by default
- Two new tests for GitHub token behavior (when available and when null)

This should prevent the 5000ms timeout on Windows CI.

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

* fix(tests): increase ipc-handlers test timeout for Windows CI

Windows file system operations and module loading are significantly
slower than macOS/Linux. The test uses vi.resetModules() in beforeEach
which causes each test to re-import all handler modules.

Increased timeout from 15000ms to 30000ms to accommodate Windows.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-25 16:35:45 +01:00
committed by GitHub
parent 1e72c8d773
commit aa2cb4fa6b
8 changed files with 312 additions and 84 deletions
@@ -153,8 +153,9 @@ function cleanupTestDirs(): void {
} }
} }
// Increase timeout for all tests in this file due to dynamic imports and setup overhead // Increase timeout for all tests in this file due to dynamic imports and setup overhead.
describe("IPC Handlers", { timeout: 15000 }, () => { // Windows requires longer timeout due to slower file system operations and module loading.
describe("IPC Handlers", { timeout: 30000 }, () => {
let ipcMain: EventEmitter & { let ipcMain: EventEmitter & {
handlers: Map<string, Function>; handlers: Map<string, Function>;
invokeHandler: (channel: string, event: unknown, ...args: unknown[]) => Promise<unknown>; invokeHandler: (channel: string, event: unknown, ...args: unknown[]) => Promise<unknown>;
@@ -4,6 +4,7 @@ const mockGetAPIProfileEnv = vi.fn();
const mockGetOAuthModeClearVars = vi.fn(); const mockGetOAuthModeClearVars = vi.fn();
const mockGetPythonEnv = vi.fn(); const mockGetPythonEnv = vi.fn();
const mockGetBestAvailableProfileEnv = vi.fn(); const mockGetBestAvailableProfileEnv = vi.fn();
const mockGetGitHubTokenForSubprocess = vi.fn();
vi.mock('../../../../services/profile', () => ({ vi.mock('../../../../services/profile', () => ({
getAPIProfileEnv: (...args: unknown[]) => mockGetAPIProfileEnv(...args), getAPIProfileEnv: (...args: unknown[]) => mockGetAPIProfileEnv(...args),
@@ -23,6 +24,12 @@ vi.mock('../../../../rate-limit-detector', () => ({
getBestAvailableProfileEnv: () => mockGetBestAvailableProfileEnv(), getBestAvailableProfileEnv: () => mockGetBestAvailableProfileEnv(),
})); }));
// Mock getGitHubTokenForSubprocess to avoid calling gh CLI in tests
// Path is relative to the module being mocked (runner-env.ts), which imports from '../utils'
vi.mock('../../utils', () => ({
getGitHubTokenForSubprocess: () => mockGetGitHubTokenForSubprocess(),
}));
import { getRunnerEnv } from '../runner-env'; import { getRunnerEnv } from '../runner-env';
describe('getRunnerEnv', () => { describe('getRunnerEnv', () => {
@@ -42,6 +49,8 @@ describe('getRunnerEnv', () => {
profileName: 'Default', profileName: 'Default',
wasSwapped: false wasSwapped: false
}); });
// Default mock for GitHub token - returns null (no token) by default
mockGetGitHubTokenForSubprocess.mockResolvedValue(null);
}); });
it('merges Python env with API profile env and OAuth clear vars', async () => { it('merges Python env with API profile env and OAuth clear vars', async () => {
@@ -130,4 +139,24 @@ describe('getRunnerEnv', () => {
// extraEnv has highest precedence // extraEnv has highest precedence
expect(result.SHARED_VAR).toBe('from-extra'); expect(result.SHARED_VAR).toBe('from-extra');
}); });
it('includes GitHub token from gh CLI when available (fixes #151)', async () => {
mockGetAPIProfileEnv.mockResolvedValue({});
mockGetOAuthModeClearVars.mockReturnValue({});
mockGetGitHubTokenForSubprocess.mockResolvedValue('gh-token-123');
const result = await getRunnerEnv();
expect(result.GITHUB_TOKEN).toBe('gh-token-123');
});
it('omits GITHUB_TOKEN when gh CLI returns null', async () => {
mockGetAPIProfileEnv.mockResolvedValue({});
mockGetOAuthModeClearVars.mockReturnValue({});
mockGetGitHubTokenForSubprocess.mockResolvedValue(null);
const result = await getRunnerEnv();
expect(result.GITHUB_TOKEN).toBeUndefined();
});
}); });
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Plus, Plus,
@@ -47,12 +47,17 @@ import {
initializeProject initializeProject
} from '../stores/project-store'; } from '../stores/project-store';
import { useSettingsStore, saveSettings } from '../stores/settings-store'; import { useSettingsStore, saveSettings } from '../stores/settings-store';
import {
useProjectEnvStore,
loadProjectEnvConfig,
clearProjectEnvConfig
} from '../stores/project-env-store';
import { AddProjectModal } from './AddProjectModal'; import { AddProjectModal } from './AddProjectModal';
import { GitSetupModal } from './GitSetupModal'; import { GitSetupModal } from './GitSetupModal';
import { RateLimitIndicator } from './RateLimitIndicator'; import { RateLimitIndicator } from './RateLimitIndicator';
import { ClaudeCodeStatusBadge } from './ClaudeCodeStatusBadge'; import { ClaudeCodeStatusBadge } from './ClaudeCodeStatusBadge';
import { UpdateBanner } from './UpdateBanner'; import { UpdateBanner } from './UpdateBanner';
import type { Project, AutoBuildVersionInfo, GitStatus, ProjectEnvConfig } from '../../shared/types'; import type { Project, AutoBuildVersionInfo, GitStatus } from '../../shared/types';
export type SidebarView = 'kanban' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'github-issues' | 'gitlab-issues' | 'github-prs' | 'gitlab-merge-requests' | 'changelog' | 'insights' | 'worktrees' | 'agent-tools'; export type SidebarView = 'kanban' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'github-issues' | 'gitlab-issues' | 'github-prs' | 'gitlab-merge-requests' | 'changelog' | 'insights' | 'worktrees' | 'agent-tools';
@@ -112,7 +117,6 @@ export function Sidebar({
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null); const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
const [pendingProject, setPendingProject] = useState<Project | null>(null); const [pendingProject, setPendingProject] = useState<Project | null>(null);
const [isInitializing, setIsInitializing] = useState(false); const [isInitializing, setIsInitializing] = useState(false);
const [envConfig, setEnvConfig] = useState<ProjectEnvConfig | null>(null);
const selectedProject = projects.find((p) => p.id === selectedProjectId); const selectedProject = projects.find((p) => p.id === selectedProjectId);
@@ -123,41 +127,55 @@ export function Sidebar({
saveSettings({ sidebarCollapsed: !isCollapsed }); saveSettings({ sidebarCollapsed: !isCollapsed });
}; };
// Load env config when project changes to check GitHub/GitLab enabled state // Subscribe to project-env-store for reactive GitHub/GitLab tab visibility
useEffect(() => { const githubEnabled = useProjectEnvStore((state) => state.envConfig?.githubEnabled ?? false);
const loadEnvConfig = async () => { const gitlabEnabled = useProjectEnvStore((state) => state.envConfig?.gitlabEnabled ?? false);
if (selectedProject?.autoBuildPath) {
try {
const result = await window.electronAPI.getProjectEnv(selectedProject.id);
if (result.success && result.data) {
setEnvConfig(result.data);
} else {
setEnvConfig(null);
}
} catch {
setEnvConfig(null);
}
} else {
setEnvConfig(null);
}
};
loadEnvConfig();
}, [selectedProject?.id, selectedProject?.autoBuildPath]);
// Compute visible nav items based on GitHub/GitLab enabled state // Track the last loaded project ID to avoid redundant loads
const lastLoadedProjectIdRef = useRef<string | null>(null);
// Compute visible nav items based on GitHub/GitLab enabled state from store
const visibleNavItems = useMemo(() => { const visibleNavItems = useMemo(() => {
const items = [...baseNavItems]; const items = [...baseNavItems];
if (envConfig?.githubEnabled) { if (githubEnabled) {
items.push(...githubNavItems); items.push(...githubNavItems);
} }
if (envConfig?.gitlabEnabled) { if (gitlabEnabled) {
items.push(...gitlabNavItems); items.push(...gitlabNavItems);
} }
return items; return items;
}, [envConfig?.githubEnabled, envConfig?.gitlabEnabled]); }, [githubEnabled, gitlabEnabled]);
// Load envConfig when project changes to ensure store is populated
useEffect(() => {
// Track whether this effect is still current (for race condition handling)
let isCurrent = true;
const initializeEnvConfig = async () => {
if (selectedProject?.id && selectedProject?.autoBuildPath) {
// Only reload if the project ID differs from what we last loaded
if (selectedProject.id !== lastLoadedProjectIdRef.current) {
lastLoadedProjectIdRef.current = selectedProject.id;
await loadProjectEnvConfig(selectedProject.id);
// Check if this effect was cancelled while loading
if (!isCurrent) return;
}
} else {
// Clear the store if no project is selected or has no autoBuildPath
lastLoadedProjectIdRef.current = null;
clearProjectEnvConfig();
}
};
initializeEnvConfig();
// Cleanup function to mark this effect as stale
return () => {
isCurrent = false;
};
}, [selectedProject?.id, selectedProject?.autoBuildPath]);
// Keyboard shortcuts // Keyboard shortcuts
useEffect(() => { useEffect(() => {
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { import {
Zap, Zap,
Eye, Eye,
@@ -79,25 +79,39 @@ export function IntegrationSettings({
const [branches, setBranches] = useState<string[]>([]); const [branches, setBranches] = useState<string[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false); const [isLoadingBranches, setIsLoadingBranches] = useState(false);
// Load branches when GitHub section expands // Track whether initial branch detection has been done to prevent double-execution
useEffect(() => { const hasDetectedMainBranch = useRef(false);
if (githubExpanded && project.path) { // Track mainBranch in a ref to avoid stale closure issues in loadBranches callback
loadBranches(); const mainBranchRef = useRef(settings.mainBranch);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- loadBranches is intentionally excluded to avoid infinite loops
}, [githubExpanded, project.path]);
const loadBranches = async () => { // Keep mainBranchRef in sync with settings.mainBranch
useEffect(() => {
mainBranchRef.current = settings.mainBranch;
}, [settings.mainBranch]);
// Reset detection flag when project OR GitHub repo changes
// This allows auto-detection to run for a new repo within the same project
useEffect(() => {
hasDetectedMainBranch.current = false;
}, [project.path, envConfig?.githubRepo]);
// Load branches function wrapped in useCallback
// Note: We use refs for mainBranch check and detection tracking to avoid stale closures
const loadBranches = useCallback(async () => {
setIsLoadingBranches(true); setIsLoadingBranches(true);
try { try {
const result = await window.electronAPI.getGitBranches(project.path); const result = await window.electronAPI.getGitBranches(project.path);
if (result.success && result.data) { if (result.success && result.data) {
setBranches(result.data); setBranches(result.data);
// Auto-detect main branch if not set // Auto-detect main branch if not set and not already detected
if (!settings.mainBranch) { // Use mainBranchRef to avoid stale closure issues
if (!mainBranchRef.current && !hasDetectedMainBranch.current) {
hasDetectedMainBranch.current = true;
const detectResult = await window.electronAPI.detectMainBranch(project.path); const detectResult = await window.electronAPI.detectMainBranch(project.path);
if (detectResult.success && detectResult.data) { // Re-check mainBranchRef after await - user may have selected a branch during detection
setSettings(prev => ({ ...prev, mainBranch: detectResult.data! })); if (detectResult.success && detectResult.data !== null && detectResult.data !== undefined && !mainBranchRef.current) {
const detectedBranch = detectResult.data;
setSettings(prev => ({ ...prev, mainBranch: detectedBranch }));
} }
} }
} }
@@ -106,7 +120,24 @@ export function IntegrationSettings({
} finally { } finally {
setIsLoadingBranches(false); setIsLoadingBranches(false);
} }
}; // settings.mainBranch not in deps - we use mainBranchRef to avoid stale closures
// hasDetectedMainBranch ref tracks whether detection has run this session
}, [project.path, setSettings]);
// Load branches when GitHub section expands or GitHub connection changes
useEffect(() => {
// Only load branches when:
// 1. GitHub section is expanded
// 2. Project path exists
// 3. GitHub is enabled with repo configured
if (!githubExpanded || !project.path) return;
if (!envConfig?.githubEnabled || !envConfig?.githubRepo) return;
// Only load branches when we have a successful connection
if (gitHubConnectionStatus?.connected) {
loadBranches();
}
}, [githubExpanded, project.path, envConfig?.githubEnabled, envConfig?.githubRepo, gitHubConnectionStatus?.connected, loadBranches]);
if (!envConfig) return null; if (!envConfig) return null;
@@ -226,11 +226,9 @@ hooks/
**Returns**: **Returns**:
- `envConfig`: Current environment config - `envConfig`: Current environment config
- `setEnvConfig`: Config updater - `setEnvConfig`: Config updater
- `updateEnvConfig`: Partial update function - `updateEnvConfig`: Partial update function (auto-saves to backend)
- `isLoadingEnv`: Loading state - `isLoadingEnv`: Loading state
- `envError`: Error state - `envError`: Error state
- `isSavingEnv`: Save in progress state
- `saveEnvConfig`: Save function
### useClaudeAuth.ts ### useClaudeAuth.ts
**Purpose**: Manages Claude authentication status checking. **Purpose**: Manages Claude authentication status checking.
@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { import {
updateProjectSettings, updateProjectSettings,
checkProjectVersion, checkProjectVersion,
initializeProject initializeProject
} from '../../../stores/project-store'; } from '../../../stores/project-store';
import { checkGitHubConnection as checkGitHubConnectionGlobal } from '../../../stores/github'; import { checkGitHubConnection as checkGitHubConnectionGlobal } from '../../../stores/github';
import { setProjectEnvConfig } from '../../../stores/project-env-store';
import type { import type {
Project, Project,
ProjectSettings as ProjectSettingsType, ProjectSettings as ProjectSettingsType,
@@ -34,7 +35,6 @@ export interface UseProjectSettingsReturn {
isLoadingEnv: boolean; isLoadingEnv: boolean;
envError: string | null; envError: string | null;
setEnvError: React.Dispatch<React.SetStateAction<string | null>>; setEnvError: React.Dispatch<React.SetStateAction<string | null>>;
isSavingEnv: boolean;
updateEnvConfig: (updates: Partial<ProjectEnvConfig>) => Promise<void>; updateEnvConfig: (updates: Partial<ProjectEnvConfig>) => Promise<void>;
// Password visibility toggles // Password visibility toggles
@@ -74,7 +74,6 @@ export interface UseProjectSettingsReturn {
// Actions // Actions
handleInitialize: () => Promise<void>; handleInitialize: () => Promise<void>;
handleSaveEnv: () => Promise<void>;
handleClaudeSetup: () => Promise<void>; handleClaudeSetup: () => Promise<void>;
handleSave: (onClose: () => void) => Promise<void>; handleSave: (onClose: () => void) => Promise<void>;
} }
@@ -91,10 +90,17 @@ export function useProjectSettings(
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
// Environment configuration state // Environment configuration state
// NOTE: We maintain local envConfig state AND update the global project-env-store.
// This dual-state pattern is intentional:
// - Local state: allows dialog-scoped edits, immediate UI feedback
// - Global store: syncs to Sidebar and other components for real-time updates
// The local state is the source of truth for this dialog's edits.
const [envConfig, setEnvConfig] = useState<ProjectEnvConfig | null>(null); const [envConfig, setEnvConfig] = useState<ProjectEnvConfig | null>(null);
const [isLoadingEnv, setIsLoadingEnv] = useState(false); const [isLoadingEnv, setIsLoadingEnv] = useState(false);
const [envError, setEnvError] = useState<string | null>(null); const [envError, setEnvError] = useState<string | null>(null);
const [isSavingEnv, setIsSavingEnv] = useState(false); // Ref to track the latest committed config - used to handle concurrent updateEnvConfig calls
// This ensures rapid updates don't lose changes due to stale state reads
const committedEnvConfigRef = useRef<ProjectEnvConfig | null>(null);
// Password visibility toggles // Password visibility toggles
const [showClaudeToken, setShowClaudeToken] = useState(false); const [showClaudeToken, setShowClaudeToken] = useState(false);
@@ -156,6 +162,9 @@ export function useProjectSettings(
const result = await window.electronAPI.getProjectEnv(project.id); const result = await window.electronAPI.getProjectEnv(project.id);
if (result.success && result.data) { if (result.success && result.data) {
setEnvConfig(result.data); setEnvConfig(result.data);
committedEnvConfigRef.current = result.data;
// Update the shared store so other components (like Sidebar) can react
setProjectEnvConfig(project.id, result.data);
} else { } else {
setEnvError(result.error || 'Failed to load environment config'); setEnvError(result.error || 'Failed to load environment config');
} }
@@ -287,6 +296,9 @@ export function useProjectSettings(
const envResult = await window.electronAPI.getProjectEnv(project.id); const envResult = await window.electronAPI.getProjectEnv(project.id);
if (envResult.success && envResult.data) { if (envResult.success && envResult.data) {
setEnvConfig(envResult.data); setEnvConfig(envResult.data);
committedEnvConfigRef.current = envResult.data;
// Update global store so Sidebar reflects correct GitHub/GitLab enabled state
setProjectEnvConfig(project.id, envResult.data);
} }
} else { } else {
setError(result?.error || 'Failed to initialize'); setError(result?.error || 'Failed to initialize');
@@ -298,23 +310,6 @@ export function useProjectSettings(
} }
}; };
const handleSaveEnv = async () => {
if (!envConfig) return;
setIsSavingEnv(true);
setEnvError(null);
try {
const result = await window.electronAPI.updateProjectEnv(project.id, envConfig);
if (!result.success) {
setEnvError(result.error || 'Failed to save environment config');
}
} catch (err) {
setEnvError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsSavingEnv(false);
}
};
const handleClaudeSetup = async () => { const handleClaudeSetup = async () => {
setIsCheckingClaudeAuth(true); setIsCheckingClaudeAuth(true);
try { try {
@@ -324,6 +319,9 @@ export function useProjectSettings(
const envResult = await window.electronAPI.getProjectEnv(project.id); const envResult = await window.electronAPI.getProjectEnv(project.id);
if (envResult.success && envResult.data) { if (envResult.success && envResult.data) {
setEnvConfig(envResult.data); setEnvConfig(envResult.data);
committedEnvConfigRef.current = envResult.data;
// Update global store so Sidebar and other components reflect changes
setProjectEnvConfig(project.id, envResult.data);
} }
} }
} catch { } catch {
@@ -361,22 +359,42 @@ export function useProjectSettings(
}; };
const updateEnvConfig = async (updates: Partial<ProjectEnvConfig>) => { const updateEnvConfig = async (updates: Partial<ProjectEnvConfig>) => {
if (envConfig) { // Use the committed ref as base to handle concurrent calls correctly
const newConfig = { ...envConfig, ...updates }; // This ensures rapid updates don't lose changes due to stale state reads
const baseConfig = committedEnvConfigRef.current || envConfig;
if (!baseConfig) return;
// Save to backend FIRST so disk is updated before effects run const newConfig = { ...baseConfig, ...updates };
try {
const result = await window.electronAPI.updateProjectEnv(project.id, newConfig); // Update the ref BEFORE the await (optimistically) to prevent race conditions
if (!result.success) { // If two calls happen rapidly, the second will see the first's changes in the ref
console.error('[useProjectSettings] Failed to auto-save env config:', result.error); committedEnvConfigRef.current = newConfig;
}
} catch (err) { // Save to backend
console.error('[useProjectSettings] Error auto-saving env config:', err); try {
const result = await window.electronAPI.updateProjectEnv(project.id, newConfig);
if (!result.success) {
console.error('[useProjectSettings] Failed to auto-save env config:', result.error);
setEnvError(result.error || 'Failed to save environment config');
// Note: We don't rollback the ref here because another concurrent call may have
// already updated it. The error is shown to the user who can retry.
return;
} }
} catch (err) {
// Then update local state (triggers effects that read from disk) console.error('[useProjectSettings] Error auto-saving env config:', err);
setEnvConfig(newConfig); setEnvError(err instanceof Error ? err.message : 'Failed to save environment config');
// Note: We don't rollback the ref here for the same reason as above.
return;
} }
// Clear any previous error on successful save
setEnvError(null);
// Update local state after successful backend save
setEnvConfig(newConfig);
// Update the shared store so other components (like Sidebar) can react immediately
setProjectEnvConfig(project.id, newConfig);
}; };
return { return {
@@ -393,7 +411,6 @@ export function useProjectSettings(
isLoadingEnv, isLoadingEnv,
envError, envError,
setEnvError, setEnvError,
isSavingEnv,
updateEnvConfig, updateEnvConfig,
showClaudeToken, showClaudeToken,
setShowClaudeToken, setShowClaudeToken,
@@ -419,7 +436,6 @@ export function useProjectSettings(
linearConnectionStatus, linearConnectionStatus,
isCheckingLinear, isCheckingLinear,
handleInitialize, handleInitialize,
handleSaveEnv,
handleClaudeSetup, handleClaudeSetup,
handleSave handleSave
}; };
@@ -25,7 +25,6 @@ export function createHookProxy(
get isLoadingEnv() { return hookRef.current.isLoadingEnv; }, get isLoadingEnv() { return hookRef.current.isLoadingEnv; },
get envError() { return hookRef.current.envError; }, get envError() { return hookRef.current.envError; },
get setEnvError() { return hookRef.current.setEnvError; }, get setEnvError() { return hookRef.current.setEnvError; },
get isSavingEnv() { return hookRef.current.isSavingEnv; },
get updateEnvConfig() { return hookRef.current.updateEnvConfig; }, get updateEnvConfig() { return hookRef.current.updateEnvConfig; },
get showClaudeToken() { return hookRef.current.showClaudeToken; }, get showClaudeToken() { return hookRef.current.showClaudeToken; },
get setShowClaudeToken() { return hookRef.current.setShowClaudeToken; }, get setShowClaudeToken() { return hookRef.current.setShowClaudeToken; },
@@ -51,7 +50,6 @@ export function createHookProxy(
get linearConnectionStatus() { return hookRef.current.linearConnectionStatus; }, get linearConnectionStatus() { return hookRef.current.linearConnectionStatus; },
get isCheckingLinear() { return hookRef.current.isCheckingLinear; }, get isCheckingLinear() { return hookRef.current.isCheckingLinear; },
get handleInitialize() { return hookRef.current.handleInitialize; }, get handleInitialize() { return hookRef.current.handleInitialize; },
get handleSaveEnv() { return hookRef.current.handleSaveEnv; },
get handleClaudeSetup() { return hookRef.current.handleClaudeSetup; }, get handleClaudeSetup() { return hookRef.current.handleClaudeSetup; },
get handleSave() { return hookRef.current.handleSave; }, get handleSave() { return hookRef.current.handleSave; },
}; };
@@ -0,0 +1,137 @@
import { create } from 'zustand';
import type { ProjectEnvConfig } from '../../shared/types';
interface ProjectEnvState {
// State
envConfig: ProjectEnvConfig | null;
projectId: string | null;
isLoading: boolean;
error: string | null;
// Track the current pending request to handle race conditions
// Stored in state so it's properly reset on HMR and managed alongside other state
currentRequestId: number;
// Actions
setEnvConfig: (projectId: string | null, config: ProjectEnvConfig | null) => void;
setEnvConfigOnly: (projectId: string | null, config: ProjectEnvConfig | null) => void;
clearEnvConfig: () => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
incrementRequestId: () => number;
}
export const useProjectEnvStore = create<ProjectEnvState>((set, get) => ({
// Initial state
envConfig: null,
projectId: null,
isLoading: false,
error: null,
currentRequestId: 0,
// Actions
// setEnvConfig clears error - used for successful config loads
setEnvConfig: (projectId, envConfig) => set({
projectId,
envConfig,
error: null
}),
// setEnvConfigOnly updates config without touching error state - used in error cases
setEnvConfigOnly: (projectId, envConfig) => set({
projectId,
envConfig
}),
clearEnvConfig: () => set({
envConfig: null,
projectId: null,
error: null
}),
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }),
incrementRequestId: () => {
const newId = get().currentRequestId + 1;
set({ currentRequestId: newId });
return newId;
}
}));
/**
* Load project environment config from main process.
* Updates the store with the loaded config.
* Handles race conditions when called rapidly for different projects.
*/
export async function loadProjectEnvConfig(projectId: string): Promise<ProjectEnvConfig | null> {
// Get fresh store state for initial operations
const initialStore = useProjectEnvStore.getState();
// Increment request ID to track this specific request
const requestId = initialStore.incrementRequestId();
initialStore.setLoading(true);
initialStore.setError(null);
try {
const result = await window.electronAPI.getProjectEnv(projectId);
// Get fresh store state after async operation for consistency
const currentStore = useProjectEnvStore.getState();
// Check if this request is still the current one (handle race conditions)
if (requestId !== currentStore.currentRequestId) {
// A newer request was made, ignore this result
return null;
}
if (result.success && result.data) {
currentStore.setEnvConfig(projectId, result.data);
return result.data;
} else {
// Use setEnvConfigOnly to update config without clearing the error we're about to set
currentStore.setEnvConfigOnly(projectId, null);
currentStore.setError(result.error || 'Failed to load environment config');
return null;
}
} catch (error) {
// Get fresh store state after async operation
const currentStore = useProjectEnvStore.getState();
// Check if this request is still the current one
if (requestId !== currentStore.currentRequestId) {
return null;
}
// Use setEnvConfigOnly to update config without clearing the error we're about to set
currentStore.setEnvConfigOnly(projectId, null);
currentStore.setError(error instanceof Error ? error.message : 'Unknown error');
return null;
} finally {
// Get fresh store state for final loading state update
const finalStore = useProjectEnvStore.getState();
// Only update loading state if this is still the current request
if (requestId === finalStore.currentRequestId) {
finalStore.setLoading(false);
}
}
}
/**
* Set project env config directly (for use by useProjectSettings hook).
* This is a standalone function for use outside React components.
*/
export function setProjectEnvConfig(projectId: string, config: ProjectEnvConfig | null): void {
const store = useProjectEnvStore.getState();
store.setEnvConfig(projectId, config);
}
/**
* Clear the project env config (for use when switching projects or closing dialogs).
* This is a standalone function for use outside React components.
*/
export function clearProjectEnvConfig(): void {
const store = useProjectEnvStore.getState();
store.clearEnvConfig();
}