Multi-run with configurable prompt templates (#1111)
* Multi-run with configurable prompt templates * Fixes * Fix handleDuplicate fire-and-forget: await createTemplate and handle failure * Add Polish translations for prompt template and multirun group keys * fix: migrate remaining Remix icons to Icon component in MultiRunLauncher --------- Signed-off-by: Tom Rochette <roctom@gmail.com>
This commit is contained in:
@@ -8,13 +8,9 @@ import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||
import { createWorktreeWithDefaults, resolveRootTrackingRemote } from '@/lib/worktrees/worktreeCreate';
|
||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||
// sessionStore removed — sync bootstrap handles session loading
|
||||
import { useDirectoryStore } from './useDirectoryStore';
|
||||
import { useProjectsStore } from './useProjectsStore';
|
||||
|
||||
/**
|
||||
* Generate a git-safe slug from a string.
|
||||
*/
|
||||
const toGitSafeSlug = (value: string): string => {
|
||||
return value
|
||||
.toLowerCase()
|
||||
@@ -23,19 +19,12 @@ const toGitSafeSlug = (value: string): string => {
|
||||
.substring(0, 50);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a model slug from provider and model IDs.
|
||||
*/
|
||||
const toModelSlug = (providerID: string, modelID: string): string => {
|
||||
const provider = toGitSafeSlug(providerID);
|
||||
const model = toGitSafeSlug(modelID);
|
||||
return `${provider}-${model}`.substring(0, 60);
|
||||
};
|
||||
|
||||
/**
|
||||
* Seed name for worktree creation.
|
||||
* Uses slashes for readability; create payload will slugify.
|
||||
*/
|
||||
const generateWorktreeNameSeed = (groupSlug: string, modelSlug: string): string => {
|
||||
return `${groupSlug}/${modelSlug}`;
|
||||
};
|
||||
@@ -43,16 +32,11 @@ const generateWorktreeNameSeed = (groupSlug: string, modelSlug: string): string
|
||||
const resolveActiveProject = (): ProjectRef | null => {
|
||||
const projectsState = useProjectsStore.getState();
|
||||
const activeProjectId = projectsState.activeProjectId;
|
||||
if (!activeProjectId) {
|
||||
return null;
|
||||
}
|
||||
if (!activeProjectId) return null;
|
||||
|
||||
const project = projectsState.projects.find((entry) => entry.id === activeProjectId);
|
||||
if (project?.path) {
|
||||
return { id: project.id, path: project.path };
|
||||
}
|
||||
if (project?.path) return { id: project.id, path: project.path };
|
||||
|
||||
// Fall back to current directory only when active project is missing.
|
||||
const currentDirectory = useDirectoryStore.getState().currentDirectory ?? null;
|
||||
if (currentDirectory && currentDirectory.trim().length > 0) {
|
||||
const normalized = currentDirectory.replace(/\\/g, '/').replace(/\/+$/, '') || currentDirectory;
|
||||
@@ -68,7 +52,6 @@ interface MultiRunState {
|
||||
}
|
||||
|
||||
interface MultiRunActions {
|
||||
/** Create worktrees/sessions and immediately start all runs */
|
||||
createMultiRun: (params: CreateMultiRunParams) => Promise<CreateMultiRunResult | null>;
|
||||
clearError: () => void;
|
||||
}
|
||||
@@ -83,27 +66,31 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
|
||||
createMultiRun: async (params: CreateMultiRunParams) => {
|
||||
const groupName = params.name.trim();
|
||||
const prompt = params.prompt.trim();
|
||||
const { models, agent, files, setupCommands } = params;
|
||||
const { groups, agent, files, setupCommands } = params;
|
||||
|
||||
if (!groupName) {
|
||||
set({ error: 'Group name is required' });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!prompt) {
|
||||
set({ error: 'Prompt is required' });
|
||||
if (!groups || groups.length === 0) {
|
||||
set({ error: 'At least one run group is required' });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (models.length < 1) {
|
||||
set({ error: 'Select at least 1 model' });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (models.length > 5) {
|
||||
set({ error: 'Maximum 5 models allowed' });
|
||||
return null;
|
||||
for (let gi = 0; gi < groups.length; gi++) {
|
||||
if (!groups[gi].prompt.trim()) {
|
||||
set({ error: `Group ${gi + 1}: prompt is required` });
|
||||
return null;
|
||||
}
|
||||
if (groups[gi].models.length < 1) {
|
||||
set({ error: `Group ${gi + 1}: select at least 1 model` });
|
||||
return null;
|
||||
}
|
||||
if (groups[gi].models.length > 5) {
|
||||
set({ error: `Group ${gi + 1}: maximum 5 models allowed` });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
@@ -130,94 +117,101 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
variant?: string;
|
||||
prompt: string;
|
||||
}> = [];
|
||||
|
||||
const commandsToRun = setupCommands?.filter((cmd) => cmd.trim().length > 0) ?? [];
|
||||
|
||||
// Count occurrences of each model to handle duplicates
|
||||
const modelCounts = new Map<string, number>();
|
||||
for (const model of models) {
|
||||
const key = `${model.providerID}:${model.modelID}`;
|
||||
modelCounts.set(key, (modelCounts.get(key) || 0) + 1);
|
||||
}
|
||||
for (let gi = 0; gi < groups.length; gi++) {
|
||||
const group = groups[gi];
|
||||
const prompt = group.prompt;
|
||||
|
||||
// Track current index per model during iteration
|
||||
const modelIndexes = new Map<string, number>();
|
||||
const modelCounts = new Map<string, number>();
|
||||
for (const model of group.models) {
|
||||
const key = `${model.providerID}:${model.modelID}`;
|
||||
modelCounts.set(key, (modelCounts.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
// 1) Create isolated worktrees for Git projects, or same-directory sessions otherwise.
|
||||
for (const model of models) {
|
||||
const key = `${model.providerID}:${model.modelID}`;
|
||||
const count = modelCounts.get(key) || 1;
|
||||
const index = (modelIndexes.get(key) || 0) + 1;
|
||||
modelIndexes.set(key, index);
|
||||
const modelIndexes = new Map<string, number>();
|
||||
|
||||
for (const model of group.models) {
|
||||
const key = `${model.providerID}:${model.modelID}`;
|
||||
const count = modelCounts.get(key) || 1;
|
||||
const index = (modelIndexes.get(key) || 0) + 1;
|
||||
modelIndexes.set(key, index);
|
||||
|
||||
const modelSlug = toModelSlug(model.providerID, model.modelID);
|
||||
const groupPart = groups.length > 1 ? `g${gi + 1}` : '';
|
||||
const modelPart = count > 1
|
||||
? generateWorktreeNameSeed(groupSlug, `${modelSlug}/${index}`)
|
||||
: generateWorktreeNameSeed(groupSlug, modelSlug);
|
||||
const preferredName = groupPart
|
||||
? `${groupPart}/${modelPart}`
|
||||
: modelPart;
|
||||
|
||||
const modelSlug = toModelSlug(model.providerID, model.modelID);
|
||||
// Append index only when same model is selected multiple times
|
||||
const preferredName = count > 1
|
||||
? generateWorktreeNameSeed(groupSlug, `${modelSlug}/${index}`)
|
||||
: generateWorktreeNameSeed(groupSlug, modelSlug);
|
||||
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}`;
|
||||
? `${groupSlug}/${groupPart}/${model.providerID}/${model.modelID}/${index}`
|
||||
: groupPart
|
||||
? `${groupSlug}/${groupPart}/${model.providerID}/${model.modelID}`
|
||||
: `${groupSlug}/${model.providerID}/${model.modelID}`;
|
||||
|
||||
try {
|
||||
if (!shouldIsolateRuns) {
|
||||
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,
|
||||
prompt,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const worktreeMetadata = await createWorktreeWithDefaults(project, {
|
||||
preferredName,
|
||||
mode: 'new',
|
||||
branchName: preferredName,
|
||||
worktreeName: preferredName,
|
||||
startRef: params.worktreeBaseBranch || 'HEAD',
|
||||
setupCommands: commandsToRun,
|
||||
}, {
|
||||
resolvedRootTrackingRemote: rootTrackingRemote,
|
||||
});
|
||||
|
||||
const enrichedMetadata = {
|
||||
...worktreeMetadata,
|
||||
createdFromBranch: rootBranch,
|
||||
kind: 'standard' as const,
|
||||
};
|
||||
|
||||
if (!shouldIsolateRuns) {
|
||||
const session = await opencodeClient.withDirectory(
|
||||
directory,
|
||||
() => opencodeClient.createSession({ title: sessionTitle })
|
||||
worktreeMetadata.path,
|
||||
() => opencodeClient.createSession({ title: sessionTitle }),
|
||||
);
|
||||
|
||||
useSessionUIStore.getState().setWorktreeMetadata(session.id, enrichedMetadata);
|
||||
|
||||
createdRuns.push({
|
||||
sessionId: session.id,
|
||||
worktreePath: directory,
|
||||
worktreePath: worktreeMetadata.path,
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
variant: model.variant,
|
||||
prompt,
|
||||
});
|
||||
continue;
|
||||
} catch (err) {
|
||||
console.warn('[MultiRun] Failed to create session:', err);
|
||||
}
|
||||
|
||||
const worktreeMetadata = await createWorktreeWithDefaults(project, {
|
||||
preferredName,
|
||||
mode: 'new',
|
||||
branchName: preferredName,
|
||||
worktreeName: preferredName,
|
||||
startRef: params.worktreeBaseBranch || 'HEAD',
|
||||
setupCommands: commandsToRun,
|
||||
}, {
|
||||
resolvedRootTrackingRemote: rootTrackingRemote,
|
||||
});
|
||||
|
||||
const enrichedMetadata = {
|
||||
...worktreeMetadata,
|
||||
createdFromBranch: rootBranch,
|
||||
kind: 'standard' as const,
|
||||
};
|
||||
|
||||
const session = await opencodeClient.withDirectory(
|
||||
worktreeMetadata.path,
|
||||
() => opencodeClient.createSession({ title: sessionTitle })
|
||||
);
|
||||
|
||||
useSessionUIStore.getState().setWorktreeMetadata(session.id, enrichedMetadata);
|
||||
|
||||
createdRuns.push({
|
||||
sessionId: session.id,
|
||||
worktreePath: worktreeMetadata.path,
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
variant: model.variant,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Best-effort: allow partial success
|
||||
console.warn('[MultiRun] Failed to create session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Save setup commands to config if any were provided (for future worktree creation)
|
||||
const commandsToSave = setupCommands?.filter(cmd => cmd.trim().length > 0) ?? [];
|
||||
const commandsToSave = setupCommands?.filter((cmd) => cmd.trim().length > 0) ?? [];
|
||||
if (commandsToSave.length > 0) {
|
||||
saveWorktreeSetupCommands(project, commandsToSave).catch(() => {
|
||||
console.warn('[MultiRun] Failed to save worktree setup commands');
|
||||
@@ -232,9 +226,6 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2) Start all runs with the same prompt.
|
||||
// IMPORTANT: do not await model/agent execution here; only worktree + session creation.
|
||||
// Convert files to the format expected by sendMessage
|
||||
const filesForMessage = files?.map((f) => ({
|
||||
type: 'file' as const,
|
||||
mime: f.mime,
|
||||
@@ -242,33 +233,29 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
url: f.url,
|
||||
}));
|
||||
|
||||
// Session list refresh handled by sync bootstrap via SSE events
|
||||
|
||||
// Setup commands run via SDK worktree startCommand.
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
createdRuns.map(async (run) => {
|
||||
try {
|
||||
await opencodeClient.withDirectory(run.worktreePath, () =>
|
||||
opencodeClient.sendMessage({
|
||||
id: run.sessionId,
|
||||
providerID: run.providerID,
|
||||
modelID: run.modelID,
|
||||
variant: run.variant,
|
||||
text: prompt,
|
||||
agent,
|
||||
files: filesForMessage,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[MultiRun] Failed to start run:', error);
|
||||
await opencodeClient.withDirectory(run.worktreePath, () =>
|
||||
opencodeClient.sendMessage({
|
||||
id: run.sessionId,
|
||||
providerID: run.providerID,
|
||||
modelID: run.modelID,
|
||||
variant: run.variant,
|
||||
text: run.prompt,
|
||||
agent,
|
||||
files: filesForMessage,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[MultiRun] Failed to start run:', err);
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[MultiRun] Failed to start runs:', error);
|
||||
} catch (err) {
|
||||
console.warn('[MultiRun] Failed to start runs:', err);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -287,6 +274,6 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
set({ error: null });
|
||||
},
|
||||
}),
|
||||
{ name: 'multirun-store' }
|
||||
)
|
||||
{ name: 'multirun-store' },
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user