feat: support multi-run for non-Git projects
Creates non-Git multi-runs in the same project directory Shows concise footer info when worktree isolation is unavailable Displays default model variants with a friendly label
This commit is contained in:
@@ -9,7 +9,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { useGitStore, useGitBranches, useGitLoadingBranches } from '@/stores/useGitStore';
|
import { useGitStore, useGitBranches, useGitLoadingBranches, useGitLoadingStatus, useIsGitRepo } from '@/stores/useGitStore';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -53,15 +53,26 @@ export interface BranchSelectorState {
|
|||||||
export function useBranchOptions(directory: string | null): BranchSelectorState {
|
export function useBranchOptions(directory: string | null): BranchSelectorState {
|
||||||
const { git } = useRuntimeAPIs();
|
const { git } = useRuntimeAPIs();
|
||||||
const branches = useGitBranches(directory);
|
const branches = useGitBranches(directory);
|
||||||
const isLoading = useGitLoadingBranches(directory);
|
const isGitRepo = useIsGitRepo(directory);
|
||||||
|
const isLoadingStatus = useGitLoadingStatus(directory);
|
||||||
|
const isLoadingBranches = useGitLoadingBranches(directory);
|
||||||
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
||||||
|
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!directory || !git || isGitRepo !== null || isLoadingStatus) return;
|
||||||
|
void fetchStatus(directory, git, { silent: true });
|
||||||
|
}, [directory, git, fetchStatus, isGitRepo, isLoadingStatus]);
|
||||||
|
|
||||||
// Fetch branches if not cached
|
// Fetch branches if not cached
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!directory || !git) return;
|
if (!directory || !git) return;
|
||||||
|
if (isGitRepo !== true) return;
|
||||||
if (branches?.all) return; // Already cached
|
if (branches?.all) return; // Already cached
|
||||||
void fetchBranches(directory, git);
|
void fetchBranches(directory, git);
|
||||||
}, [directory, git, branches?.all, fetchBranches]);
|
}, [directory, git, isGitRepo, branches?.all, fetchBranches]);
|
||||||
|
|
||||||
|
const isLoading = isLoadingStatus || (isGitRepo === true && isLoadingBranches);
|
||||||
|
|
||||||
// Compute local and remote branch lists (same as NewWorktreeDialog)
|
// Compute local and remote branch lists (same as NewWorktreeDialog)
|
||||||
const localBranches = React.useMemo(() => {
|
const localBranches = React.useMemo(() => {
|
||||||
@@ -82,10 +93,10 @@ export function useBranchOptions(directory: string | null): BranchSelectorState
|
|||||||
// isGitRepository: true if we got branches, false if fetch returned empty, null if not yet loaded
|
// isGitRepository: true if we got branches, false if fetch returned empty, null if not yet loaded
|
||||||
const isGitRepository = React.useMemo<boolean | null>(() => {
|
const isGitRepository = React.useMemo<boolean | null>(() => {
|
||||||
if (!directory) return null;
|
if (!directory) return null;
|
||||||
|
if (isGitRepo !== null) return isGitRepo;
|
||||||
if (isLoading) return null;
|
if (isLoading) return null;
|
||||||
if (!branches) return null;
|
return branches?.all ? true : null;
|
||||||
return Boolean(branches.all);
|
}, [directory, isLoading, branches, isGitRepo]);
|
||||||
}, [directory, isLoading, branches]);
|
|
||||||
|
|
||||||
return { localBranches, remoteBranches, isLoading, isGitRepository };
|
return { localBranches, remoteBranches, isLoading, isGitRepository };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -560,7 +560,11 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
|
|||||||
variantValue === DEFAULT_VARIANT_VALUE ? 'text-muted-foreground' : 'text-[color:var(--status-info)]'
|
variantValue === DEFAULT_VARIANT_VALUE ? 'text-muted-foreground' : 'text-[color:var(--status-info)]'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SelectValue placeholder={t('multirun.modelMultiSelect.variant.placeholder')} />
|
<SelectValue placeholder={t('multirun.modelMultiSelect.variant.placeholder')}>
|
||||||
|
{(value) => value === DEFAULT_VARIANT_VALUE
|
||||||
|
? t('multirun.modelMultiSelect.variant.default')
|
||||||
|
: value}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent fitContent>
|
<SelectContent fitContent>
|
||||||
<SelectItem value={DEFAULT_VARIANT_VALUE} className="pr-2 [&>span:first-child]:hidden">
|
<SelectItem value={DEFAULT_VARIANT_VALUE} className="pr-2 [&>span:first-child]:hidden">
|
||||||
|
|||||||
@@ -587,7 +587,12 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isValid = Boolean(
|
const isValid = Boolean(
|
||||||
name.trim() && prompt.trim() && selectedModels.length >= 2 && worktreeBaseBranch && isGitRepository && !isLoadingWorktreeBaseBranches
|
name.trim() &&
|
||||||
|
prompt.trim() &&
|
||||||
|
selectedModels.length >= 2 &&
|
||||||
|
selectedProjectDirectory &&
|
||||||
|
!isLoadingWorktreeBaseBranches &&
|
||||||
|
(isGitRepository === false || (isGitRepository === true && worktreeBaseBranch))
|
||||||
);
|
);
|
||||||
|
|
||||||
const configuredSetupCount = setupCommands.filter(cmd => cmd.trim()).length;
|
const configuredSetupCount = setupCommands.filter(cmd => cmd.trim()).length;
|
||||||
@@ -929,6 +934,12 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
{/* ── Fixed footer ── */}
|
{/* ── Fixed footer ── */}
|
||||||
<div className="shrink-0 px-4 sm:px-6 py-3">
|
<div className="shrink-0 px-4 sm:px-6 py-3">
|
||||||
<div className="mx-auto w-full max-w-2xl flex items-center justify-end gap-2">
|
<div className="mx-auto w-full max-w-2xl flex items-center justify-end gap-2">
|
||||||
|
{isGitRepository === false ? (
|
||||||
|
<div className="mr-auto flex min-w-0 items-center gap-1.5 typography-micro text-muted-foreground">
|
||||||
|
<Icon name="information" className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{t('multirun.launcher.project.gitRequired')}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export const dict = {
|
|||||||
'multirun.launcher.project.label': 'Project',
|
'multirun.launcher.project.label': 'Project',
|
||||||
'multirun.launcher.project.placeholder': 'Select project',
|
'multirun.launcher.project.placeholder': 'Select project',
|
||||||
'multirun.launcher.project.empty': 'Add a project first.',
|
'multirun.launcher.project.empty': 'Add a project first.',
|
||||||
|
'multirun.launcher.project.gitRequired': 'No worktree isolation: runs use the same directory.',
|
||||||
'multirun.launcher.groupName.label': 'Group name',
|
'multirun.launcher.groupName.label': 'Group name',
|
||||||
'multirun.launcher.groupName.info': 'Used for worktree directory and branch names',
|
'multirun.launcher.groupName.info': 'Used for worktree directory and branch names',
|
||||||
'multirun.launcher.groupName.placeholder': 'feature-auth, bugfix-login',
|
'multirun.launcher.groupName.placeholder': 'feature-auth, bugfix-login',
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"multirun.launcher.project.label": "Proyecto",
|
"multirun.launcher.project.label": "Proyecto",
|
||||||
"multirun.launcher.project.placeholder": "Seleccionar proyecto",
|
"multirun.launcher.project.placeholder": "Seleccionar proyecto",
|
||||||
"multirun.launcher.project.empty": "Añade un proyecto primero.",
|
"multirun.launcher.project.empty": "Añade un proyecto primero.",
|
||||||
|
"multirun.launcher.project.gitRequired": "Sin aislamiento worktree: las ejecuciones usan el mismo directorio.",
|
||||||
"multirun.launcher.groupName.label": "Nombre del grupo",
|
"multirun.launcher.groupName.label": "Nombre del grupo",
|
||||||
"multirun.launcher.groupName.info": "Usado para el directorio de worktree y nombres de rama",
|
"multirun.launcher.groupName.info": "Usado para el directorio de worktree y nombres de rama",
|
||||||
"multirun.launcher.groupName.placeholder": "feature-auth, bugfix-login",
|
"multirun.launcher.groupName.placeholder": "feature-auth, bugfix-login",
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'multirun.launcher.project.label': '프로젝트',
|
'multirun.launcher.project.label': '프로젝트',
|
||||||
'multirun.launcher.project.placeholder': '프로젝트 선택',
|
'multirun.launcher.project.placeholder': '프로젝트 선택',
|
||||||
'multirun.launcher.project.empty': '먼저 프로젝트를 추가하세요.',
|
'multirun.launcher.project.empty': '먼저 프로젝트를 추가하세요.',
|
||||||
|
'multirun.launcher.project.gitRequired': 'worktree 격리 없음: 실행은 같은 디렉터리를 사용합니다.',
|
||||||
'multirun.launcher.groupName.label': '그룹 이름',
|
'multirun.launcher.groupName.label': '그룹 이름',
|
||||||
'multirun.launcher.groupName.info': '워크트리 디렉터리와 브랜치 이름에 사용됩니다',
|
'multirun.launcher.groupName.info': '워크트리 디렉터리와 브랜치 이름에 사용됩니다',
|
||||||
'multirun.launcher.groupName.placeholder': 'feature-auth, bugfix-login',
|
'multirun.launcher.groupName.placeholder': 'feature-auth, bugfix-login',
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'multirun.launcher.project.label': 'Projekt',
|
'multirun.launcher.project.label': 'Projekt',
|
||||||
'multirun.launcher.project.placeholder': 'Wybierz projekt',
|
'multirun.launcher.project.placeholder': 'Wybierz projekt',
|
||||||
'multirun.launcher.project.empty': 'Najpierw dodaj projekt.',
|
'multirun.launcher.project.empty': 'Najpierw dodaj projekt.',
|
||||||
|
'multirun.launcher.project.gitRequired': 'Bez izolacji worktree: uruchomienia używają tego samego katalogu.',
|
||||||
'multirun.launcher.groupName.label': 'Nazwa grupy',
|
'multirun.launcher.groupName.label': 'Nazwa grupy',
|
||||||
'multirun.launcher.groupName.info': 'Używane dla katalogu drzewa pracy i nazw gałęzi',
|
'multirun.launcher.groupName.info': 'Używane dla katalogu drzewa pracy i nazw gałęzi',
|
||||||
'multirun.launcher.groupName.placeholder': 'feature-auth, bugfix-login',
|
'multirun.launcher.groupName.placeholder': 'feature-auth, bugfix-login',
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"multirun.launcher.project.label": "Projeto",
|
"multirun.launcher.project.label": "Projeto",
|
||||||
"multirun.launcher.project.placeholder": "Selecionar projeto",
|
"multirun.launcher.project.placeholder": "Selecionar projeto",
|
||||||
"multirun.launcher.project.empty": "Adicione um projeto primeiro.",
|
"multirun.launcher.project.empty": "Adicione um projeto primeiro.",
|
||||||
|
"multirun.launcher.project.gitRequired": "Sem isolamento por worktree: as execuções usam o mesmo diretório.",
|
||||||
"multirun.launcher.groupName.label": "Nome do grupo",
|
"multirun.launcher.groupName.label": "Nome do grupo",
|
||||||
"multirun.launcher.groupName.info": "Usado para o diretório de worktree e nomes de branch",
|
"multirun.launcher.groupName.info": "Usado para o diretório de worktree e nomes de branch",
|
||||||
"multirun.launcher.groupName.placeholder": "feature-auth, bugfix-login",
|
"multirun.launcher.groupName.placeholder": "feature-auth, bugfix-login",
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"multirun.launcher.project.label": "Проєкт",
|
"multirun.launcher.project.label": "Проєкт",
|
||||||
"multirun.launcher.project.placeholder": "Вибрати проєкт",
|
"multirun.launcher.project.placeholder": "Вибрати проєкт",
|
||||||
"multirun.launcher.project.empty": "Спочатку додайте проєкт.",
|
"multirun.launcher.project.empty": "Спочатку додайте проєкт.",
|
||||||
|
"multirun.launcher.project.gitRequired": "Без ізоляції worktree: запуски використовують ту саму директорію.",
|
||||||
"multirun.launcher.groupName.label": "Назва групи",
|
"multirun.launcher.groupName.label": "Назва групи",
|
||||||
"multirun.launcher.groupName.info": "Використовується для імен каталогу та гілок worktree",
|
"multirun.launcher.groupName.info": "Використовується для імен каталогу та гілок worktree",
|
||||||
"multirun.launcher.groupName.placeholder": "feature-auth, bugfix-login",
|
"multirun.launcher.groupName.placeholder": "feature-auth, bugfix-login",
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'multirun.launcher.project.label': '项目',
|
'multirun.launcher.project.label': '项目',
|
||||||
'multirun.launcher.project.placeholder': '选择项目',
|
'multirun.launcher.project.placeholder': '选择项目',
|
||||||
'multirun.launcher.project.empty': '请先添加项目。',
|
'multirun.launcher.project.empty': '请先添加项目。',
|
||||||
|
'multirun.launcher.project.gitRequired': '无 worktree 隔离:运行将使用同一目录。',
|
||||||
'multirun.launcher.groupName.label': '组名',
|
'multirun.launcher.groupName.label': '组名',
|
||||||
'multirun.launcher.groupName.info': '用于工作树目录和分支名称',
|
'multirun.launcher.groupName.info': '用于工作树目录和分支名称',
|
||||||
'multirun.launcher.groupName.placeholder': 'feature-auth, bugfix-login',
|
'multirun.launcher.groupName.placeholder': 'feature-auth, bugfix-login',
|
||||||
|
|||||||
@@ -118,14 +118,10 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
|||||||
const directory = project.path;
|
const directory = project.path;
|
||||||
|
|
||||||
const isGit = await checkIsGitRepository(directory);
|
const isGit = await checkIsGitRepository(directory);
|
||||||
if (!isGit) {
|
|
||||||
set({ error: 'Not in a git repository', isLoading: false });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupSlug = toGitSafeSlug(groupName);
|
const groupSlug = toGitSafeSlug(groupName);
|
||||||
const rootBranch = await getRootBranch(directory);
|
const rootBranch = isGit ? await getRootBranch(directory) : undefined;
|
||||||
const rootTrackingRemote = await resolveRootTrackingRemote(directory);
|
const rootTrackingRemote = isGit ? await resolveRootTrackingRemote(directory) : null;
|
||||||
|
|
||||||
const createdRuns: Array<{
|
const createdRuns: Array<{
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -147,7 +143,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
|||||||
// Track current index per model during iteration
|
// Track current index per model during iteration
|
||||||
const modelIndexes = new Map<string, number>();
|
const modelIndexes = new Map<string, number>();
|
||||||
|
|
||||||
// 1) Create worktrees + sessions
|
// 1) Create isolated worktrees for Git projects, or same-directory sessions otherwise.
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
const key = `${model.providerID}:${model.modelID}`;
|
const key = `${model.providerID}:${model.modelID}`;
|
||||||
const count = modelCounts.get(key) || 1;
|
const count = modelCounts.get(key) || 1;
|
||||||
@@ -160,6 +156,27 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
|||||||
? generateWorktreeNameSeed(groupSlug, `${modelSlug}/${index}`)
|
? generateWorktreeNameSeed(groupSlug, `${modelSlug}/${index}`)
|
||||||
: generateWorktreeNameSeed(groupSlug, modelSlug);
|
: generateWorktreeNameSeed(groupSlug, modelSlug);
|
||||||
try {
|
try {
|
||||||
|
// Session title format: groupSlug/provider/model (or groupSlug/provider/model/index for duplicates)
|
||||||
|
const sessionTitle = count > 1
|
||||||
|
? `${groupSlug}/${model.providerID}/${model.modelID}/${index}`
|
||||||
|
: `${groupSlug}/${model.providerID}/${model.modelID}`;
|
||||||
|
|
||||||
|
if (!isGit) {
|
||||||
|
const session = await opencodeClient.withDirectory(
|
||||||
|
directory,
|
||||||
|
() => opencodeClient.createSession({ title: sessionTitle })
|
||||||
|
);
|
||||||
|
|
||||||
|
createdRuns.push({
|
||||||
|
sessionId: session.id,
|
||||||
|
worktreePath: directory,
|
||||||
|
providerID: model.providerID,
|
||||||
|
modelID: model.modelID,
|
||||||
|
variant: model.variant,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const worktreeMetadata = await createWorktreeWithDefaults(project, {
|
const worktreeMetadata = await createWorktreeWithDefaults(project, {
|
||||||
preferredName,
|
preferredName,
|
||||||
mode: 'new',
|
mode: 'new',
|
||||||
@@ -177,11 +194,6 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
|||||||
kind: 'standard' as const,
|
kind: 'standard' as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Session title format: groupSlug/provider/model (or groupSlug/provider/model/index for duplicates)
|
|
||||||
const sessionTitle = count > 1
|
|
||||||
? `${groupSlug}/${model.providerID}/${model.modelID}/${index}`
|
|
||||||
: `${groupSlug}/${model.providerID}/${model.modelID}`;
|
|
||||||
|
|
||||||
const session = await opencodeClient.withDirectory(
|
const session = await opencodeClient.withDirectory(
|
||||||
worktreeMetadata.path,
|
worktreeMetadata.path,
|
||||||
() => opencodeClient.createSession({ title: sessionTitle })
|
() => opencodeClient.createSession({ title: sessionTitle })
|
||||||
|
|||||||
Reference in New Issue
Block a user