diff --git a/src/renderer/actions/currentProject.ts b/src/renderer/actions/currentProject.ts index 5e045dfa..b00d7f8a 100644 --- a/src/renderer/actions/currentProject.ts +++ b/src/renderer/actions/currentProject.ts @@ -13,6 +13,7 @@ export function getCurrentProjectId(): string | undefined { const s = useAppStore.getState(); const v = s.view; if (v.kind === "draft") return v.projectId; + if (v.kind === "experiment") return s.experiments[v.experimentId]?.projectId; if (v.kind === "thread") { const paneId = resolveActivePaneId(v.panes, s.focusedPaneId); const draftProjectId = parseDraftProjectId(paneId); diff --git a/src/renderer/actions/experimentActions.test.ts b/src/renderer/actions/experimentActions.test.ts new file mode 100644 index 00000000..18382d79 --- /dev/null +++ b/src/renderer/actions/experimentActions.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Project, PromptSegment } from "@/shared/contracts"; +import { useAppStore } from "@/renderer/state/appStore"; +import { launchExperiment } from "./experimentActions"; + +const { bridge } = vi.hoisted(() => ({ + bridge: { + gitAddWorktree: vi.fn<() => Promise<{ path: string }>>(), + startThread: vi.fn<() => Promise<{ threadId: string }>>(), + gitWatchWorktrees: vi.fn<() => Promise>(), + }, +})); +const { refreshGitProject, getProjectActiveWorktreePaths } = vi.hoisted(() => ({ + refreshGitProject: vi.fn<() => void>(), + getProjectActiveWorktreePaths: vi.fn<() => string[]>(), +})); +const { performWorktreeRemoval } = vi.hoisted(() => ({ + performWorktreeRemoval: vi.fn<() => Promise>(), +})); +const { toast } = vi.hoisted(() => ({ + toast: { + danger: vi.fn<() => void>(), + success: vi.fn<() => void>(), + }, +})); + +vi.mock("@/renderer/bridge", () => ({ + readBridge: () => bridge, +})); + +vi.mock("@/renderer/state/gitRefresh", () => ({ + refreshGitProject, + getProjectActiveWorktreePaths, +})); + +vi.mock("@/renderer/actions/worktreeActions", () => ({ + performWorktreeRemoval, +})); + +vi.mock("@heroui/react", () => ({ toast })); + +describe("experimentActions", () => { + beforeEach(() => { + vi.clearAllMocks(); + getProjectActiveWorktreePaths.mockReturnValue([]); + performWorktreeRemoval.mockResolvedValue(undefined); + bridge.gitWatchWorktrees.mockResolvedValue(undefined); + bridge.startThread.mockResolvedValue({ threadId: "thread" }); + useAppStore.setState((state) => ({ + ...state, + projects: [], + threads: [], + experiments: {}, + view: { kind: "home" }, + lastRuntimeConfigByThreadId: {}, + })); + }); + + it("cleans up a partial launch instead of creating a one-candidate experiment", async () => { + bridge.gitAddWorktree + .mockResolvedValueOnce({ path: "/repo/.worktrees/a" }) + .mockRejectedValueOnce(new Error("branch exists")); + + const result = await launchExperiment({ + project: makeProject(), + prompt: "fix the bug", + candidates: [ + { agentKind: "codex", agentLabel: "Codex", config: { model: "gpt-5.4" } }, + { agentKind: "claude", agentLabel: "Claude", config: { model: "opus" } }, + ], + }); + + expect(result).toBeNull(); + expect(performWorktreeRemoval).toHaveBeenCalledWith( + makeProject(), + "/repo/.worktrees/a", + expect.stringMatching(/^exp\//), + ); + expect(useAppStore.getState().threads).toEqual([]); + expect(useAppStore.getState().experiments).toEqual({}); + expect(bridge.startThread).not.toHaveBeenCalled(); + }); + + it("starts candidates with the original structured prompt segments", async () => { + bridge.gitAddWorktree + .mockResolvedValueOnce({ path: "/repo/.worktrees/a" }) + .mockResolvedValueOnce({ path: "/repo/.worktrees/b" }); + const segments: PromptSegment[] = [ + { kind: "text", content: "fix " }, + { kind: "file", path: "src/app.ts" }, + ]; + + const result = await launchExperiment({ + project: makeProject(), + prompt: "fix @src/app.ts", + segments, + candidates: [ + { agentKind: "codex", agentLabel: "Codex", config: { model: "gpt-5.4" } }, + { agentKind: "claude", agentLabel: "Claude", config: { model: "opus" } }, + ], + }); + + expect(result).toEqual(expect.any(String)); + expect(bridge.startThread).toHaveBeenCalledTimes(2); + expect(bridge.startThread).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "fix @src/app.ts", + segments, + }), + ); + }); +}); + +function makeProject(): Project { + return { + id: "project-1", + name: "Project", + location: { kind: "posix", path: "/repo" }, + createdAt: "2026-06-15T00:00:00.000Z", + }; +} diff --git a/src/renderer/actions/experimentActions.ts b/src/renderer/actions/experimentActions.ts new file mode 100644 index 00000000..339893ed --- /dev/null +++ b/src/renderer/actions/experimentActions.ts @@ -0,0 +1,418 @@ +import { toast } from "@heroui/react"; +import type { + AgentInstanceId, + ExperimentCandidate, + Project, + PromptSegment, + ThreadConfig, + ThreadPresentationMode, +} from "@/shared/contracts"; +import { friendlyError } from "@/shared/messages"; +import { buildWorktreeLocation, sanitizeWorktreeBranchName } from "@/shared/worktree"; +import { readBridge } from "@/renderer/bridge"; +import { makeThreadTitle, useAppStore } from "@/renderer/state/appStore"; +import { getProjectActiveWorktreePaths, refreshGitProject } from "@/renderer/state/gitRefresh"; +import { useGitStore } from "@/renderer/state/gitStore"; +import { performWorktreeRemoval } from "@/renderer/actions/worktreeActions"; +import { runGitMergeToSource, showGitOperationFailure } from "@/renderer/actions/gitCommandRunner"; + +/** A single agent/model the same prompt should be fanned out to. */ +export interface ExperimentCandidateSpec { + agentKind: string; + agentInstanceId?: AgentInstanceId; + /** Display label (provider name) captured for the board + branch names. */ + agentLabel?: string; + config: ThreadConfig; + presentationMode?: ThreadPresentationMode; +} + +export interface LaunchExperimentInput { + project: Project; + prompt: string; + segments?: PromptSegment[]; + /** Branch every candidate forks from and the winner merges back into. */ + baseBranch?: string; + candidates: ExperimentCandidateSpec[]; +} + +const DEFAULT_LAUNCH_SIZE = { cols: 120, rows: 40 } as const; + +function candidateLabel(spec: ExperimentCandidateSpec): string { + const provider = spec.agentLabel ?? spec.agentKind; + return spec.config.model ? `${provider} · ${spec.config.model}` : provider; +} + +/** + * Fan one prompt out across several agent/model candidates. Each candidate runs + * in its own fresh worktree (branched from `baseBranch`) so their diffs can be + * compared side-by-side on the experiment board and the winner merged back. + * + * Returns the new experiment id, or `null` if no candidate could be launched. + */ +export async function launchExperiment(input: LaunchExperimentInput): Promise { + const { project, prompt, segments, baseBranch, candidates } = input; + if (candidates.length < 2) { + toast.danger("Choose at least two candidates for an experiment."); + return null; + } + + const experimentId = crypto.randomUUID(); + const title = makeThreadTitle(prompt) || "Experiment"; + const promptSlug = sanitizeWorktreeBranchName(title).slice(0, 24) || "experiment"; + const shortId = experimentId.slice(0, 6); + + interface PreparedCandidate { + spec: ExperimentCandidateSpec; + worktreePath: string; + worktreeBranch: string; + label: string; + } + + // Create worktrees sequentially: concurrent `git worktree add` in the same + // repo races on index.lock. Failures skip just that candidate. + const prepared: PreparedCandidate[] = []; + const usedBranches = new Set(); + for (let i = 0; i < candidates.length; i++) { + const spec = candidates[i]!; + const agentSlug = sanitizeWorktreeBranchName(candidateLabel(spec)).slice(0, 16); + let branch = `exp/${promptSlug}-${agentSlug}-${shortId}`; + if (usedBranches.has(branch)) branch = `${branch}-${i + 1}`; + usedBranches.add(branch); + try { + const result = await readBridge().gitAddWorktree({ + projectLocation: project.location, + branch, + createBranch: true, + ...(baseBranch ? { startPoint: baseBranch } : {}), + ...(project.scripts?.worktreeCopyPatterns + ? { copyIgnoredPatterns: project.scripts.worktreeCopyPatterns } + : {}), + transferUncommitted: false, + keepChangesInSource: false, + }); + prepared.push({ + spec, + worktreePath: result.path, + worktreeBranch: branch, + label: candidateLabel(spec), + }); + } catch (err) { + console.error("[experiment] failed to create worktree for candidate", err); + toast.danger(`${candidateLabel(spec)}: ${friendlyError(err)}`); + } + } + + if (prepared.length < 2) { + toast.danger( + prepared.length === 0 + ? "Couldn't create any worktrees for the experiment." + : "Only one experiment worktree was created; cleaned it up instead of launching.", + ); + await Promise.all( + prepared.map((candidate) => + performWorktreeRemoval(project, candidate.worktreePath, candidate.worktreeBranch).catch( + (err) => console.warn("[experiment] failed to clean up partial launch", err), + ), + ), + ); + void refreshGitProject({ id: project.id, location: project.location }, "manual", "full"); + return null; + } + + const store = useAppStore.getState(); + const threads = store.createThreadsBatch( + prepared.map((p) => ({ + projectId: project.id, + agentKind: p.spec.agentKind, + ...(p.spec.agentInstanceId ? { agentInstanceId: p.spec.agentInstanceId } : {}), + config: p.spec.config, + prompt, + title: p.label, + worktreePath: p.worktreePath, + worktreeBranch: p.worktreeBranch, + groupId: experimentId, + groupName: title, + ...(p.spec.presentationMode ? { presentationMode: p.spec.presentationMode } : {}), + })), + ); + + const candidateRecords: ExperimentCandidate[] = threads.map((thread, idx) => { + const p = prepared[idx]!; + return { + threadId: thread.id, + agentKind: p.spec.agentKind, + ...(p.spec.agentLabel ? { agentLabel: p.spec.agentLabel } : {}), + ...(p.spec.config.model ? { model: p.spec.config.model } : {}), + worktreePath: p.worktreePath, + worktreeBranch: p.worktreeBranch, + }; + }); + + store.createExperimentRecord({ + id: experimentId, + projectId: project.id, + title, + prompt, + ...(baseBranch ? { baseBranch } : {}), + candidates: candidateRecords, + }); + + // Launch each candidate directly: the experiment board does not mount the + // full ThreadView, so we can't rely on its queued-launch effect. The agents + // run in the supervisor regardless; opening a candidate later just reattaches. + for (let idx = 0; idx < threads.length; idx++) { + const thread = threads[idx]!; + const p = prepared[idx]!; + const projectLocation = buildWorktreeLocation(project.location, p.worktreePath); + void readBridge() + .startThread({ + threadId: thread.id, + projectLocation, + agentKind: thread.agentKind, + ...(thread.agentInstanceId ? { agentInstanceId: thread.agentInstanceId } : {}), + config: thread.config, + prompt, + ...(segments ? { segments } : {}), + initialSize: DEFAULT_LAUNCH_SIZE, + ...(thread.presentationMode ? { presentationMode: thread.presentationMode } : {}), + }) + .catch((err) => { + console.error("[experiment] failed to start candidate thread", err); + useAppStore.getState().updateThreadRuntime(thread.id, { + status: "error", + attention: "error", + canResumeWithConfig: false, + }); + }); + } + + // Prime git state for the new worktrees so the board shows diff stats without + // waiting for the next background refresh. + const worktreePaths = [ + ...new Set([ + ...getProjectActiveWorktreePaths(project.id), + ...prepared.map((p) => p.worktreePath), + ]), + ].sort(); + void readBridge() + .gitWatchWorktrees({ projectId: project.id, worktreePaths }) + .catch(() => undefined); + void refreshGitProject({ id: project.id, location: project.location }, "manual", "full"); + + store.openExperiment(experimentId); + return experimentId; +} + +/** + * Merge the chosen candidate's branch into the experiment's base branch, then + * archive the losers (close their threads + remove their worktrees/branches). + */ +export async function mergeExperimentWinner( + experimentId: string, + winnerThreadId: string, +): Promise { + const state = useAppStore.getState(); + const experiment = state.experiments[experimentId]; + if (!experiment) return; + const project = state.projects.find((p) => p.id === experiment.projectId); + if (!project) return; + const winner = experiment.candidates.find((c) => c.threadId === winnerThreadId); + if (!winner) return; + + let sourceBranch = experiment.baseBranch; + if (!sourceBranch) { + try { + const res = await readBridge().gitGetWorktreeSourceBranch({ + projectLocation: project.location, + branch: winner.worktreeBranch, + }); + sourceBranch = res.sourceBranch ?? undefined; + } catch { + // fall through to the missing-branch guard below + } + } + if (!sourceBranch) { + toast.danger("Couldn't determine which branch to merge into."); + return; + } + + const worktreeLocation = buildWorktreeLocation(project.location, winner.worktreePath); + let result; + try { + result = await runGitMergeToSource({ + projectLocation: project.location, + worktreeLocation, + worktreeBranch: winner.worktreeBranch, + sourceBranch, + }); + } catch (err) { + toast.danger(friendlyError(err)); + return; + } + + if (!result.merged) { + showGitOperationFailure(result); + return; + } + + toast.success( + result.fastForward + ? `Merged ${winner.worktreeBranch} into ${sourceBranch}` + : `Merged ${winner.worktreeBranch} into ${sourceBranch} (merge commit)`, + ); + + state.setExperimentWinner(experimentId, winnerThreadId); + + // Archive the losers: close their agents, drop their worktrees/branches, and + // hide the threads from the active list (history is still recoverable). + const losers = experiment.candidates.filter((c) => c.threadId !== winnerThreadId); + await Promise.all( + losers.map(async (loser) => { + await readBridge() + .closeThread({ threadId: loser.threadId }) + .catch(() => undefined); + useAppStore.getState().archiveThread(loser.threadId); + await performWorktreeRemoval(project, loser.worktreePath, loser.worktreeBranch).catch((err) => + console.warn("[experiment] failed to remove loser worktree", err), + ); + }), + ); + + void refreshGitProject({ id: project.id, location: project.location }, "manual", "full"); +} + +/** + * Abandon an experiment without picking a winner: close every candidate, remove + * all worktrees/branches, and drop the experiment record. + */ +export async function discardExperiment(experimentId: string): Promise { + const state = useAppStore.getState(); + const experiment = state.experiments[experimentId]; + if (!experiment) return; + const project = state.projects.find((p) => p.id === experiment.projectId); + + if (state.view.kind === "experiment" && state.view.experimentId === experimentId) { + state.closeExperiment(); + } + + for (const candidate of experiment.candidates) { + await readBridge() + .closeThread({ threadId: candidate.threadId }) + .catch(() => undefined); + useAppStore.getState().archiveThread(candidate.threadId); + if (project) { + await performWorktreeRemoval(project, candidate.worktreePath, candidate.worktreeBranch).catch( + (err) => console.warn("[experiment] failed to remove worktree", err), + ); + } + } + + useAppStore.getState().removeExperiment(experimentId); + if (project) { + void refreshGitProject({ id: project.id, location: project.location }, "manual", "full"); + } +} + +/** Resolve a default base branch for a project from its current git status. */ +export function resolveProjectBaseBranch(projectId: string): string | undefined { + return useGitStore.getState().statuses[projectId]?.branch || undefined; +} + +// Low-signal files the judge shouldn't waste budget on (lockfiles, minified +// bundles, sourcemaps). Mirrors the filtering cmux applies before judging. +const NOISE_PATH = + /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|[^/]*\.lock|[^/]*\.min\.(js|css)|[^/]*\.map)$/i; + +/** Concatenate a worktree's staged + unstaged diffs, dropping low-signal files. */ +async function collectCandidateDiff(project: Project, worktreePath: string): Promise { + const projectLocation = buildWorktreeLocation(project.location, worktreePath); + try { + const status = await readBridge().getGitStatus({ projectLocation }); + const untrackedPaths = status.unstaged + .filter((file) => file.status === "?") + .map((file) => file.path); + const batch = await readBridge().getGitDiffBatch({ projectLocation, untrackedPaths }); + const parts: string[] = []; + for (const record of [batch.unstaged, batch.staged]) { + for (const [path, diff] of Object.entries(record)) { + if (NOISE_PATH.test(path)) continue; + if (diff.trim()) parts.push(diff); + } + } + return parts.join("\n"); + } catch (err) { + console.warn("[experiment] failed to collect candidate diff", err); + return ""; + } +} + +/** + * Run the LLM judge ("crown"): gather each candidate's diff, ask a model to + * pick the best, and record the result as an AI crown (overridable by the + * user). Candidate diffs are sent with anonymized labels so the judge can't be + * biased by which provider produced which diff. Returns true on success. + */ +export async function crownExperiment(experimentId: string): Promise { + const state = useAppStore.getState(); + const experiment = state.experiments[experimentId]; + if (!experiment) return false; + const project = state.projects.find((p) => p.id === experiment.projectId); + if (!project) return false; + if (experiment.candidates.length < 2) { + toast.danger("Need at least two candidates to crown a winner."); + return false; + } + + const diffs = await Promise.all( + experiment.candidates.map((c) => collectCandidateDiff(project, c.worktreePath)), + ); + if (diffs.every((d) => !d.trim())) { + toast.danger("No changes yet — let the agents finish before crowning."); + return false; + } + + // Judge through the first candidate's provider (reuses its auth/config); the + // label is anonymized but the threadId is preserved for mapping the winner. + const judgeAgentKind = experiment.candidates[0]!.agentKind; + const judgeModel = experiment.candidates[0]!.model; + const judgeLabel = judgeModel + ? `${experiment.candidates[0]!.agentLabel ?? judgeAgentKind} · ${judgeModel}` + : (experiment.candidates[0]!.agentLabel ?? judgeAgentKind); + + let result; + try { + result = await readBridge().judgeExperiment({ + projectLocation: project.location, + agentKind: judgeAgentKind, + ...(judgeModel ? { model: judgeModel } : {}), + prompt: experiment.prompt, + candidates: experiment.candidates.map((c, idx) => ({ + threadId: c.threadId, + label: `Solution ${String.fromCharCode(65 + idx)}`, + diff: diffs[idx] ?? "", + })), + }); + } catch (err) { + toast.danger(`Crown judge failed: ${friendlyError(err)}`); + return false; + } + + useAppStore.getState().setExperimentCrown(experimentId, { + threadId: result.winnerThreadId, + rationale: result.rationale, + source: "ai", + modelLabel: judgeLabel, + createdAt: new Date().toISOString(), + }); + return true; +} + +/** Manually crown a candidate (overrides any AI crown). */ +export function setManualCrown(experimentId: string, threadId: string): void { + useAppStore.getState().setExperimentCrown(experimentId, { + threadId, + rationale: "Selected by you.", + source: "user", + createdAt: new Date().toISOString(), + }); +} diff --git a/src/renderer/actions/threadActions.test.ts b/src/renderer/actions/threadActions.test.ts index 0d94de49..bfab7802 100644 --- a/src/renderer/actions/threadActions.test.ts +++ b/src/renderer/actions/threadActions.test.ts @@ -4,11 +4,14 @@ import type { Thread } from "@/shared/contracts"; import { useAppStore } from "@/renderer/state/appStore"; import { useDevTerminalStore } from "@/renderer/state/devTerminalStore"; import { usePanelStore } from "@/renderer/state/panelStore"; +import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { useWorktreeDeleteStore } from "@/renderer/state/worktreeDeleteStore"; import { deleteThread, openThread, + openThreadStandalone, reopenStoredThread, + sweepStaleThreads, switchToAdjacentThread, toggleMarkThreadDone, } from "./threadActions"; @@ -41,6 +44,7 @@ vi.mock("@/renderer/state/chatRuntimePersister", () => ({ describe("threadActions", () => { beforeEach(() => { + vi.useRealTimers(); vi.clearAllMocks(); localStorage.clear(); hasHydratedThreadRuntimeItems.mockReturnValue(false); @@ -55,7 +59,9 @@ describe("threadActions", () => { runtimeItemIdsByThread: {}, runtimeItemsByIdByThread: {}, runtimeCompletedTurnsByThread: {}, + experiments: {}, })); + useSharedSettings.setState({ staleThreadUnloadMinutes: 0 }); useDevTerminalStore.setState({ isOpen: false, activeProjectId: null, @@ -153,6 +159,32 @@ describe("threadActions", () => { }); }); + it("hydrates only the selected GUI thread when opening a standalone grouped thread", async () => { + const firstThread = makeThread({ + id: "thread-group-a", + groupId: "group-1", + presentationMode: "gui", + }); + const secondThread = makeThread({ + id: "thread-group-b", + groupId: "group-1", + presentationMode: "gui", + }); + useAppStore.setState((state) => ({ ...state, threads: [firstThread, secondThread] })); + + openThreadStandalone(firstThread.id); + + expect(hydrateThreadRuntimeItems).toHaveBeenCalledWith(firstThread.id); + expect(hydrateThreadRuntimeItems).not.toHaveBeenCalledWith(secondThread.id); + + await waitFor(() => { + expect(useAppStore.getState().view).toEqual({ + kind: "thread", + panes: [firstThread.id], + }); + }); + }); + it("queues terminal reconnects as launching when reopening", () => { const thread = makeThread({ status: "inactive", @@ -190,6 +222,49 @@ describe("threadActions", () => { expect(useAppStore.getState().pendingThreadLaunches[thread.id]).toBe(""); }); + it("does not sweep idle experiment candidates while the board is open", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-15T12:00:00.000Z")); + useSharedSettings.setState({ staleThreadUnloadMinutes: 1 }); + const candidate = makeThread({ + id: "thread-candidate", + status: "idle", + updatedAt: "2026-06-15T11:00:00.000Z", + sessionRef: { + providerSessionId: "session-1", + discoveredAt: "2026-06-15T11:00:00.000Z", + }, + }); + useAppStore.setState((state) => ({ + ...state, + threads: [candidate], + view: { kind: "experiment", experimentId: "exp-1" }, + experiments: { + "exp-1": { + id: "exp-1", + projectId: candidate.projectId, + title: "Experiment", + prompt: "try", + candidates: [ + { + threadId: candidate.id, + agentKind: candidate.agentKind, + worktreePath: "/repo/.worktrees/exp", + worktreeBranch: "exp/test", + }, + ], + status: "running", + createdAt: "2026-06-15T11:00:00.000Z", + updatedAt: "2026-06-15T11:00:00.000Z", + }, + }, + })); + + sweepStaleThreads(); + + expect(bridge.closeThread).not.toHaveBeenCalledWith({ threadId: candidate.id }); + }); + it("closes a live CLI thread when marking done even before a session ref is known", async () => { const project = useAppStore.getState().addProject({ kind: "posix", diff --git a/src/renderer/actions/threadActions.ts b/src/renderer/actions/threadActions.ts index 1cfbf302..a38a777d 100644 --- a/src/renderer/actions/threadActions.ts +++ b/src/renderer/actions/threadActions.ts @@ -145,6 +145,41 @@ export function switchToAdjacentThread(current: Thread, direction: "next" | "pre if (nextId && nextId !== current.id) openThread(nextId); } +export function openThreadStandalone( + threadId: string, + options?: { focusComposer?: boolean }, +): void { + const thread = useAppStore.getState().threads.find((item) => item.id === threadId); + const requestId = ++openThreadRequestId; + const shouldHydrate = + thread?.presentationMode === "gui" && !hasHydratedThreadRuntimeItems(thread.id); + + const applyOpen = () => { + if (requestId !== openThreadRequestId) return; + + startTransition(() => { + useAppStore.getState().openThreadStandalone(threadId); + if (options?.focusComposer) { + useAppStore.getState().requestComposerFocus(threadId); + } + if (thread?.presentationMode === "gui") { + useAppStore.getState().requestChatScrollToBottom(threadId); + } + }); + + if (thread?.status === "inactive") { + reopenStoredThread(threadId); + } + }; + + if (shouldHydrate) { + void hydrateThreadRuntimeItems(thread.id).then(applyOpen, applyOpen); + return; + } + + applyOpen(); +} + function getGuiThreadIdsToHydrateBeforeOpen(threadId: string): string[] { const state = useAppStore.getState(); const clickedThread = state.threads.find((thread) => thread.id === threadId); @@ -216,7 +251,7 @@ export function sweepStaleThreads(): void { if (staleThreadUnloadMinutes <= 0) return; const store = useAppStore.getState(); - const visibleThreadIds = new Set(store.view.kind === "thread" ? store.view.panes : []); + const visibleThreadIds = new Set(getCurrentViewThreadIds(store)); const staleBefore = Date.now() - staleThreadUnloadMinutes * 60_000; for (const thread of store.threads) { @@ -234,6 +269,14 @@ export function sweepStaleThreads(): void { } } +function getCurrentViewThreadIds(store: ReturnType): string[] { + if (store.view.kind === "thread") return store.view.panes; + if (store.view.kind === "experiment") { + return store.experiments[store.view.experimentId]?.candidates.map((c) => c.threadId) ?? []; + } + return []; +} + export function archiveThread(threadId: string): void { void unloadStoredThread(threadId).catch(() => undefined); useAppStore.getState().archiveThread(threadId); diff --git a/src/renderer/components/experiment/NewExperimentModal.tsx b/src/renderer/components/experiment/NewExperimentModal.tsx new file mode 100644 index 00000000..54f6365e --- /dev/null +++ b/src/renderer/components/experiment/NewExperimentModal.tsx @@ -0,0 +1,346 @@ +import { useState } from "react"; +import { useShallow } from "zustand/shallow"; +import { Button, Label, Modal } from "@heroui/react"; +import { FlaskConical, Loader2, Plus, X } from "lucide-react"; +import type { AgentStatus, Project, PromptSegment, ThreadConfig } from "@/shared/contracts"; +import { getProjectAgentStatuses } from "@/shared/agentStatus"; +import { useAppStore } from "@/renderer/state/appStore"; +import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore"; +import { useGitStore } from "@/renderer/state/gitStore"; +import { useExperimentLauncherStore } from "@/renderer/state/experimentLauncherStore"; +import { + launchExperiment, + resolveProjectBaseBranch, + type ExperimentCandidateSpec, +} from "@/renderer/actions/experimentActions"; + +interface CandidateDraft { + agentKind: string; + model: string; + baseConfig: ThreadConfig | null; +} + +export function NewExperimentModal() { + const { open, projectId, initialPrompt, initialSegments, initialCandidate, sessionId, close } = + useExperimentLauncherStore( + useShallow((s) => ({ + open: s.open, + projectId: s.projectId, + initialPrompt: s.initialPrompt, + initialSegments: s.initialSegments, + initialCandidate: s.initialCandidate, + sessionId: s.sessionId, + close: s.close, + })), + ); + + return ( + { + if (!next) close(); + }} + > + + + {open && projectId ? ( + + ) : null} + + + + ); +} + +function defaultModelForAgent(agent: AgentStatus | undefined): string { + return agent?.capabilities.models[0]?.id ?? ""; +} + +function defaultConfigForAgent(agent: AgentStatus | undefined, model: string): ThreadConfig { + const caps = agent?.capabilities; + return { + model, + ...(caps?.defaultEffort ? { effort: caps.defaultEffort } : {}), + ...(caps?.defaultApprovalPolicy ? { approvalPolicy: caps.defaultApprovalPolicy } : {}), + ...(caps?.defaultSandboxMode ? { sandboxMode: caps.defaultSandboxMode } : {}), + ...(caps?.defaultContextSize ? { contextSize: caps.defaultContextSize } : {}), + }; +} + +function NewExperimentModalInner(props: { + projectId: string; + initialPrompt: string; + initialSegments: PromptSegment[]; + initialCandidate: { agentKind: string; config?: ThreadConfig; model?: string } | null; + onClose: () => void; +}) { + const project = useAppStore( + useShallow((s) => s.projects.find((p) => p.id === props.projectId)), + ) as Project | undefined; + + const agents = useAgentStatusesStore( + useShallow((s) => + project ? getProjectAgentStatuses(project.location, s.agentStatuses, s.wslAgentStatuses) : [], + ), + ); + const installedAgents = agents.filter((a) => a.installed && a.capabilities.models.length > 0); + + const branchList = useGitStore((s) => s.branches[props.projectId]); + const localBranches = branchList?.branches.map((b) => b.name) ?? []; + + const agentByKind = (kind: string): AgentStatus | undefined => + installedAgents.find((a) => a.kind === kind); + + const [prompt, setPrompt] = useState(props.initialPrompt); + const [baseBranch, setBaseBranch] = useState( + () => resolveProjectBaseBranch(props.projectId) ?? branchList?.current ?? "", + ); + const [submitting, setSubmitting] = useState(false); + const [candidates, setCandidates] = useState(() => { + if (installedAgents.length === 0) return []; + const seedKind = + props.initialCandidate && agentByKind(props.initialCandidate.agentKind) + ? props.initialCandidate.agentKind + : installedAgents[0]!.kind; + const seedAgent = agentByKind(seedKind); + const first: CandidateDraft = { + agentKind: seedKind, + model: + props.initialCandidate?.config?.model ?? + props.initialCandidate?.model ?? + defaultModelForAgent(seedAgent), + baseConfig: props.initialCandidate?.config ?? null, + }; + // Seed a second row to nudge toward an actual fan-out: a different provider + // when one is installed, otherwise the same agent again (a "voting" run). + const secondAgent = installedAgents.find((a) => a.kind !== seedKind) ?? seedAgent; + const second: CandidateDraft = { + agentKind: secondAgent?.kind ?? seedKind, + model: defaultModelForAgent(secondAgent), + baseConfig: null, + }; + return [first, second]; + }); + + function updateCandidate(idx: number, patch: Partial) { + setCandidates((prev) => prev.map((c, i) => (i === idx ? { ...c, ...patch } : c))); + } + + function changeAgent(idx: number, agentKind: string) { + updateCandidate(idx, { + agentKind, + model: defaultModelForAgent(agentByKind(agentKind)), + baseConfig: null, + }); + } + + function addCandidate() { + const agent = installedAgents[0]; + if (!agent) return; + setCandidates((prev) => [ + ...prev, + { agentKind: agent.kind, model: defaultModelForAgent(agent), baseConfig: null }, + ]); + } + + function removeCandidate(idx: number) { + setCandidates((prev) => prev.filter((_, i) => i !== idx)); + } + + const canLaunch = + !!project && + prompt.trim().length > 0 && + candidates.length >= 2 && + candidates.every((c) => { + const agent = agentByKind(c.agentKind); + return !!agent && agent.capabilities.models.some((m) => m.id === c.model); + }) && + !submitting; + + async function handleLaunch() { + if (!project || !canLaunch) return; + const trimmedPrompt = prompt.trim(); + const segments = + props.initialSegments.length > 0 && trimmedPrompt === props.initialPrompt.trim() + ? props.initialSegments + : undefined; + const specs: ExperimentCandidateSpec[] = candidates.map((c) => { + const agent = agentByKind(c.agentKind); + const caps = agent?.capabilities; + const defaults = defaultConfigForAgent(agent, c.model); + return { + agentKind: c.agentKind, + ...(agent ? { agentLabel: agent.label } : {}), + config: { + ...defaults, + ...(c.baseConfig ?? {}), + model: c.model, + }, + ...(caps ? { presentationMode: caps.presentationMode } : {}), + }; + }); + setSubmitting(true); + try { + const id = await launchExperiment({ + project, + prompt: trimmedPrompt, + ...(segments ? { segments } : {}), + ...(baseBranch ? { baseBranch } : {}), + candidates: specs, + }); + if (id) props.onClose(); + } finally { + setSubmitting(false); + } + } + + const selectClass = + "rounded-md border border-[var(--hairline)] bg-transparent px-2 py-1 text-sm outline-none"; + + return ( + <> + + + + + + New experiment + + +

+ Fan one prompt out across multiple agents, then compare and merge the winner. +

+
+ + {installedAgents.length === 0 ? ( +

+ No installed agents with selectable models for this project. +

+ ) : ( + <> +
+ +