From f2e06abb624a439d4993abc3791fba04c6eca2de Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:36:36 -0400 Subject: [PATCH 1/4] feat(multi-run): add a runs sheet with inline fusion Replace the centered Multi-run dialog and the stacked Fusion dialog with one right-side runs sheet, turn fusion into an inline selection mode, and pre-approve each selected source workspace for the fusion session. - Move MultiRunDialog into a SideDrawer with Runs / New run tabs; add MultiRunCard, FusionComposer and an optimistic starting fusion. - Add buildFusionSourcePermissionRuleset (external_directory allow, edit deny) and thread permissions through SessionLauncher.launch. - Apply the default permission mode to fusion sessions after launch. - Let ModelCombobox and BranchCombobox take a listClassName; make SideDrawer ignore an Escape already handled by a nested layer. --- backend/src/index.ts | 2 +- backend/src/services/multi-run-fusion.ts | 29 + backend/src/services/multi-runs.ts | 13 +- backend/src/services/session-launcher.ts | 4 + .../src/services/session-permission-modes.ts | 15 + backend/test/services/multi-runs.test.ts | 114 ++- .../test/services/session-launcher.test.ts | 26 + .../services/session-permission-modes.test.ts | 48 ++ .../src/components/model/ModelCombobox.tsx | 10 +- .../src/components/repo/BranchCombobox.tsx | 3 + .../components/repo/FuseRunDialog.test.tsx | 419 ---------- .../src/components/repo/FuseRunDialog.tsx | 285 ------- .../src/components/repo/FusionComposer.tsx | 254 ++++++ frontend/src/components/repo/MultiRunCard.tsx | 408 ++++++++++ .../components/repo/MultiRunDialog.test.tsx | 575 -------------- .../src/components/repo/MultiRunDialog.tsx | 447 ----------- .../components/repo/MultiRunSheet.test.tsx | 735 ++++++++++++++++++ .../src/components/repo/MultiRunSheet.tsx | 401 ++++++++++ .../src/components/ui/side-drawer.test.tsx | 14 + frontend/src/components/ui/side-drawer.tsx | 2 +- frontend/src/hooks/useMultiRuns.ts | 44 +- frontend/src/pages/RepoDetail.tsx | 4 +- shared/src/schemas/multi-runs.ts | 6 + 23 files changed, 2122 insertions(+), 1736 deletions(-) delete mode 100644 frontend/src/components/repo/FuseRunDialog.test.tsx delete mode 100644 frontend/src/components/repo/FuseRunDialog.tsx create mode 100644 frontend/src/components/repo/FusionComposer.tsx create mode 100644 frontend/src/components/repo/MultiRunCard.tsx delete mode 100644 frontend/src/components/repo/MultiRunDialog.test.tsx delete mode 100644 frontend/src/components/repo/MultiRunDialog.tsx create mode 100644 frontend/src/components/repo/MultiRunSheet.test.tsx create mode 100644 frontend/src/components/repo/MultiRunSheet.tsx diff --git a/backend/src/index.ts b/backend/src/index.ts index 4dcedd4ee..b7b9409cd 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -271,7 +271,7 @@ const sessionGoalService = new SessionGoalService(db, openCodeClient, sessionSet }) sessionGoalService.loadOpenGoals() -const multiRunService = new MultiRunService(db, openCodeClient, repoWorkspaces) +const multiRunService = new MultiRunService(db, openCodeClient, repoWorkspaces, sessionPermissionModeService) const changeWalkthroughService = new ChangeWalkthroughService(db, openCodeClient) sseAggregator.onEvent((directory, event) => { diff --git a/backend/src/services/multi-run-fusion.ts b/backend/src/services/multi-run-fusion.ts index aae081bc5..25970ceb2 100644 --- a/backend/src/services/multi-run-fusion.ts +++ b/backend/src/services/multi-run-fusion.ts @@ -1,3 +1,4 @@ +import { resolve } from 'node:path' import { isSessionNotFoundError, type FileDiffInfo, @@ -7,6 +8,7 @@ import { FUSION_PROMPT_MAX_LENGTH, type FusionUnavailableSource, type MultiRunFusionSource, + type SchedulePermissionRuleset, } from '@opencode-manager/shared/schemas' import type { MultiRunEntryRecord } from '../db/multi-runs' import { getErrorMessage } from '../utils/error-utils' @@ -42,6 +44,33 @@ const FUSION_REPLY_BUDGET_RATIO = 0.4 const FUSION_BLOCK_SEPARATOR = '\n\n' +/** + * Builds the OpenCode session permission ruleset that lets a fusion session read + * its selected source workspaces while denying any edit to them. + * + * Each unique directory is normalized with `resolve` before a pair of rules is + * emitted: an `external_directory` allow so the session may read outside its own + * workspace, followed by an `edit` deny so the read-only reference cannot be + * mutated. Deduplication happens after normalization. + */ +export function buildFusionSourcePermissionRuleset(directories: string[]): SchedulePermissionRuleset { + const seen = new Set() + const ruleset: SchedulePermissionRuleset = [] + + for (const directory of directories) { + const normalized = resolve(directory).replaceAll('\\', '/') + if (seen.has(normalized)) { + continue + } + + seen.add(normalized) + ruleset.push({ action: 'external_directory', resource: `${normalized}/*`, effect: 'allow' }) + ruleset.push({ action: 'edit', resource: `${normalized}/*`, effect: 'deny' }) + } + + return ruleset +} + export class FusionContextLimitError extends ServiceError { constructor(requiredPerSource: number, availablePerSource: number) { super( diff --git a/backend/src/services/multi-runs.ts b/backend/src/services/multi-runs.ts index 99345af25..5fcc61cb7 100644 --- a/backend/src/services/multi-runs.ts +++ b/backend/src/services/multi-runs.ts @@ -19,11 +19,12 @@ import { getRepoById } from '../db/queries' import type { Repo } from '../types/repo' import { getErrorMessage } from '../utils/error-utils' import { ServiceError } from '../utils/service-error' -import { buildFusionPrompt, collectFusionSources } from './multi-run-fusion' +import { buildFusionPrompt, buildFusionSourcePermissionRuleset, collectFusionSources } from './multi-run-fusion' import type { OpenCodeClient } from './opencode/client' import { RepoWorkspaceError } from './repo' import type { RepoWorkspaceService } from './repo-workspace' import { requireReadyRepo, SessionLauncher, SessionLaunchError, type LaunchedSession } from './session-launcher' +import type { SessionPermissionModeService } from './session-permission-modes' const MULTI_RUN_LIST_LIMIT = 20 @@ -75,6 +76,7 @@ export class MultiRunService { private readonly db: Database, private readonly openCodeClient: OpenCodeClient, private readonly repoWorkspaces: RepoWorkspaceService, + private readonly permissionModes: SessionPermissionModeService, ) { this.sessionLauncher = new SessionLauncher(db, openCodeClient, repoWorkspaces) } @@ -194,6 +196,12 @@ export class MultiRunService { } } + const sourcePermissions = buildFusionSourcePermissionRuleset( + selectedEntries + .map((entry) => entry.directory) + .filter((directory): directory is string => directory !== null), + ) + try { await this.sessionLauncher.resolveModel(repo, request.model) } catch (error) { @@ -241,6 +249,7 @@ export class MultiRunService { model: request.model, title: `${record.name} · fusion`, ...(request.agent ? { agent: request.agent } : {}), + ...(sourcePermissions.length > 0 ? { permissions: sourcePermissions } : {}), ...(request.isolate ? { workspace: { @@ -256,6 +265,8 @@ export class MultiRunService { sessionId: launched.sessionId, directory: launched.directory, }) + + await this.permissionModes.applyDefaultMode(launched.sessionId, launched.directory) } catch (error) { const launchError = error instanceof SessionLaunchError ? error : null const workspaceDirectory = launchError?.workspaceDirectory ?? null diff --git a/backend/src/services/session-launcher.ts b/backend/src/services/session-launcher.ts index 76c078591..21202f359 100644 --- a/backend/src/services/session-launcher.ts +++ b/backend/src/services/session-launcher.ts @@ -8,6 +8,8 @@ import type { OpenCodeClient } from './opencode/client' import { resolveOpenCodeModel, type ResolvedOpenCodeModel } from './opencode-models' import type { RepoWorkspaceService } from './repo-workspace' +type SessionCreateInput = NonNullable[0]> + interface LaunchSessionInput { repoId: number prompt: string @@ -15,6 +17,7 @@ interface LaunchSessionInput { model?: string agent?: string workspace?: { name?: string; ref?: string } + permissions?: SessionCreateInput['permissions'] } export interface LaunchedSession { @@ -109,6 +112,7 @@ export class SessionLauncher { session = await this.openCodeClient.api.session.create({ ...(input.title ? { title: input.title } : {}), ...(input.agent ? { agent: input.agent } : {}), + ...(input.permissions ? { permissions: input.permissions } : {}), model: { providerID: model.providerID, id: model.id, diff --git a/backend/src/services/session-permission-modes.ts b/backend/src/services/session-permission-modes.ts index 0d908fab8..ba41f859c 100644 --- a/backend/src/services/session-permission-modes.ts +++ b/backend/src/services/session-permission-modes.ts @@ -126,6 +126,21 @@ export class SessionPermissionModeService { return this.settingsService.getSettings().preferences.sessionDefaults?.permissionMode ?? 'ask' } + /** + * Records the default permission mode for a session after it has been launched + * and accepts any permission requests it raised before the mode was stored. + * + * Does nothing when the default mode is `ask`, since that needs no stored row. + */ + async applyDefaultMode(sessionId: string, directory: string): Promise { + if (this.defaultMode() !== 'auto') { + return + } + + insertSessionPermissionModeIfAbsent(this.db, sessionId, 'auto') + await this.acceptPendingRequestsInDirectory(directory, sessionId) + } + async acceptPendingRequestsForActiveSessions(): Promise { try { const active = await this.openCodeClient.api.session.active() diff --git a/backend/test/services/multi-runs.test.ts b/backend/test/services/multi-runs.test.ts index 0dd6dddb0..00187247f 100644 --- a/backend/test/services/multi-runs.test.ts +++ b/backend/test/services/multi-runs.test.ts @@ -4,10 +4,13 @@ import { createRepo, deleteRepo } from '../../src/db/queries' import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' import { createMultiRunWithEntries } from '../../src/db/multi-runs' +import { getSessionPermissionMode } from '../../src/db/session-permission-modes' import { FusionContextLimitError } from '../../src/services/multi-run-fusion' import { MultiRunError, MultiRunService } from '../../src/services/multi-runs' import { RepoWorkspaceError } from '../../src/services/repo' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' +import { SessionPermissionModeService } from '../../src/services/session-permission-modes' +import { SettingsService } from '../../src/services/settings' import type { Repo } from '../../src/types/repo' import type { OpenCodeClient } from '../../src/services/opencode/client' import { assistantMessage } from '../helpers/stub-schedule-api' @@ -109,6 +112,9 @@ function createClient(sessions: Record = {}): FakeMul const sessionDiff = vi.fn(async () => []) + const permissionRequestList = vi.fn(async () => ({ data: [] })) + const permissionReply = vi.fn(async () => ({})) + const client = { api: { session: { @@ -119,6 +125,10 @@ function createClient(sessions: Record = {}): FakeMul diff: sessionDiff, }, message: { list: messageList }, + permission: { + request: { list: permissionRequestList }, + reply: permissionReply, + }, }, } as unknown as OpenCodeClient @@ -198,8 +208,16 @@ describe('MultiRunService', () => { return repo.id } - function createService(client: OpenCodeClient, repoWorkspaces: RepoWorkspaceService): MultiRunService { - return new MultiRunService(db, client, repoWorkspaces) + function createService( + client: OpenCodeClient, + repoWorkspaces: RepoWorkspaceService, + permissionModes: SessionPermissionModeService = new SessionPermissionModeService( + db, + client, + new SettingsService(db), + ), + ): MultiRunService { + return new MultiRunService(db, client, repoWorkspaces, permissionModes) } it('launches one session per model, each in its own workspace named from the group', async () => { @@ -641,6 +659,98 @@ describe('MultiRunService', () => { ).toEqual(before) }) + it('grants read access to the selected source workspaces and denies edits when fusing', async () => { + const repoId = readyRepo() + const { client, sessionCreate } = createClient() + const repoWorkspaces = createRepoWorkspaces() + const service = createService(client, repoWorkspaces.service) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a', 'openai/b', 'openai/c'], + isolate: true, + }) + const entryIds = run.entries.map((entry) => entry.id) + sessionCreate.mockClear() + + await service.fuse(run.id, { + requestId: FUSE_REQUEST_ID, + entryIds: [entryIds[0]!, entryIds[2]!], + model: 'openai/d', + isolate: true, + }) + + expect(sessionCreate).toHaveBeenCalledTimes(1) + expect(sessionCreate.mock.calls[0]![0].permissions).toEqual([ + { action: 'external_directory', resource: '/worktrees/Sweep-1/*', effect: 'allow' }, + { action: 'edit', resource: '/worktrees/Sweep-1/*', effect: 'deny' }, + { action: 'external_directory', resource: '/worktrees/Sweep-3/*', effect: 'allow' }, + { action: 'edit', resource: '/worktrees/Sweep-3/*', effect: 'deny' }, + ]) + const serializedPermissions = JSON.stringify(sessionCreate.mock.calls[0]![0].permissions) + expect(serializedPermissions).not.toContain('Sweep-2') + expect(serializedPermissions).not.toContain('Sweep-fusion-1') + }) + + it('stores the default auto mode on the fusion session', async () => { + const repoId = readyRepo() + const { client, sessionCreate } = createClient() + const repoWorkspaces = createRepoWorkspaces() + const settingsService = new SettingsService(db) + settingsService.updateSettings({ sessionDefaults: { permissionMode: 'auto' } }) + const permissionModes = new SessionPermissionModeService(db, client, settingsService) + const service = createService(client, repoWorkspaces.service, permissionModes) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a', 'openai/b'], + isolate: true, + }) + const entryIds = run.entries.map((entry) => entry.id) + sessionCreate.mockClear() + + const { run: fused } = await service.fuse(run.id, { + requestId: FUSE_REQUEST_ID, + entryIds, + model: 'openai/c', + isolate: false, + }) + + const fusionSessionId = fused.fusions[0]!.sessionId! + expect(getSessionPermissionMode(db, fusionSessionId)).toBe('auto') + }) + + it('stores no mode on the fusion session when the default is ask', async () => { + const repoId = readyRepo() + const { client, sessionCreate } = createClient() + const repoWorkspaces = createRepoWorkspaces() + const service = createService(client, repoWorkspaces.service) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a', 'openai/b'], + isolate: true, + }) + const entryIds = run.entries.map((entry) => entry.id) + sessionCreate.mockClear() + + const { run: fused } = await service.fuse(run.id, { + requestId: FUSE_REQUEST_ID, + entryIds, + model: 'openai/c', + isolate: false, + }) + + const fusionSessionId = fused.fusions[0]!.sessionId! + expect(getSessionPermissionMode(db, fusionSessionId)).toBeNull() + }) + it('returns the same fusion for a repeated requestId and launches once', async () => { const repoId = readyRepo() const { client, sessionCreate } = createClient() diff --git a/backend/test/services/session-launcher.test.ts b/backend/test/services/session-launcher.test.ts index 40546fd61..9362414a5 100644 --- a/backend/test/services/session-launcher.test.ts +++ b/backend/test/services/session-launcher.test.ts @@ -122,6 +122,32 @@ describe('SessionLauncher', () => { }) }) + it('forwards a permission ruleset to session creation when provided', async () => { + const repoId = readyRepo() + const { client, create } = createClient() + const { service } = createRepoWorkspaces() + const launcher = new SessionLauncher(db, client, service) + const permissions = [ + { action: 'external_directory', resource: '/repos/source/*', effect: 'allow' as const }, + { action: 'edit', resource: '/repos/source/*', effect: 'deny' as const }, + ] + + await launcher.launch({ repoId, prompt: 'hello', permissions }) + + expect(create).toHaveBeenCalledWith(expect.objectContaining({ permissions })) + }) + + it('omits the permissions key from session creation when none are given', async () => { + const repoId = readyRepo() + const { client, create } = createClient() + const { service } = createRepoWorkspaces() + const launcher = new SessionLauncher(db, client, service) + + await launcher.launch({ repoId, prompt: 'hello' }) + + expect(create.mock.calls[0]![0]).not.toHaveProperty('permissions') + }) + it('launches in a new workspace and prompts there', async () => { const repoId = readyRepo() const { client, create } = createClient() diff --git a/backend/test/services/session-permission-modes.test.ts b/backend/test/services/session-permission-modes.test.ts index c4d6f4c67..0f1263738 100644 --- a/backend/test/services/session-permission-modes.test.ts +++ b/backend/test/services/session-permission-modes.test.ts @@ -177,6 +177,54 @@ describe('SessionPermissionModeService', () => { expect(service.defaultMode()).toBe('auto') }) + it('stores the default auto mode for a launched session', async () => { + const settingsService = new SettingsService(db) + settingsService.updateSettings({ sessionDefaults: { permissionMode: 'auto' } }) + const service = new SessionPermissionModeService(db, createFakeSessionPermissionClient(), settingsService) + + await service.applyDefaultMode('ses_launched', DIRECTORY) + + expect(getSessionPermissionMode(db, 'ses_launched')).toBe('auto') + }) + + it('stores no mode for a launched session when the default is ask', async () => { + const service = new SessionPermissionModeService(db, createFakeSessionPermissionClient(), new SettingsService(db)) + + await service.applyDefaultMode('ses_launched', DIRECTORY) + + expect(getSessionPermissionMode(db, 'ses_launched')).toBeNull() + }) + + it('does not override an existing stored ask when applying the default auto mode', async () => { + const settingsService = new SettingsService(db) + settingsService.updateSettings({ sessionDefaults: { permissionMode: 'auto' } }) + setSessionPermissionMode(db, 'ses_launched', 'ask') + const service = new SessionPermissionModeService(db, createFakeSessionPermissionClient(), settingsService) + + await service.applyDefaultMode('ses_launched', DIRECTORY) + + expect(getSessionPermissionMode(db, 'ses_launched')).toBe('ask') + }) + + it('accepts a pending request raised before the launched session mode was recorded', async () => { + const settingsService = new SettingsService(db) + settingsService.updateSettings({ sessionDefaults: { permissionMode: 'auto' } }) + const client = createFakeSessionPermissionClient({ + parents: { ses_launched: null }, + pendingRequests: { [DIRECTORY]: [{ id: 'perm-launched', sessionID: 'ses_launched' }] }, + }) + const service = new SessionPermissionModeService(db, client, settingsService) + + await service.applyDefaultMode('ses_launched', DIRECTORY) + + expect(client.replyPermission).toHaveBeenCalledTimes(1) + expect(client.replyPermission).toHaveBeenCalledWith({ + sessionID: 'ses_launched', + requestID: 'perm-launched', + decision: 'once', + }) + }) + it('auto-accepts a permission request for an auto root session', async () => { setSessionPermissionMode(db, 'ses_root', 'auto') const client = createFakeSessionPermissionClient({ parents: { ses_root: null } }) diff --git a/frontend/src/components/model/ModelCombobox.tsx b/frontend/src/components/model/ModelCombobox.tsx index d0a7bb084..dfdcf10ad 100644 --- a/frontend/src/components/model/ModelCombobox.tsx +++ b/frontend/src/components/model/ModelCombobox.tsx @@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react' import { Combobox, type ComboboxOption } from '@/components/ui/combobox' import { useModelSections } from '@/hooks/useModelSections' import { filterModelSections, toModelComboboxOptions } from '@/lib/modelSections' +import { cn } from '@/lib/utils' interface ModelComboboxProps { value: string @@ -15,6 +16,8 @@ interface ModelComboboxProps { id?: string disabled?: boolean className?: string + listClassName?: string + emptyMeansDefault?: boolean } export function ModelCombobox({ @@ -29,8 +32,11 @@ export function ModelCombobox({ id, disabled, className, + listClassName, + emptyMeansDefault = true, }: ModelComboboxProps) { - const { sections, defaultModel } = useModelSections(directory, { enabled }) + const { sections, defaultModel: openCodeDefaultModel } = useModelSections(directory, { enabled }) + const defaultModel = emptyMeansDefault ? openCodeDefaultModel : null const options = useMemo(() => toModelComboboxOptions(sections, defaultModel), [sections, defaultModel]) @@ -53,7 +59,7 @@ export function ModelCombobox({ ariaLabel={ariaLabel} disabled={disabled} className={className} - listClassName="max-h-[min(24rem,60vh)]" + listClassName={cn('max-h-[min(24rem,60vh)]', listClassName)} /> ) } diff --git a/frontend/src/components/repo/BranchCombobox.tsx b/frontend/src/components/repo/BranchCombobox.tsx index ea74d13fa..06353d172 100644 --- a/frontend/src/components/repo/BranchCombobox.tsx +++ b/frontend/src/components/repo/BranchCombobox.tsx @@ -17,6 +17,7 @@ interface BranchComboboxProps { clearable?: boolean id?: string ariaLabel?: string + listClassName?: string } /** @@ -34,6 +35,7 @@ export function BranchCombobox({ clearable = false, id, ariaLabel, + listClassName, }: BranchComboboxProps) { const { data: branchesData, isLoading } = useRepoBranches(repoId, enabled) @@ -69,6 +71,7 @@ export function BranchCombobox({ allowCustomValue={false} showClear={clearable} ariaLabel={ariaLabel} + listClassName={listClassName} /> ) } diff --git a/frontend/src/components/repo/FuseRunDialog.test.tsx b/frontend/src/components/repo/FuseRunDialog.test.tsx deleted file mode 100644 index aa502aa37..000000000 --- a/frontend/src/components/repo/FuseRunDialog.test.tsx +++ /dev/null @@ -1,419 +0,0 @@ -import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest' -import { render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import type { ReactNode } from 'react' -import { FuseRunDialog } from './FuseRunDialog' -import { FetchError } from '@/api/fetchWrapper' -import type { FuseMultiRunRequest, MultiRun } from '@opencode-manager/shared/schemas' - -const mocks = vi.hoisted(() => ({ - fuseMultiRun: vi.fn(), - listMultiRuns: vi.fn(), - launchMultiRun: vi.fn(), - discardMultiRunEntry: vi.fn(), - useProviders: vi.fn(), - useOpenCodeModelState: vi.fn(), - useOpenCodeDefaultModel: vi.fn(), - listBranches: vi.fn(), -})) - -vi.mock('@/api/multiRuns', () => ({ - fuseMultiRun: mocks.fuseMultiRun, - listMultiRuns: mocks.listMultiRuns, - launchMultiRun: mocks.launchMultiRun, - discardMultiRunEntry: mocks.discardMultiRunEntry, -})) - -vi.mock('@/hooks/useProviders', () => ({ - useProviders: mocks.useProviders, -})) - -vi.mock('@/hooks/useModelSelection', () => ({ - useOpenCodeModelState: mocks.useOpenCodeModelState, - useOpenCodeDefaultModel: mocks.useOpenCodeDefaultModel, -})) - -vi.mock('@/api/repos', () => ({ - listBranches: mocks.listBranches, -})) - -vi.mock('@/lib/toast', () => ({ - showToast: { - success: vi.fn(), - error: vi.fn(), - warning: vi.fn(), - info: vi.fn(), - }, -})) - -const providers = [ - { - id: 'openai', - name: 'OpenAI', - models: [ - { id: 'gpt-4o', name: 'GPT-4o', released: 0, free: false }, - { id: 'gpt-4.1', name: 'GPT-4.1', released: 0, free: false }, - ], - }, - { - id: 'anthropic', - name: 'Anthropic', - models: [{ id: 'claude-opus', name: 'Claude Opus', released: 0, free: false }], - }, -] - -const run: MultiRun = { - id: 3, - repoId: 7, - name: 'Sweep', - prompt: 'go', - isolated: true, - baseRef: 'main', - createdAt: 1, - entries: [ - { - id: 11, - model: 'openai/gpt-4o', - status: 'started', - sessionId: 'ses_1', - directory: '/workspaces/sweep-1', - isolated: true, - error: null, - createdAt: 1, - updatedAt: 1, - }, - { - id: 12, - model: 'anthropic/claude-opus', - status: 'started', - sessionId: 'ses_2', - directory: '/workspaces/sweep-2', - isolated: true, - error: null, - createdAt: 1, - updatedAt: 1, - }, - { - id: 13, - model: 'openai/gpt-4.1', - status: 'failed', - sessionId: null, - directory: null, - isolated: true, - error: 'launch failed', - createdAt: 1, - updatedAt: 1, - }, - ], - fusions: [], -} - -const nonIsolatedRun: MultiRun = { - ...run, - entries: run.entries.map((entry) => (entry.id === 11 ? { ...entry, isolated: false } : entry)), -} - -function createWrapper() { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - return ({ children }: { children: ReactNode }) => ( - {children} - ) -} - -function renderFuseDialog(overrides: Partial> = {}) { - const onOpenChange = vi.fn() - const onOpenSession = vi.fn() - const view = render( - , - { wrapper: createWrapper() }, - ) - return { ...view, onOpenChange, onOpenSession } -} - -async function selectSourcesAndModel(user: ReturnType) { - await user.click(screen.getByRole('checkbox', { name: 'openai/gpt-4o' })) - await user.click(screen.getByRole('checkbox', { name: 'anthropic/claude-opus' })) - await user.click(screen.getByRole('checkbox', { name: 'GPT-4o' })) -} - -function synthesisCheckboxLabels() { - return screen - .getAllByRole('checkbox') - .map((checkbox) => checkbox.getAttribute('aria-label') ?? '') - .filter((label) => !label.includes('/')) -} - -describe('FuseRunDialog', () => { - beforeAll(() => { - Element.prototype.hasPointerCapture ??= () => false - Element.prototype.setPointerCapture ??= () => {} - Element.prototype.releasePointerCapture ??= () => {} - Element.prototype.scrollIntoView ??= () => {} - }) - - beforeEach(() => { - vi.clearAllMocks() - mocks.useProviders.mockReturnValue({ data: { providers, models: [] }, isLoading: false }) - mocks.useOpenCodeModelState.mockReturnValue({ data: { recent: [], favorite: [], variant: {} } }) - mocks.useOpenCodeDefaultModel.mockReturnValue({ data: null }) - mocks.listBranches.mockResolvedValue({ - branches: [ - { name: 'main', type: 'local', current: true }, - { name: 'feature', type: 'local', current: false }, - ], - status: { ahead: 0, behind: 0 }, - }) - }) - - it('only allows started sources and blocks submit until two sources and a model are selected', async () => { - const user = userEvent.setup() - renderFuseDialog() - - const failed = screen.getByRole('checkbox', { name: 'openai/gpt-4.1' }) - expect(failed).toBeDisabled() - - const submit = screen.getByRole('button', { name: 'Fuse' }) - expect(submit).toBeDisabled() - - await user.click(screen.getByRole('checkbox', { name: 'openai/gpt-4o' })) - expect(submit).toBeDisabled() - - await user.click(screen.getByRole('checkbox', { name: 'anthropic/claude-opus' })) - expect(submit).toBeDisabled() - - await user.click(screen.getByRole('checkbox', { name: 'GPT-4o' })) - expect(submit).toBeEnabled() - }) - - it('replaces the synthesis model when a different model is toggled', async () => { - const user = userEvent.setup() - renderFuseDialog() - - await user.click(screen.getByRole('checkbox', { name: 'GPT-4o' })) - expect(screen.getByRole('checkbox', { name: 'GPT-4o' })).toBeChecked() - - await user.click(screen.getByRole('checkbox', { name: 'Claude Opus' })) - expect(screen.getByRole('checkbox', { name: 'GPT-4o' })).not.toBeChecked() - expect(screen.getByRole('checkbox', { name: 'Claude Opus' })).toBeChecked() - }) - - it('submits the selected sources, model, instructions and workspace', async () => { - const user = userEvent.setup() - mocks.fuseMultiRun.mockResolvedValue({ ...run, fusions: [] }) - renderFuseDialog() - - await selectSourcesAndModel(user) - await user.type(screen.getByLabelText('Instructions'), 'Merge the best ideas') - await user.click(screen.getByRole('button', { name: 'Fuse' })) - - await waitFor(() => { - expect(mocks.fuseMultiRun).toHaveBeenCalledTimes(1) - }) - const [runId, request] = mocks.fuseMultiRun.mock.calls[0] as [number, FuseMultiRunRequest] - expect(runId).toBe(3) - expect(request).toEqual({ - requestId: expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/), - entryIds: [11, 12], - model: 'openai/gpt-4o', - instructions: 'Merge the best ideas', - isolate: true, - baseRef: 'main', - }) - }) - - it('does not submit again while the request is pending', async () => { - const user = userEvent.setup() - mocks.fuseMultiRun.mockReturnValue(new Promise(() => {})) - renderFuseDialog() - - await selectSourcesAndModel(user) - const submit = screen.getByRole('button', { name: 'Fuse' }) - await user.click(submit) - await user.click(submit) - - expect(mocks.fuseMultiRun).toHaveBeenCalledTimes(1) - }) - - it('lists unavailable sources and reasons from a 409 response', async () => { - const user = userEvent.setup() - mocks.fuseMultiRun.mockRejectedValue( - new FetchError('Some selected results are not ready to fuse', 409, 'FUSION_SOURCES_UNAVAILABLE', undefined, { - details: { - unavailableSources: [ - { entryId: 12, model: 'anthropic/claude-opus', reason: 'running', message: 'The session is still running.' }, - ], - }, - }), - ) - renderFuseDialog() - - await selectSourcesAndModel(user) - await user.click(screen.getByRole('button', { name: 'Fuse' })) - - expect(await screen.findByText('anthropic/claude-opus: The session is still running.')).toBeInTheDocument() - }) - - it('shows the context limit message for a 413 response', async () => { - const user = userEvent.setup() - mocks.fuseMultiRun.mockRejectedValue( - new FetchError('The selected results are too large to fuse.', 413, 'FUSION_CONTEXT_LIMIT'), - ) - renderFuseDialog() - - await selectSourcesAndModel(user) - await user.click(screen.getByRole('button', { name: 'Fuse' })) - - expect( - await screen.findByText('Too much context: select fewer sources or shorten instructions'), - ).toBeInTheDocument() - }) - - it('closes and opens the new session when the fusion starts', async () => { - const user = userEvent.setup() - mocks.fuseMultiRun.mockImplementation((_runId: number, request: FuseMultiRunRequest) => - Promise.resolve({ - ...run, - fusions: [ - { - id: 1, - requestId: request.requestId, - model: request.model, - instructions: null, - isolated: true, - baseRef: 'main', - status: 'started', - sessionId: 'ses_fusion', - directory: '/workspaces/fusion', - error: null, - sources: [], - createdAt: 1, - updatedAt: 1, - }, - ], - }), - ) - const { onOpenChange, onOpenSession } = renderFuseDialog() - - await selectSourcesAndModel(user) - await user.click(screen.getByRole('button', { name: 'Fuse' })) - - await waitFor(() => { - expect(onOpenSession).toHaveBeenCalledWith('ses_fusion', true) - }) - expect(onOpenChange).toHaveBeenCalledWith(false) - }) - - it('regenerates the request id after a failed fusion so retries are idempotent per failure', async () => { - const user = userEvent.setup() - mocks.fuseMultiRun.mockImplementation((_runId: number, request: FuseMultiRunRequest) => - Promise.resolve({ - ...run, - fusions: [ - { - id: 1, - requestId: request.requestId, - model: request.model, - instructions: null, - isolated: true, - baseRef: 'main', - status: 'failed', - sessionId: null, - directory: null, - error: 'launch failed', - sources: [], - createdAt: 1, - updatedAt: 1, - }, - ], - }), - ) - renderFuseDialog() - - await selectSourcesAndModel(user) - const submit = screen.getByRole('button', { name: 'Fuse' }) - await user.click(submit) - await waitFor(() => expect(mocks.fuseMultiRun).toHaveBeenCalledTimes(1)) - - await user.click(submit) - await waitFor(() => expect(mocks.fuseMultiRun).toHaveBeenCalledTimes(2)) - - const first = mocks.fuseMultiRun.mock.calls[0][1] as FuseMultiRunRequest - const second = mocks.fuseMultiRun.mock.calls[1][1] as FuseMultiRunRequest - expect(second.requestId).not.toBe(first.requestId) - }) - - it('runs in the repository checkout and omits the base ref when isolation is off', async () => { - const user = userEvent.setup() - mocks.fuseMultiRun.mockResolvedValue({ ...run, fusions: [] }) - renderFuseDialog() - - await user.click(screen.getByRole('switch', { name: 'Isolated workspace' })) - expect(screen.getByText('Runs in the repository checkout.')).toBeInTheDocument() - expect(screen.queryByRole('combobox', { name: 'Start from' })).not.toBeInTheDocument() - - await selectSourcesAndModel(user) - await user.click(screen.getByRole('button', { name: 'Fuse' })) - - await waitFor(() => expect(mocks.fuseMultiRun).toHaveBeenCalledTimes(1)) - const [, request] = mocks.fuseMultiRun.mock.calls[0] as [number, FuseMultiRunRequest] - expect(request).toMatchObject({ isolate: false }) - expect(request).not.toHaveProperty('baseRef') - }) - - it('forces isolation when a selected source ran in the repository checkout', async () => { - const user = userEvent.setup() - mocks.fuseMultiRun.mockResolvedValue({ ...nonIsolatedRun, fusions: [] }) - renderFuseDialog({ run: nonIsolatedRun }) - - expect(screen.getByRole('switch', { name: 'Isolated workspace' })).toBeEnabled() - - await selectSourcesAndModel(user) - - const toggle = screen.getByRole('switch', { name: 'Isolated workspace' }) - expect(toggle).toBeChecked() - expect(toggle).toBeDisabled() - expect( - screen.getByText('Isolation is required because a selected result ran in the repository checkout'), - ).toBeInTheDocument() - - await user.click(screen.getByRole('button', { name: 'Fuse' })) - - await waitFor(() => expect(mocks.fuseMultiRun).toHaveBeenCalledTimes(1)) - const [, request] = mocks.fuseMultiRun.mock.calls[0] as [number, FuseMultiRunRequest] - expect(request).toMatchObject({ isolate: true, baseRef: 'main' }) - }) - - it('shows the recovered-attempt message when an earlier fusion is already running', async () => { - const user = userEvent.setup() - mocks.fuseMultiRun.mockRejectedValue( - new FetchError('An earlier fusion attempt is already running', 409, 'FUSION_ATTEMPT_RECOVERED', undefined, { - details: { fusions: [{ fusionId: 1, sessionId: 'ses_1' }] }, - }), - ) - renderFuseDialog() - - await selectSourcesAndModel(user) - await user.click(screen.getByRole('button', { name: 'Fuse' })) - - expect( - await screen.findByText('An earlier attempt is already running. Open it from the Fusions list.'), - ).toBeInTheDocument() - }) - - it('lists the OpenCode default model first without repeating it', () => { - mocks.useOpenCodeDefaultModel.mockReturnValue({ data: 'anthropic/claude-opus' }) - renderFuseDialog() - - expect(screen.getByText('Default')).toBeInTheDocument() - expect(synthesisCheckboxLabels()).toEqual(['Claude Opus', 'GPT-4o', 'GPT-4.1']) - }) -}) diff --git a/frontend/src/components/repo/FuseRunDialog.tsx b/frontend/src/components/repo/FuseRunDialog.tsx deleted file mode 100644 index a6e7ce6d9..000000000 --- a/frontend/src/components/repo/FuseRunDialog.tsx +++ /dev/null @@ -1,285 +0,0 @@ -import { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react' -import { Loader2, Search } from 'lucide-react' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Textarea } from '@/components/ui/textarea' -import { Switch } from '@/components/ui/switch' -import { Checkbox } from '@/components/ui/checkbox' -import { Label } from '@/components/ui/label' -import { SessionStatusIndicator } from '@/components/ui/session-status-indicator' -import { BranchCombobox } from '@/components/repo/BranchCombobox' -import { ModelCheckboxList } from '@/components/repo/ModelCheckboxList' -import { useModelSections } from '@/hooks/useModelSections' -import { useFuseMultiRun } from '@/hooks/useMultiRuns' -import { filterModelSections } from '@/lib/modelSections' -import { randomId } from '@/lib/utils' -import { - FUSION_INSTRUCTIONS_MAX_LENGTH, - FusionUnavailableDetailsSchema, - MULTI_RUN_FUSION_MIN_SOURCES, - type FuseMultiRunRequest, - type MultiRun, - type MultiRunEntryStatus, -} from '@opencode-manager/shared/schemas' - -interface FuseRunDialogProps { - repoId: number - directory?: string - run: MultiRun - open: boolean - onOpenChange: (open: boolean) => void - onOpenSession: (sessionId: string, isolated: boolean) => void -} - -const ENTRY_STATUS_LABELS: Record = { - starting: 'Starting', - started: 'Started', - failed: 'Failed', - discarded: 'Discarded', -} - -function unavailableDetails(error: unknown) { - const parsed = FusionUnavailableDetailsSchema.safeParse((error as { details?: unknown } | null)?.details) - return parsed.success ? parsed.data : null -} - -export function FuseRunDialog({ - repoId, - directory, - run, - open, - onOpenChange, - onOpenSession, -}: FuseRunDialogProps) { - const [selectedEntryIds, setSelectedEntryIds] = useState([]) - const [selectedModel, setSelectedModel] = useState(null) - const [instructions, setInstructions] = useState('') - const [isolate, setIsolate] = useState(true) - const [baseRef, setBaseRef] = useState('') - const [modelSearch, setModelSearch] = useState('') - const [requestId, setRequestId] = useState(() => randomId()) - const deferredModelSearch = useDeferredValue(modelSearch) - - const fuse = useFuseMultiRun(repoId) - const { sections: modelSections } = useModelSections(directory, { enabled: open }) - - useEffect(() => { - if (!open) return - setSelectedEntryIds([]) - setSelectedModel(null) - setInstructions('') - setIsolate(true) - setBaseRef(run.baseRef ?? '') - setModelSearch('') - setRequestId(randomId()) - }, [open, run.id, run.baseRef]) - - const visibleModelSections = useMemo( - () => filterModelSections(modelSections, deferredModelSearch), - [modelSections, deferredModelSearch], - ) - const selectedModels = useMemo(() => (selectedModel ? [selectedModel] : []), [selectedModel]) - - const requiresIsolation = useMemo( - () => run.entries.some((entry) => selectedEntryIds.includes(entry.id) && !entry.isolated), - [run.entries, selectedEntryIds], - ) - const effectiveIsolate = requiresIsolation || isolate - - const toggleEntry = useCallback((entryId: number, checked: boolean) => { - setSelectedEntryIds((current) => { - if (checked) { - if (current.includes(entryId)) return current - return [...current, entryId] - } - return current.filter((id) => id !== entryId) - }) - }, []) - - const toggleModel = useCallback((value: string, checked: boolean) => { - setSelectedModel(checked ? value : null) - }, []) - - const canSubmit = - selectedEntryIds.length >= MULTI_RUN_FUSION_MIN_SOURCES && selectedModel !== null && !fuse.isPending - - const handleSubmit = () => { - if (!selectedModel) return - const trimmedInstructions = instructions.trim() - const request: FuseMultiRunRequest = { - requestId, - entryIds: selectedEntryIds, - model: selectedModel, - ...(trimmedInstructions ? { instructions: trimmedInstructions } : {}), - isolate: effectiveIsolate, - ...(effectiveIsolate && baseRef ? { baseRef } : {}), - } - fuse.mutate( - { runId: run.id, request }, - { - onSuccess: (result) => { - const fusion = result.fusions.find((candidate) => candidate.requestId === requestId) - if (fusion?.status === 'started' && fusion.sessionId) { - onOpenChange(false) - onOpenSession(fusion.sessionId, fusion.isolated) - } else if (fusion?.status === 'failed') { - setRequestId(randomId()) - } - }, - }, - ) - } - - const details = unavailableDetails(fuse.error) - const errorCode = (fuse.error as { code?: string } | null)?.code - - return ( - - - - Fuse results - - Ask one model to synthesise the selected results from “{run.name}”. - - - -
-
- -
- {run.entries.map((entry) => { - const selectable = entry.status === 'started' && !!entry.sessionId - return ( - - ) - })} -
-

- Select at least {MULTI_RUN_FUSION_MIN_SOURCES} completed results. -

-
- -
- -
- - setModelSearch(event.target.value)} - placeholder="Search models..." - aria-label="Search models" - autoComplete="off" - className="pl-9" - /> -
-
- -
-
- -
- -