Refactor branch fetching to use repo IDs instead of project IDs (Vibe Kanban) (#1560)

* All tasks are complete. Here's a summary of the changes made:

## Summary

Successfully refactored the project branches API to fetch branches using repo IDs instead of project IDs.

### Files Created
- `frontend/src/hooks/useRepoBranches.ts` - New hook that fetches branches for a single repository using `repoApi.getBranches(repoId)`

### Files Modified

1. **`frontend/src/hooks/index.ts`** - Added export for `useRepoBranches`

2. **`frontend/src/components/dialogs/tasks/GitActionsDialog.tsx`**
   - Removed `useState` for `repoBranches` and `loadingBranches`
   - Removed `useEffect` that called `projectsApi.getBranches(effectiveProjectId)`
   - Removed `useMemo` that filtered branches by `selectedRepoId`
   - Now uses `useRepoBranches(selectedRepoId)` to fetch branches directly for the selected repo from the task attempt

3. **`frontend/src/components/dialogs/tasks/CreatePRDialog.tsx`**
   - Removed `projectId` from props interface and component destructuring
   - Removed `useState` for `branches` and `branchesLoading`
   - Removed branch-fetching logic from useEffect
   - Now uses `useRepoBranches(repoId)` to fetch branches directly for the specified repo
   - Split useEffect into two: one for form initialization, one for setting default base branch

4. **`frontend/src/components/tasks/Toolbar/GitOperations.tsx`**
   - Removed `projectId` from `CreatePRDialog.show()` call

### Benefits
- More efficient - fetches only branches for the specific repo needed instead of all project repos
- Better caching - React Query caches branches per repo for efficient reuse
- Cleaner code - removed manual filtering logic from components
- Type safety - hooks return `GitBranch[]` directly instead of `RepositoryBranches[]`

* All tasks complete. Here's a summary of the changes made:

## Summary

Successfully decoupled the branches API from project IDs.

### Changes Made

**1. `frontend/src/hooks/useBranches.ts`** - Refactored to accept `repos: Array<{ id: string; name: string }>` instead of `projectId`
- Now uses `useQueries` to fetch branches for multiple repos in parallel
- Reuses the same query keys as `useRepoBranches` for cache consistency

**2. `frontend/src/hooks/useRepoBranchSelection.ts`** - Refactored to accept `repos: Repo[]` instead of `projectId`
- Removed internal fetch of project repos (caller now provides them)
- Removed `projectRepos` from return value
- Uses refactored `useBranches(repos)`

**3. `frontend/src/components/dialogs/tasks/CreateAttemptDialog.tsx`**
- Added `useQuery` to fetch project repos separately
- Passes `repos` to `useRepoBranchSelection`

**4. `frontend/src/components/dialogs/tasks/TaskFormDialog.tsx`**
- Already fetched `projectRepos`
- Updated to pass `projectRepos` to refactored `useBranches`

**5. `frontend/src/components/layout/Navbar.tsx`**
- Replaced `useBranches(projectId)` with `useQuery` for `projectsApi.getRepositories`
- Only needed repo count, not branches

**6. `frontend/src/components/projects/ProjectCard.tsx`**
- Replaced `useBranches(project.id)` with `useQuery` for `projectsApi.getRepositories`
- Only needed repo count, not branches

### Benefits
- Branch hooks no longer require a project ID
- Repos can come from any source (project, attempt, or future sources without projects)
- Better separation of concerns
- Enables future task attempts without projects attached

* Cleanup script changes for task attempt 6b5b9208-565d-4620-9fe3-20791db77d7a

* The fix is already applied. Type checks pass with `Repo[]` instead of the loose `Array<{ id: string; name: string }>` type.

This makes it explicit that `useBranches` expects actual `Repo` objects (with `Repo.id` being the repository's UUID), not project-repo junction records.

* All done. The refactor is complete:

**Summary of changes:**

1. **Created `useProjectRepos` hook** (`frontend/src/hooks/useProjectRepos.ts`) - A reusable hook that fetches project repositories with proper query key management

2. **Updated 5 files** to use the new hook instead of duplicating the `useQuery` pattern:
   - `CreateAttemptDialog.tsx`
   - `TaskFormDialog.tsx`
   - `Navbar.tsx`
   - `ProjectCard.tsx`
   - `NoServerContent.tsx`

3. **Earlier refactors** (from before the summary):
   - `useBranches.ts` - Now accepts `repos: Repo[]` instead of `projectId`
   - `useRepoBranchSelection.ts` - Now accepts `repos: Repo[]` instead of `projectId`

This enables future task attempts without projects attached, as branch fetching is now decoupled from project IDs.

* No more usages. The branches in `ProjectTasks.tsx` are used for the diffs panel - showing available branches for git operations when viewing an attempt's diffs.

* Cleanup script changes for task attempt 6b5b9208-565d-4620-9fe3-20791db77d7a

* All done. The refactor is complete:

**Summary of changes:**

`GitOperations` now fetches its own branches using `useRepoBranches(selectedRepoId)` instead of receiving them as a prop. This:

1. Removes prop drilling through `ProjectTasks` → `DiffsPanelContainer` → `DiffsPanel` → `GitOperations`
2. Removes the need for `ProjectTasks` to fetch all project repos just to get branches for the selected repo
3. Makes `GitOperations` self-contained - it already has `selectedRepoId` from `useAttemptRepo`, so it can fetch its own branches
4. Simplifies `GitActionsDialog` which no longer needs to fetch branches either

* All done. Here's a summary of the changes:

**Removed `projectId` from GitOperations flow:**

1. **`useRebase.ts`** - Changed param from `projectId` to `repoId`, updated invalidation to use `repoBranchKeys.byRepo(repoId)`

2. **`useChangeTargetBranch.ts`** - Changed param from `projectId` to `repoId`, updated invalidation to use `repoBranchKeys.byRepo(repoId)`

3. **`useMerge.ts`** - Updated invalidation to use `repoBranchKeys.all` instead of `['projectBranches']`

4. **`useGitOperations.ts`** - Changed param from `projectId` to `repoId`

5. **`GitOperations.tsx`** - Removed `projectId` prop, now passes `selectedRepoId` to `useGitOperations`

6. **`GitActionsDialog.tsx`** - Removed `projectId` from props and internal logic

7. **`DiffsPanelContainer`** (in ProjectTasks.tsx) - Removed `projectId` prop

8. **Callers of `GitActionsDialog.show`** - Removed `projectId` from `NextActionCard.tsx` and `actions-dropdown.tsx`

* The cleanup is complete. Here's a summary of what was done:

**Removed dead project branches code:**

1. **Backend** (`crates/server/src/routes/projects.rs`):
   - Removed `get_project_branches` handler and route
   - Removed `RepositoryBranches` and `ProjectBranchesResponse` structs
   - Fixed unused `Serialize` import warning

2. **Type Generation** (`crates/server/src/bin/generate_types.rs`):
   - Removed exports for `ProjectBranchesResponse` and `RepositoryBranches`

3. **Frontend Hooks** (`frontend/src/hooks/useBranches.ts`):
   - Defined local `RepositoryBranches` type instead of importing from shared/types

4. **Frontend API** (`frontend/src/lib/api.ts`):
   - Removed `projectsApi.getBranches` method
   - Removed `RepositoryBranches` and `ProjectBranchesResponse` imports

Both TypeScript and Rust compilation pass without errors.

* Done. Removed the `RepositoryBranches` type and simplified `useBranches` to return `Map<string, GitBranch[]>`. Changes:

1. **`useBranches.ts`** - Returns `Map<string, GitBranch[]>` instead of `RepositoryBranches[]`
2. **`useRepoBranchSelection.ts`** - Uses `branchMap.get(repo.id) ?? []` instead of `.find()`
3. **`TaskFormDialog.tsx`** - Uses `branchMap.get(repo.id) ?? []` instead of `.find()`

Type checks pass.

* Done. Deleted `useBranches` entirely and consolidated everything into `useRepoBranchSelection`. Summary:

1. **Deleted** `frontend/src/hooks/useBranches.ts`
2. **Updated** `useRepoBranchSelection.ts` - inlined the `useQueries` logic directly
3. **Updated** `hooks/index.ts` - removed `useBranches` export
4. **Updated** `TaskFormDialog.tsx` - now uses `useRepoBranchSelection` instead of duplicating the config-building logic

Type checks pass.

* types
This commit is contained in:
Alex Netsch
2025-12-16 14:21:42 +00:00
committed by GitHub
parent ec8666da74
commit f989b47470
25 changed files with 137 additions and 309 deletions

View File

@@ -122,9 +122,8 @@ export function NextActionCard({
GitActionsDialog.show({
attemptId,
task,
projectId: project?.id,
});
}, [attemptId, task, project?.id]);
}, [attemptId, task]);
const handleRunSetup = useCallback(async () => {
if (!attemptId || !attempt) return;

View File

@@ -18,6 +18,7 @@ import {
useAttempt,
useRepoBranchSelection,
useTaskAttempts,
useProjectRepos,
} from '@/hooks';
import { useProject } from '@/contexts/ProjectContext';
import { useUserSystem } from '@/components/ConfigProvider';
@@ -66,17 +67,19 @@ const CreateAttemptDialogImpl = NiceModal.create<CreateAttemptDialogProps>(
{ enabled: modal.visible && !!parentAttemptId }
);
const { data: projectRepos = [], isLoading: isLoadingRepos } =
useProjectRepos(projectId, { enabled: modal.visible });
const {
configs: repoBranchConfigs,
projectRepos,
isLoading: isLoadingBranches,
setRepoBranch,
getAttemptRepoInputs,
reset: resetBranchSelection,
} = useRepoBranchSelection({
projectId,
repos: projectRepos,
initialBranch: parentAttempt?.branch,
enabled: modal.visible,
enabled: modal.visible && projectRepos.length > 0,
});
const latestAttempt = useMemo(() => {
@@ -118,6 +121,7 @@ const CreateAttemptDialogImpl = NiceModal.create<CreateAttemptDialogProps>(
const effectiveProfile = userSelectedProfile ?? defaultProfile;
const isLoadingInitial =
isLoadingRepos ||
isLoadingBranches ||
isLoadingAttempts ||
isLoadingTask ||

View File

@@ -17,16 +17,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { attemptsApi } from '@/lib/api.ts';
import { useTranslation } from 'react-i18next';
import {
GitBranch,
RepositoryBranches,
TaskAttempt,
TaskWithAttemptStatus,
} from 'shared/types';
import { projectsApi } from '@/lib/api.ts';
import { TaskAttempt, TaskWithAttemptStatus } from 'shared/types';
import { Loader2 } from 'lucide-react';
import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { useAuth } from '@/hooks';
import { useAuth, useRepoBranches } from '@/hooks';
import {
GhCliHelpInstructions,
GhCliSetupDialog,
@@ -43,12 +37,11 @@ import { defineModal } from '@/lib/modals';
interface CreatePRDialogProps {
attempt: TaskAttempt;
task: TaskWithAttemptStatus;
projectId: string;
repoId: string;
}
const CreatePRDialogImpl = NiceModal.create<CreatePRDialogProps>(
({ attempt, task, projectId, repoId }) => {
({ attempt, task, repoId }) => {
const modal = useModal();
const { t } = useTranslation('tasks');
const { isLoaded } = useAuth();
@@ -61,18 +54,22 @@ const CreatePRDialogImpl = NiceModal.create<CreatePRDialogProps>(
const [ghCliHelp, setGhCliHelp] = useState<GhCliSupportContent | null>(
null
);
const [branches, setBranches] = useState<GitBranch[]>([]);
const [branchesLoading, setBranchesLoading] = useState(false);
const [isDraft, setIsDraft] = useState(false);
const [autoGenerateDescription, setAutoGenerateDescription] = useState(
config?.pr_auto_description_enabled ?? false
);
const { data: branches = [], isLoading: branchesLoading } = useRepoBranches(
repoId,
{ enabled: modal.visible && !!repoId }
);
const getGhCliHelpTitle = (variant: GhCliSupportVariant) =>
variant === 'homebrew'
? 'Homebrew is required for automatic setup'
: 'GitHub CLI needs manual setup';
// Initialize form when dialog opens
useEffect(() => {
if (!modal.visible || !isLoaded) {
return;
@@ -80,32 +77,19 @@ const CreatePRDialogImpl = NiceModal.create<CreatePRDialogProps>(
setPrTitle(`${task.title} (vibe-kanban)`);
setPrBody(task.description || '');
// Always fetch branches for dropdown population
if (projectId) {
setBranchesLoading(true);
projectsApi
.getBranches(projectId)
.then((repoBranches: RepositoryBranches[]) => {
const repoData = repoBranches.find(
(r) => r.repository_id === repoId
);
const branchesForRepo = repoData?.branches ?? [];
setBranches(branchesForRepo);
// Set smart default: current branch (target_branch is now per-repo in AttemptRepo)
const currentBranch = branchesForRepo.find((b) => b.is_current);
if (currentBranch) {
setPrBaseBranch(currentBranch.name);
}
})
.catch(console.error)
.finally(() => setBranchesLoading(false));
}
setError(null); // Reset error when opening
setError(null);
setGhCliHelp(null);
}, [modal.visible, isLoaded, task, attempt, projectId, repoId]);
}, [modal.visible, isLoaded, task]);
// Set default base branch when branches are loaded
useEffect(() => {
if (branches.length > 0 && !prBaseBranch) {
const currentBranch = branches.find((b) => b.is_current);
if (currentBranch) {
setPrBaseBranch(currentBranch.name);
}
}
}, [branches, prBaseBranch]);
const isMacEnvironment = useMemo(
() => environment?.os_type?.toLowerCase().includes('mac'),
@@ -113,7 +97,7 @@ const CreatePRDialogImpl = NiceModal.create<CreatePRDialogProps>(
);
const handleConfirmCreatePR = useCallback(async () => {
if (!projectId || !attempt.id) return;
if (!repoId || !attempt.id) return;
setError(null);
setGhCliHelp(null);
@@ -227,7 +211,7 @@ const CreatePRDialogImpl = NiceModal.create<CreatePRDialogProps>(
}
}, [
attempt,
projectId,
repoId,
prBaseBranch,
prBody,
prTitle,
@@ -237,7 +221,6 @@ const CreatePRDialogImpl = NiceModal.create<CreatePRDialogProps>(
modal,
isMacEnvironment,
t,
repoId,
]);
const handleCancelCreatePR = useCallback(() => {

View File

@@ -1,4 +1,3 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ExternalLink, GitPullRequest } from 'lucide-react';
import {
@@ -12,41 +11,28 @@ import GitOperations from '@/components/tasks/Toolbar/GitOperations';
import { useTaskAttempt } from '@/hooks/useTaskAttempt';
import { useBranchStatus, useAttemptExecution } from '@/hooks';
import { useAttemptRepo } from '@/hooks/useAttemptRepo';
import { useProject } from '@/contexts/ProjectContext';
import { ExecutionProcessesProvider } from '@/contexts/ExecutionProcessesContext';
import {
GitOperationsProvider,
useGitOperationsError,
} from '@/contexts/GitOperationsContext';
import { projectsApi } from '@/lib/api';
import type {
GitBranch,
Merge,
RepositoryBranches,
TaskAttempt,
TaskWithAttemptStatus,
} from 'shared/types';
import type { Merge, TaskAttempt, TaskWithAttemptStatus } from 'shared/types';
import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
export interface GitActionsDialogProps {
attemptId: string;
task?: TaskWithAttemptStatus;
projectId?: string;
}
interface GitActionsDialogContentProps {
attempt: TaskAttempt;
task: TaskWithAttemptStatus;
projectId: string;
branches: GitBranch[];
}
function GitActionsDialogContent({
attempt,
task,
projectId,
branches,
}: GitActionsDialogContentProps) {
const { t } = useTranslation('tasks');
const { data: branchStatus } = useBranchStatus(attempt.id);
@@ -96,9 +82,7 @@ function GitActionsDialogContent({
<GitOperations
selectedAttempt={attempt}
task={task}
projectId={projectId}
branchStatus={branchStatus ?? null}
branches={branches}
isAttemptRunning={isAttemptRunning}
selectedBranch={getSelectedRepoStatus()?.target_branch_name ?? null}
layout="vertical"
@@ -108,34 +92,11 @@ function GitActionsDialogContent({
}
const GitActionsDialogImpl = NiceModal.create<GitActionsDialogProps>(
({ attemptId, task, projectId: providedProjectId }) => {
({ attemptId, task }) => {
const modal = useModal();
const { t } = useTranslation('tasks');
const { project } = useProject();
const effectiveProjectId = providedProjectId ?? project?.id;
const { data: attempt } = useTaskAttempt(attemptId);
const { selectedRepoId } = useAttemptRepo(attemptId);
const [repoBranches, setRepoBranches] = useState<RepositoryBranches[]>([]);
const [loadingBranches, setLoadingBranches] = useState(true);
useEffect(() => {
if (!effectiveProjectId) return;
setLoadingBranches(true);
projectsApi
.getBranches(effectiveProjectId)
.then(setRepoBranches)
.catch(() => setRepoBranches([]))
.finally(() => setLoadingBranches(false));
}, [effectiveProjectId]);
const branches = useMemo(
() =>
repoBranches.find((r) => r.repository_id === selectedRepoId)
?.branches ?? [],
[repoBranches, selectedRepoId]
);
const handleOpenChange = (open: boolean) => {
if (!open) {
@@ -143,8 +104,7 @@ const GitActionsDialogImpl = NiceModal.create<GitActionsDialogProps>(
}
};
const isLoading =
!attempt || !effectiveProjectId || loadingBranches || !task;
const isLoading = !attempt || !task;
return (
<Dialog open={modal.visible} onOpenChange={handleOpenChange}>
@@ -163,12 +123,7 @@ const GitActionsDialogImpl = NiceModal.create<GitActionsDialogProps>(
key={attempt.id}
attemptId={attempt.id}
>
<GitActionsDialogContent
attempt={attempt}
task={task}
projectId={effectiveProjectId}
branches={branches}
/>
<GitActionsDialogContent attempt={attempt} task={task} />
</ExecutionProcessesProvider>
</GitOperationsProvider>
)}

View File

@@ -31,11 +31,11 @@ import RepoBranchSelector from '@/components/tasks/RepoBranchSelector';
import { ExecutorProfileSelector } from '@/components/settings';
import { useUserSystem } from '@/components/ConfigProvider';
import {
useBranches,
useTaskImages,
useImageUpload,
useTaskMutations,
type RepoBranchConfig,
useProjectRepos,
useRepoBranchSelection,
} from '@/hooks';
import {
useKeySubmitTask,
@@ -50,8 +50,6 @@ import type {
ExecutorProfileId,
ImageResponse,
} from 'shared/types';
import { projectsApi } from '@/lib/api';
import { useQuery } from '@tanstack/react-query';
interface Task {
id: string;
@@ -104,43 +102,20 @@ const TaskFormDialogImpl = NiceModal.create<TaskFormDialogProps>((props) => {
const [showDiscardWarning, setShowDiscardWarning] = useState(false);
const forceCreateOnlyRef = useRef(false);
const { data: repoBranches, isLoading: branchesLoading } =
useBranches(projectId);
const { data: taskImages } = useTaskImages(
editMode ? props.task.id : undefined
);
const { data: projectRepos = [] } = useQuery({
queryKey: ['projectRepositories', projectId],
queryFn: () => projectsApi.getRepositories(projectId),
const { data: projectRepos = [] } = useProjectRepos(projectId, {
enabled: modal.visible,
});
const repoBranchConfigs = useMemo((): RepoBranchConfig[] => {
return projectRepos.map((repo) => {
const repoBranchData = repoBranches?.find(
(rb) => rb.repository_id === repo.id
);
const branches = repoBranchData?.branches ?? [];
let targetBranch: string | null = null;
const initialBranch =
mode === 'subtask' ? props.initialBaseBranch : undefined;
if (initialBranch && branches.some((b) => b.name === initialBranch)) {
targetBranch = initialBranch;
} else {
const currentBranch = branches.find((b) => b.is_current);
targetBranch = currentBranch?.name ?? branches[0]?.name ?? null;
}
return {
repoId: repo.id,
repoDisplayName: repo.display_name,
targetBranch,
branches,
};
const initialBranch =
mode === 'subtask' ? props.initialBaseBranch : undefined;
const { configs: repoBranchConfigs, isLoading: branchesLoading } =
useRepoBranchSelection({
repos: projectRepos,
initialBranch,
enabled: modal.visible && projectRepos.length > 0,
});
}, [projectRepos, repoBranches, mode, props]);
const defaultRepoBranches = useMemo((): RepoBranch[] => {
return repoBranchConfigs

View File

@@ -27,7 +27,7 @@ import { openTaskForm } from '@/lib/openTaskForm';
import { useProject } from '@/contexts/ProjectContext';
import { useOpenProjectInEditor } from '@/hooks/useOpenProjectInEditor';
import { OpenInIdeButton } from '@/components/ide/OpenInIdeButton';
import { useBranches } from '@/hooks/useBranches';
import { useProjectRepos } from '@/hooks';
import { useDiscordOnlineCount } from '@/hooks/useDiscordOnlineCount';
import { useTranslation } from 'react-i18next';
import { Switch } from '@/components/ui/switch';
@@ -80,7 +80,7 @@ export function Navbar() {
const { data: onlineCount } = useDiscordOnlineCount();
const { loginStatus, reloadSystem } = useUserSystem();
const { data: repos } = useBranches(projectId);
const { data: repos } = useProjectRepos(projectId);
const isSingleRepoProject = repos?.length === 1;
const setSearchBarRef = useCallback(

View File

@@ -24,12 +24,11 @@ import {
import { Project } from 'shared/types';
import { useEffect, useRef } from 'react';
import { useOpenProjectInEditor } from '@/hooks/useOpenProjectInEditor';
import { useNavigateWithSearch } from '@/hooks';
import { useNavigateWithSearch, useProjectRepos } from '@/hooks';
import { projectsApi } from '@/lib/api';
import { LinkProjectDialog } from '@/components/dialogs/projects/LinkProjectDialog';
import { useTranslation } from 'react-i18next';
import { useProjectMutations } from '@/hooks/useProjectMutations';
import { useBranches } from '@/hooks/useBranches';
type Props = {
project: Project;
@@ -51,7 +50,7 @@ function ProjectCard({
const handleOpenInEditor = useOpenProjectInEditor(project);
const { t } = useTranslation('projects');
const { data: repos } = useBranches(project.id);
const { data: repos } = useProjectRepos(project.id);
const isSingleRepoProject = repos?.length === 1;
const { unlinkProject } = useProjectMutations({

View File

@@ -1,6 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import {
Play,
Edit3,
@@ -21,7 +20,7 @@ import {
import { useUserSystem } from '@/components/ConfigProvider';
import { useTaskMutations } from '@/hooks/useTaskMutations';
import { useProjectMutations } from '@/hooks/useProjectMutations';
import { projectsApi } from '@/lib/api';
import { useProjectRepos } from '@/hooks';
import {
COMPANION_INSTALL_TASK_TITLE,
COMPANION_INSTALL_TASK_DESCRIPTION,
@@ -53,14 +52,7 @@ export function NoServerContent({
const { createAndStart } = useTaskMutations(project?.id);
const { updateProject } = useProjectMutations();
const { data: projectRepos = [] } = useQuery({
queryKey: ['projectRepositories', project?.id],
queryFn: () =>
project?.id
? projectsApi.getRepositories(project.id)
: Promise.resolve([]),
enabled: !!project?.id,
});
const { data: projectRepos = [] } = useProjectRepos(project?.id);
// Create strategy-based placeholders
const placeholders = system.environment

View File

@@ -19,7 +19,6 @@ import { useCallback, useMemo, useState } from 'react';
import type {
RepoBranchStatus,
Merge,
GitBranch,
TaskAttempt,
TaskWithAttemptStatus,
} from 'shared/types';
@@ -30,13 +29,12 @@ import { CreatePRDialog } from '@/components/dialogs/tasks/CreatePRDialog';
import { useTranslation } from 'react-i18next';
import { useAttemptRepo } from '@/hooks/useAttemptRepo';
import { useGitOperations } from '@/hooks/useGitOperations';
import { useRepoBranches } from '@/hooks';
interface GitOperationsProps {
selectedAttempt: TaskAttempt;
task: TaskWithAttemptStatus;
projectId: string;
branchStatus: RepoBranchStatus[] | null;
branches: GitBranch[];
isAttemptRunning: boolean;
selectedBranch: string | null;
layout?: 'horizontal' | 'vertical';
@@ -47,19 +45,18 @@ export type GitOperationsInputs = Omit<GitOperationsProps, 'selectedAttempt'>;
function GitOperations({
selectedAttempt,
task,
projectId,
branchStatus,
branches,
isAttemptRunning,
selectedBranch,
layout = 'horizontal',
}: GitOperationsProps) {
const { t } = useTranslation('tasks');
const git = useGitOperations(selectedAttempt.id, projectId);
const { repos, selectedRepoId, setSelectedRepoId } = useAttemptRepo(
selectedAttempt.id
);
const git = useGitOperations(selectedAttempt.id, selectedRepoId ?? undefined);
const { data: branches = [] } = useRepoBranches(selectedRepoId);
const isChangingTargetBranch = git.states.changeTargetBranchPending;
// Local state for git operations
@@ -256,7 +253,6 @@ function GitOperations({
CreatePRDialog.show({
attempt: selectedAttempt,
task,
projectId,
repoId: getSelectedRepoId(),
});
};

View File

@@ -128,7 +128,6 @@ export function ActionsDropdown({
GitActionsDialog.show({
attemptId: attempt.id,
task,
projectId,
});
};

View File

@@ -16,7 +16,8 @@ export { useNavigateWithSearch } from './useNavigateWithSearch';
export { useGitOperations } from './useGitOperations';
export { useTask } from './useTask';
export { useAttempt } from './useAttempt';
export { useBranches } from './useBranches';
export { useRepoBranches } from './useRepoBranches';
export { useProjectRepos } from './useProjectRepos';
export { useRepoBranchSelection } from './useRepoBranchSelection';
export type { RepoBranchConfig } from './useRepoBranchSelection';
export { useTaskAttempts } from './useTaskAttempts';

View File

@@ -1,25 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { projectsApi } from '@/lib/api';
import type { RepositoryBranches } from 'shared/types';
export const branchKeys = {
all: ['projectBranches'] as const,
byProject: (projectId: string | undefined) =>
['projectBranches', projectId] as const,
};
type Options = {
enabled?: boolean;
};
export function useBranches(projectId?: string, opts?: Options) {
const enabled = (opts?.enabled ?? true) && !!projectId;
return useQuery<RepositoryBranches[]>({
queryKey: branchKeys.byProject(projectId),
queryFn: () => projectsApi.getBranches(projectId!),
enabled,
staleTime: 60_000,
refetchOnWindowFocus: true,
});
}

View File

@@ -4,6 +4,7 @@ import type {
ChangeTargetBranchRequest,
ChangeTargetBranchResponse,
} from 'shared/types';
import { repoBranchKeys } from './useRepoBranches';
type ChangeTargetBranchParams = {
newTargetBranch: string;
@@ -12,7 +13,7 @@ type ChangeTargetBranchParams = {
export function useChangeTargetBranch(
attemptId: string | undefined,
projectId: string | undefined,
repoId: string | undefined,
onSuccess?: (data: ChangeTargetBranchResponse) => void,
onError?: (err: unknown) => void
) {
@@ -45,9 +46,9 @@ export function useChangeTargetBranch(
});
}
if (projectId) {
if (repoId) {
queryClient.invalidateQueries({
queryKey: ['projectBranches', projectId],
queryKey: repoBranchKeys.byRepo(repoId),
});
}

View File

@@ -10,13 +10,13 @@ import { ForcePushDialog } from '@/components/dialogs/git/ForcePushDialog';
export function useGitOperations(
attemptId: string | undefined,
projectId: string | undefined
repoId: string | undefined
) {
const { setError } = useGitOperationsError();
const rebase = useRebase(
attemptId,
projectId,
repoId,
() => setError(null),
(err: Result<void, GitOperationError>) => {
if (!err.success) {
@@ -78,7 +78,7 @@ export function useGitOperations(
const changeTargetBranch = useChangeTargetBranch(
attemptId,
projectId,
repoId,
() => setError(null),
(err: unknown) => {
const message =

View File

@@ -1,5 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { attemptsApi } from '@/lib/api';
import { repoBranchKeys } from './useRepoBranches';
type MergeParams = {
repoId: string;
@@ -23,8 +24,8 @@ export function useMerge(
// Refresh attempt-specific branch information
queryClient.invalidateQueries({ queryKey: ['branchStatus', attemptId] });
// Invalidate all project branches queries
queryClient.invalidateQueries({ queryKey: ['projectBranches'] });
// Invalidate all repo branches queries
queryClient.invalidateQueries({ queryKey: repoBranchKeys.all });
onSuccess?.();
},

View File

@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { projectsApi } from '@/lib/api';
import type { Repo } from 'shared/types';
type Options = {
enabled?: boolean;
};
export function useProjectRepos(projectId?: string, opts?: Options) {
const enabled = (opts?.enabled ?? true) && !!projectId;
return useQuery<Repo[]>({
queryKey: ['projectRepositories', projectId],
queryFn: () => projectsApi.getRepositories(projectId!),
enabled,
});
}

View File

@@ -2,10 +2,11 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { attemptsApi, Result } from '@/lib/api';
import type { RebaseTaskAttemptRequest } from 'shared/types';
import type { GitOperationError } from 'shared/types';
import { repoBranchKeys } from './useRepoBranches';
export function useRebase(
attemptId: string | undefined,
projectId: string | undefined,
repoId: string | undefined,
onSuccess?: () => void,
onError?: (err: Result<void, GitOperationError>) => void
) {
@@ -47,10 +48,10 @@ export function useRebase(
queryKey: ['taskAttempt', attemptId],
});
// Refresh branch list used by PR dialog
if (projectId) {
// Refresh branch list
if (repoId) {
queryClient.invalidateQueries({
queryKey: ['projectBranches', projectId],
queryKey: repoBranchKeys.byRepo(repoId),
});
}

View File

@@ -1,8 +1,8 @@
import { useState, useMemo, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useBranches } from './useBranches';
import { projectsApi } from '@/lib/api';
import type { GitBranch, Repo, RepositoryBranches } from 'shared/types';
import { useQueries } from '@tanstack/react-query';
import { repoApi } from '@/lib/api';
import { repoBranchKeys } from './useRepoBranches';
import type { GitBranch, Repo } from 'shared/types';
export type RepoBranchConfig = {
repoId: string;
@@ -12,15 +12,13 @@ export type RepoBranchConfig = {
};
type UseRepoBranchSelectionOptions = {
projectId: string | undefined;
repos: Repo[];
initialBranch?: string | null;
enabled?: boolean;
};
type UseRepoBranchSelectionReturn = {
configs: RepoBranchConfig[];
repositoryBranches: RepositoryBranches[];
projectRepos: Repo[];
isLoading: boolean;
setRepoBranch: (repoId: string, branch: string) => void;
getAttemptRepoInputs: () => Array<{ repo_id: string; target_branch: string }>;
@@ -28,7 +26,7 @@ type UseRepoBranchSelectionReturn = {
};
export function useRepoBranchSelection({
projectId,
repos,
initialBranch,
enabled = true,
}: UseRepoBranchSelectionOptions): UseRepoBranchSelectionReturn {
@@ -36,22 +34,20 @@ export function useRepoBranchSelection({
Record<string, string | null>
>({});
const { data: repositoryBranches = [], isLoading: isLoadingBranches } =
useBranches(projectId, { enabled: enabled && !!projectId });
const { data: projectRepos = [], isLoading: isLoadingRepos } = useQuery({
queryKey: ['projectRepositories', projectId],
queryFn: () =>
projectId ? projectsApi.getRepositories(projectId) : Promise.resolve([]),
enabled: enabled && !!projectId,
const queries = useQueries({
queries: repos.map((repo) => ({
queryKey: repoBranchKeys.byRepo(repo.id),
queryFn: () => repoApi.getBranches(repo.id),
enabled,
staleTime: 60_000,
})),
});
const isLoadingBranches = queries.some((q) => q.isLoading);
const configs = useMemo((): RepoBranchConfig[] => {
return projectRepos.map((repo) => {
const repoBranchData = repositoryBranches.find(
(rb) => rb.repository_id === repo.id
);
const branches = repoBranchData?.branches ?? [];
return repos.map((repo, i) => {
const branches = queries[i]?.data ?? [];
let targetBranch: string | null = userOverrides[repo.id] ?? null;
@@ -71,7 +67,7 @@ export function useRepoBranchSelection({
branches,
};
});
}, [projectRepos, repositoryBranches, userOverrides, initialBranch]);
}, [repos, queries, userOverrides, initialBranch]);
const setRepoBranch = useCallback((repoId: string, branch: string) => {
setUserOverrides((prev) => ({
@@ -95,9 +91,7 @@ export function useRepoBranchSelection({
return {
configs,
repositoryBranches,
projectRepos,
isLoading: isLoadingBranches || isLoadingRepos,
isLoading: isLoadingBranches,
setRepoBranch,
getAttemptRepoInputs,
reset,

View File

@@ -0,0 +1,24 @@
import { useQuery } from '@tanstack/react-query';
import { repoApi } from '@/lib/api';
import type { GitBranch } from 'shared/types';
export const repoBranchKeys = {
all: ['repoBranches'] as const,
byRepo: (repoId: string | undefined) => ['repoBranches', repoId] as const,
};
type Options = {
enabled?: boolean;
};
export function useRepoBranches(repoId?: string | null, opts?: Options) {
const enabled = (opts?.enabled ?? true) && !!repoId;
return useQuery<GitBranch[]>({
queryKey: repoBranchKeys.byRepo(repoId ?? undefined),
queryFn: () => repoApi.getBranches(repoId!),
enabled,
staleTime: 60_000,
refetchOnWindowFocus: true,
});
}

View File

@@ -20,8 +20,6 @@ import {
ProjectRepo,
Repo,
RepoWithTargetBranch,
RepositoryBranches,
ProjectBranchesResponse,
CreateProject,
CreateProjectRepo,
UpdateProjectRepo,
@@ -288,12 +286,6 @@ export const projectsApi = {
return handleApiResponse<OpenEditorResponse>(response);
},
getBranches: async (id: string): Promise<RepositoryBranches[]> => {
const response = await makeRequest(`/api/projects/${id}/branches`);
const data = await handleApiResponse<ProjectBranchesResponse>(response);
return data.repositories;
},
searchFiles: async (
id: string,
query: string,

View File

@@ -6,12 +6,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { AlertTriangle, Plus, X } from 'lucide-react';
import { Loader } from '@/components/ui/loader';
import { tasksApi } from '@/lib/api';
import type {
GitBranch,
TaskAttempt,
RepoBranchStatus,
RepositoryBranches,
} from 'shared/types';
import type { TaskAttempt, RepoBranchStatus } from 'shared/types';
import { openTaskForm } from '@/lib/openTaskForm';
import { FeatureShowcaseDialog } from '@/components/dialogs/global/FeatureShowcaseDialog';
import { showcases } from '@/config/showcases';
@@ -24,8 +19,6 @@ import { useTaskAttempts } from '@/hooks/useTaskAttempts';
import { useTaskAttempt } from '@/hooks/useTaskAttempt';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useBranchStatus, useAttemptExecution } from '@/hooks';
import { useAttemptRepo } from '@/hooks/useAttemptRepo';
import { projectsApi } from '@/lib/api';
import { paths } from '@/lib/paths';
import { ExecutionProcessesProvider } from '@/contexts/ExecutionProcessesContext';
import { ClickedElementsProvider } from '@/contexts/ClickedElementsProvider';
@@ -108,15 +101,11 @@ function GitErrorBanner() {
function DiffsPanelContainer({
attempt,
selectedTask,
projectId,
branchStatus,
branches,
}: {
attempt: TaskAttempt | null;
selectedTask: TaskWithAttemptStatus | null;
projectId: string;
branchStatus: RepoBranchStatus[] | null;
branches: GitBranch[];
}) {
const { isAttemptRunning } = useAttemptExecution(attempt?.id);
@@ -127,9 +116,7 @@ function DiffsPanelContainer({
attempt && selectedTask
? {
task: selectedTask,
projectId,
branchStatus: branchStatus ?? null,
branches,
isAttemptRunning,
selectedBranch: branchStatus?.[0]?.target_branch_name ?? null,
}
@@ -297,23 +284,6 @@ export function ProjectTasks() {
const { data: attempt } = useTaskAttempt(effectiveAttemptId);
const { data: branchStatus } = useBranchStatus(attempt?.id);
const { selectedRepoId } = useAttemptRepo(attempt?.id);
const [repoBranches, setRepoBranches] = useState<RepositoryBranches[]>([]);
useEffect(() => {
if (!projectId) return;
projectsApi
.getBranches(projectId)
.then(setRepoBranches)
.catch(() => setRepoBranches([]));
}, [projectId]);
const branches = useMemo(
() =>
repoBranches.find((r) => r.repository_id === selectedRepoId)?.branches ??
[],
[repoBranches, selectedRepoId]
);
const rawMode = searchParams.get('view') as LayoutMode;
const mode: LayoutMode =
@@ -1028,9 +998,7 @@ export function ProjectTasks() {
<DiffsPanelContainer
attempt={attempt}
selectedTask={selectedTask}
projectId={projectId!}
branchStatus={branchStatus ?? null}
branches={branches}
/>
)}
</div>

View File

@@ -30,7 +30,7 @@ import { CopyFilesField } from '@/components/projects/CopyFilesField';
import { AutoExpandingTextarea } from '@/components/ui/auto-expanding-textarea';
import { RepoPickerDialog } from '@/components/dialogs/shared/RepoPickerDialog';
import { projectsApi } from '@/lib/api';
import { branchKeys } from '@/hooks/useBranches';
import { repoBranchKeys } from '@/hooks/useRepoBranches';
import type { Project, ProjectRepo, Repo, UpdateProject } from 'shared/types';
interface ProjectFormState {
@@ -330,7 +330,7 @@ export function ProjectSettings() {
queryKey: ['projectRepositories', selectedProjectId],
});
queryClient.invalidateQueries({
queryKey: branchKeys.byProject(selectedProjectId),
queryKey: repoBranchKeys.byRepo(newRepo.id),
});
} catch (err) {
setRepoError(
@@ -353,7 +353,7 @@ export function ProjectSettings() {
queryKey: ['projectRepositories', selectedProjectId],
});
queryClient.invalidateQueries({
queryKey: branchKeys.byProject(selectedProjectId),
queryKey: repoBranchKeys.byRepo(repoId),
});
} catch (err) {
setRepoError(