import { create } from 'zustand'; import type { Session } from '@opencode-ai/sdk/v2'; import { routeMessage, useSessionUIStore } from '@/sync/session-ui-store'; import { devtools } from 'zustand/middleware'; import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multirun'; import { opencodeClient } from '@/lib/opencode/client'; import { getWorktreeSetupWaitEnabled, saveWorktreeSetupCommands } from '@/lib/openchamberConfig'; import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; import { createWorktreeWithDefaults, resolveRootTrackingRemote } from '@/lib/worktrees/worktreeCreate'; import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { checkIsGitRepository } from '@/lib/gitApi'; import { useDirectoryStore } from './useDirectoryStore'; import { useProjectsStore } from './useProjectsStore'; import { useSnippetsStore } from './useSnippetsStore'; import { useGlobalSessionsStore } from './useGlobalSessionsStore'; import { getMultiRunSessionTitle } from '@/lib/multirun/title'; import { getSyncChildStores, registerSessionDirectory } from '@/sync/sync-refs'; const toGitSafeSlug = (value: string): string => { return value .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .substring(0, 50); }; const toModelSlug = (providerID: string, modelID: string): string => { const provider = toGitSafeSlug(providerID); const model = toGitSafeSlug(modelID); return `${provider}-${model}`.substring(0, 60); }; const generateWorktreeNameSeed = (groupSlug: string, modelSlug: string): string => { return `${groupSlug}/${modelSlug}`; }; const normalizePath = (value: string): string => { const replaced = value.replace(/\\/g, '/'); if (replaced === '/') { return '/'; } return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced; }; const registerCreatedSession = (session: Session, directory: string): Session => { const normalizedDirectory = normalizePath(directory); const sessionDirectory = (session as Session & { directory?: string | null }).directory; const sessionWithDirectory = typeof sessionDirectory === 'string' && sessionDirectory.trim().length > 0 ? session : ({ ...session, directory: normalizedDirectory } as Session); registerSessionDirectory(session.id, normalizedDirectory); useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id); useGlobalSessionsStore.getState().upsertSession(sessionWithDirectory); try { const store = getSyncChildStores().ensureChild(normalizedDirectory, { bootstrap: false }); store.setState((state) => { const existingIndex = state.session.findIndex((candidate) => candidate.id === session.id); if (existingIndex >= 0 && state.session[existingIndex] === sessionWithDirectory) { return state; } const nextSessions = existingIndex >= 0 ? state.session.map((candidate, index) => index === existingIndex ? sessionWithDirectory : candidate) : [...state.session, sessionWithDirectory].sort((a, b) => a.id.localeCompare(b.id)); return { session: nextSessions, sessionTotal: Math.max(state.sessionTotal, nextSessions.length), limit: Math.max(state.limit, nextSessions.length), }; }); } catch { // SyncProvider can be unavailable in tests or detached surfaces; the global // session upsert above is enough for the sidebar to show the session. } return sessionWithDirectory; }; const resolveActiveProject = (): ProjectRef | null => { const projectsState = useProjectsStore.getState(); const activeProjectId = projectsState.activeProjectId; if (!activeProjectId) return null; const project = projectsState.projects.find((entry) => entry.id === activeProjectId); if (project?.path) return { id: project.id, path: project.path }; const currentDirectory = useDirectoryStore.getState().currentDirectory ?? null; if (currentDirectory && currentDirectory.trim().length > 0) { const normalized = currentDirectory.replace(/\\/g, '/').replace(/\/+$/, '') || currentDirectory; return { id: `path:${normalized}`, path: normalized }; } return null; }; interface MultiRunState { isLoading: boolean; error: string | null; } interface MultiRunActions { createMultiRun: (params: CreateMultiRunParams) => Promise; clearError: () => void; } type MultiRunStore = MultiRunState & MultiRunActions; export const useMultiRunStore = create()( devtools( (set) => ({ isLoading: false, error: null, createMultiRun: async (params: CreateMultiRunParams) => { const groupName = params.name.trim(); const { groups, agent, files, setupCommands } = params; if (!groupName) { set({ error: 'Group name is required' }); return null; } if (!groups || groups.length === 0) { set({ error: 'At least one run group is required' }); 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 }); try { const project = resolveActiveProject(); if (!project) { set({ error: 'Select a project', isLoading: false }); return null; } const directory = project.path; const isGit = await checkIsGitRepository(directory); const shouldIsolateRuns = isGit && params.isolateRuns !== false; const groupSlug = toGitSafeSlug(groupName); const rootBranch = shouldIsolateRuns ? await getRootBranch(directory) : undefined; const rootTrackingRemote = shouldIsolateRuns ? await resolveRootTrackingRemote(directory) : null; const createdRuns: Array<{ sessionId: string; worktreePath: string; providerID: string; modelID: string; variant?: string; prompt: string; }> = []; const commandsToRun = setupCommands?.filter((cmd) => cmd.trim().length > 0) ?? []; for (let gi = 0; gi < groups.length; gi++) { const group = groups[gi]; const prompt = group.prompt; const modelCounts = new Map(); for (const model of group.models) { const key = `${model.providerID}:${model.modelID}`; modelCounts.set(key, (modelCounts.get(key) || 0) + 1); } const modelIndexes = new Map(); 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 runGroup = groups.length > 1 ? `g${gi + 1}` : undefined; const modelPart = count > 1 ? generateWorktreeNameSeed(groupSlug, `${modelSlug}/${index}`) : generateWorktreeNameSeed(groupSlug, modelSlug); const preferredName = runGroup ? `${runGroup}/${modelPart}` : modelPart; const sessionTitle = getMultiRunSessionTitle({ groupSlug, runGroup, providerID: model.providerID, modelID: model.modelID, index: count > 1 ? index : undefined, }); try { if (!shouldIsolateRuns) { const session = await opencodeClient.withDirectory( directory, () => opencodeClient.createSession({ title: sessionTitle }), ); registerCreatedSession(session, directory); 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, returnAfterDirectoryCreated: true, }, { resolvedRootTrackingRemote: rootTrackingRemote, }); const enrichedMetadata = { ...worktreeMetadata, createdFromBranch: rootBranch, kind: 'standard' as const, }; if (await getWorktreeSetupWaitEnabled(project)) { await waitForWorktreeBootstrap(worktreeMetadata.path); } const session = await opencodeClient.withDirectory( worktreeMetadata.path, () => opencodeClient.createSession({ title: sessionTitle }), ); registerCreatedSession(session, worktreeMetadata.path); useSessionUIStore.getState().setWorktreeMetadata(session.id, enrichedMetadata); createdRuns.push({ sessionId: session.id, worktreePath: worktreeMetadata.path, providerID: model.providerID, modelID: model.modelID, variant: model.variant, prompt, }); } catch (err) { console.warn('[MultiRun] Failed to create session:', err); } } } 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'); }); } const sessionIds = createdRuns.map((r) => r.sessionId); const firstSessionId = createdRuns[0]?.sessionId ?? null; if (sessionIds.length === 0) { set({ error: 'Failed to create any sessions', isLoading: false }); return null; } const filesForMessage = files?.map((f) => ({ type: 'file' as const, mime: f.mime, filename: f.filename, url: f.url, })); void (async () => { try { const expandText = useSnippetsStore.getState().expandText; await Promise.allSettled( createdRuns.map(async (run) => { try { const text = await expandText(run.prompt).catch(() => run.prompt); await routeMessage({ sessionId: run.sessionId, directory: run.worktreePath, content: text, providerID: run.providerID, modelID: run.modelID, variant: run.variant, agent, files: filesForMessage, }); } catch (err) { console.warn('[MultiRun] Failed to start run:', err); } }), ); } catch (err) { console.warn('[MultiRun] Failed to start runs:', err); } })(); set({ isLoading: false }); return { groupSlug, sessionIds, firstSessionId }; } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to create Multi-Run', isLoading: false, }); return null; } }, clearError: () => { set({ error: null }); }, }), { name: 'multirun-store' }, ), );