diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index c18c3d12d..355e5bfa6 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -101,6 +101,7 @@ import type { AgentChatSurface, AgentChatSteerArgs, AgentChatSendArgs, + AgentChatSuggestLaneNameArgs, AgentChatCursorConfigOption, AgentChatCursorConfigValue, AgentChatCursorModeSnapshot, @@ -837,6 +838,13 @@ Return only the title text. - No quotes. - No emoji. - No trailing punctuation.`; + +const LANE_NAME_FROM_PROMPT_SYSTEM_PROMPT = `You name git worktree lanes for a software project. +Return only the base name text (no model suffixes). +- Use 2 to 5 words, lowercase except proper nouns if needed. +- Slug-friendly: letters, numbers, spaces, and hyphens only (no slashes). +- Describe the task or feature from the user's message. +- No quotes, no emoji, no trailing punctuation.`; const CODEX_REASONING_EFFORTS: Array<{ effort: string; description: string }> = [ { effort: "low", description: "Fastest turn-around with shallow reasoning." }, { effort: "medium", description: "Balanced reasoning depth and speed." }, @@ -4403,6 +4411,80 @@ export function createAgentChatService(args: { }; }; + const suggestLaneNameFromPrompt = async (args: AgentChatSuggestLaneNameArgs): Promise => { + const prompt = String(args.prompt ?? "").trim(); + const requestedModelId = String(args.modelId ?? "").trim(); + const sourceLaneId = String(args.laneId ?? "").trim(); + const fallback = (): string => { + const collapsed = prompt.replace(/\s+/g, " ").trim(); + if (!collapsed.length) return "parallel-task"; + const words = collapsed.split(/\s+/).filter(Boolean).slice(0, 4); + const slug = words.join("-").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); + return slug.length ? slug.slice(0, 48) : "parallel-task"; + }; + + if (!prompt.length || !requestedModelId.length || !sourceLaneId.length) { + return fallback(); + } + + let cwd = projectRoot; + try { + ({ laneWorktreePath: cwd } = resolveLaneLaunchContext({ + laneService, + laneId: sourceLaneId, + purpose: "name a lane from prompt", + })); + } catch { + cwd = projectRoot; + } + + try { + const auth = await detectAuth(); + const availableModels = getRegistryModels(auth).filter((descriptor) => !descriptor.deprecated); + if (!availableModels.length) return fallback(); + + const config = resolveChatConfig(); + const preferredModelId = + [ + requestedModelId, + config.autoTitleModelId, + DEFAULT_AUTO_TITLE_MODEL_ID, + "anthropic/claude-haiku-4-5", + "openai/gpt-5.4-mini", + "openai/gpt-5.2", + "openai/gpt-5.4", + availableModels[0]?.id, + ].find((candidate) => { + const modelId = typeof candidate === "string" ? candidate.trim() : ""; + return modelId.length > 0 && availableModels.some((descriptor) => descriptor.id === modelId); + }) ?? null; + + if (!preferredModelId) return fallback(); + + const descriptor = getModelById(preferredModelId); + if (!descriptor) return fallback(); + + const resolvedModel = await providerResolver.resolveModel(descriptor.id, auth, { + cwd, + middleware: false, + }); + const result = await generateText({ + model: resolvedModel, + system: LANE_NAME_FROM_PROMPT_SYSTEM_PROMPT, + prompt: `User message to parallelize across models:\n${prompt.slice(0, 2000)}`, + }); + const sanitized = sanitizeAutoTitle(result.text.trim(), 56); + if (!sanitized) return fallback(); + return sanitized; + } catch (error) { + logger.warn("agent_chat.suggest_lane_name_failed", { + modelId: requestedModelId, + error: error instanceof Error ? error.message : String(error), + }); + return fallback(); + } + }; + const computeHeadShaBestEffort = async (laneId: string): Promise => { let cwd: string; try { @@ -13458,6 +13540,7 @@ export function createAgentChatService(args: { return { createSession, + suggestLaneNameFromPrompt, handoffSession, sendMessage, runSessionTurn, diff --git a/apps/desktop/src/main/services/conflicts/conflictService.ts b/apps/desktop/src/main/services/conflicts/conflictService.ts index 53fa1ed0a..68da8a6dc 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.ts @@ -3930,7 +3930,7 @@ export function createConflictService({ ]; const status: PrepareResolverSessionResult["status"] = contextGaps.length > 0 ? "blocked" : "ready"; - const prompt = buildExternalResolverPrompt({ + let prompt = buildExternalResolverPrompt({ targetLaneId, sourceLaneIds, contexts, @@ -3939,6 +3939,10 @@ export function createConflictService({ integrationLaneId: integrationLane?.id ?? null, scenario }); + const extra = typeof args.additionalInstructions === "string" ? args.additionalInstructions.trim() : ""; + if (extra.length > 0) { + prompt += `\n\n---\n\n## Operator instructions\n\n${extra}\n`; + } const promptPath = path.join(runDir, "prompt.md"); fs.writeFileSync(promptPath, prompt, "utf8"); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 29c99d9ae..78630f5fc 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -177,6 +177,7 @@ import type { AgentChatRespondToInputArgs, AgentChatResumeArgs, AgentChatSendArgs, + AgentChatSuggestLaneNameArgs, AgentChatSession, AgentChatSessionSummary, AgentChatSubagentSnapshot, @@ -3796,6 +3797,11 @@ export function registerIpc({ return await ctx.agentChatService.createSession(arg); }); + ipcMain.handle(IPC.agentChatSuggestLaneName, async (_event, arg: AgentChatSuggestLaneNameArgs): Promise => { + const ctx = getCtx(); + return await ctx.agentChatService.suggestLaneNameFromPrompt(arg); + }); + ipcMain.handle(IPC.agentChatHandoff, async (_event, arg: AgentChatHandoffArgs): Promise => { const ctx = getCtx(); return await ctx.agentChatService.handoffSession(arg); @@ -4779,6 +4785,9 @@ export function registerIpc({ const reasoning = typeof arg?.reasoning === "string" && arg.reasoning.trim().length > 0 ? arg.reasoning.trim() : null; + const additionalInstructions = typeof arg?.additionalInstructions === "string" && arg.additionalInstructions.trim().length > 0 + ? arg.additionalInstructions.trim() + : null; let runId = ""; if (!model) { @@ -4835,6 +4844,7 @@ export function registerIpc({ model, reasoningEffort: reasoning, permissionMode, + additionalInstructions, originSurface: context.sourceTab === "integration" ? "integration" : context.sourceTab === "rebase" diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 6071195cb..af226497e 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -151,6 +151,7 @@ type IntegrationProposalRow = { integration_lane_name: string | null; status: string; integration_lane_id: string | null; + preferred_integration_lane_id: string | null; resolution_state_json: string | null; pairwise_results_json: string | null; lane_summaries_json: string | null; @@ -163,6 +164,7 @@ type IntegrationProposalRow = { completed_at: string | null; cleanup_declined_at: string | null; cleanup_completed_at: string | null; + merge_into_head_sha: string | null; }; type PrGroupLookupRow = { @@ -1282,6 +1284,8 @@ export function createPrService({ body: String(row.body || ""), draft: Boolean(row.draft), integrationLaneName: String(row.integration_lane_name || ""), + preferredIntegrationLaneId: asString(row.preferred_integration_lane_id).trim() || null, + mergeIntoHeadSha: asString(row.merge_into_head_sha).trim() || null, status: String(row.status) as IntegrationProposal["status"], integrationLaneId, linkedGroupId: asString(row.linked_group_id).trim() || null, @@ -2406,13 +2410,25 @@ export function createPrService({ if (!preflight.baseLane) { throw new Error(`Could not map base branch "${args.baseBranch}" to an active lane. Create or attach that lane first.`); } + const existingIntegrationLaneId = asString(args.existingIntegrationLaneId).trim(); + const laneMap = new Map(lanes.map((lane) => [lane.id, lane])); + if (existingIntegrationLaneId) { + if (preflight.uniqueSourceLaneIds.includes(existingIntegrationLaneId)) { + throw new Error("Integration lane cannot be one of the source lanes."); + } + const adoptLane = laneMap.get(existingIntegrationLaneId); + if (!adoptLane) { + throw new Error(`Integration lane not found: ${existingIntegrationLaneId}`); + } + } assertDirtyWorktreesAllowed({ lanes, - laneIds: preflight.uniqueSourceLaneIds, + laneIds: existingIntegrationLaneId + ? [...preflight.uniqueSourceLaneIds, existingIntegrationLaneId] + : preflight.uniqueSourceLaneIds, allowDirtyWorktree: args.allowDirtyWorktree }); - const laneMap = new Map(lanes.map((lane) => [lane.id, lane])); const sourceLaneNames = preflight.uniqueSourceLaneIds.map((laneId) => laneMap.get(laneId)?.name ?? laneId); const groupId = randomUUID(); const now = nowIso(); @@ -2420,6 +2436,7 @@ export function createPrService({ // Track resources created during this operation for cleanup on failure. let groupInserted = false; let integrationLane: LaneSummary | null = null; + let createdNewIntegrationLane = false; try { db.run( @@ -2428,11 +2445,17 @@ export function createPrService({ ); groupInserted = true; - integrationLane = await laneService.createChild({ - parentLaneId: preflight.baseLane.id, - name: integrationLaneName, - description: `Integration lane for merging: ${sourceLaneNames.join(", ")}` - }); + if (existingIntegrationLaneId) { + integrationLane = laneMap.get(existingIntegrationLaneId) ?? null; + if (!integrationLane) throw new Error(`Integration lane not found: ${existingIntegrationLaneId}`); + } else { + integrationLane = await laneService.createChild({ + parentLaneId: preflight.baseLane.id, + name: integrationLaneName, + description: `Integration lane for merging: ${sourceLaneNames.join(", ")}` + }); + createdNewIntegrationLane = true; + } const mergeResults: Array<{ laneId: string; success: boolean; error?: string }> = []; @@ -2510,9 +2533,8 @@ export function createPrService({ }); } } - // Archive the integration lane if we created one (best-effort; deletion - // could fail if the worktree has uncommitted state, so archive is safer). - if (integrationLane) { + // Archive the integration lane only if we created it (best-effort). + if (integrationLane && createdNewIntegrationLane) { try { await laneService.archive({ laneId: integrationLane.id }); } catch (cleanupError) { @@ -2888,12 +2910,30 @@ export function createPrService({ const laneOrder = new Map(sourceLaneIds.map((laneId, index) => [laneId, index])); const zeroDiffStat: IntegrationProposalStep["diffStat"] = { insertions: 0, deletions: 0, filesChanged: 0 }; + const mergeIntoLaneId = asString(args.mergeIntoLaneId).trim(); + if (mergeIntoLaneId && sourceLaneIds.includes(mergeIntoLaneId)) { + throw new Error("Merge-into lane cannot be one of the source lanes."); + } + const mergeIntoLane = mergeIntoLaneId ? laneMap.get(mergeIntoLaneId) ?? null : null; + if (mergeIntoLaneId && !mergeIntoLane) { + throw new Error(`Merge-into lane not found: ${mergeIntoLaneId}`); + } + // Resolve base branch SHA once, then compare each lane head against it. const baseSha = (await runGitOrThrow( ["rev-parse", args.baseBranch], { cwd: projectRoot, timeoutMs: 10_000 } )).trim(); + let mergeIntoHeadSha: string | null = null; + if (mergeIntoLane) { + mergeIntoHeadSha = (await runGitOrThrow( + ["rev-parse", branchNameFromRef(mergeIntoLane.branchRef)], + { cwd: projectRoot, timeoutMs: 10_000 } + )).trim(); + } + const sequentialStartSha = mergeIntoHeadSha ?? baseSha; + const laneSummariesById = new Map< string, { @@ -3091,6 +3131,64 @@ export function createPrService({ })) }); + const mergeIntoConflictLaneIds = new Set(); + const mergeIntoFilesByLaneId = new Map>(); + if (mergeIntoHeadSha) { + for (const laneId of sourceLaneIds) { + const laneSummary = laneSummariesById.get(laneId); + if (!laneSummary?.headSha) continue; + const mergeTreeResult = await runGitMergeTree({ + cwd: projectRoot, + mergeBase: baseSha, + branchA: mergeIntoHeadSha, + branchB: laneSummary.headSha, + timeoutMs: 30_000 + }); + if (mergeTreeResult.exitCode === 128 || (!mergeTreeResult.conflicts.length && mergeTreeResult.exitCode !== 0)) { + mergeIntoConflictLaneIds.add(laneId); + continue; + } + if (mergeTreeResult.conflicts.length === 0) continue; + mergeIntoConflictLaneIds.add(laneId); + const fileMap = mergeIntoFilesByLaneId.get(laneId) ?? new Map(); + const treeOid = mergeTreeResult.treeOid; + for (const filePath of mergeTreeResult.conflicts.map((c) => c.path)) { + if (fileMap.has(filePath)) continue; + if (treeOid) { + const detail = await extractConflictDetail(treeOid, filePath, projectRoot); + fileMap.set(filePath, { + path: filePath, + conflictType: detail.conflictType, + conflictMarkers: detail.conflictMarkers, + oursExcerpt: detail.oursExcerpt || null, + theirsExcerpt: detail.theirsExcerpt || null, + diffHunk: detail.diffHunk || null + }); + } else { + let oursExcerpt: string | null = null; + let theirsExcerpt: string | null = null; + try { + const [diffI, diffS] = await Promise.all([ + runGit(["diff", `${baseSha}..${mergeIntoHeadSha}`, "--", filePath], { cwd: projectRoot, timeoutMs: 10_000 }), + runGit(["diff", `${baseSha}..${laneSummary.headSha}`, "--", filePath], { cwd: projectRoot, timeoutMs: 10_000 }) + ]); + if (diffI.exitCode === 0 && diffI.stdout.trim()) oursExcerpt = diffI.stdout.slice(0, 500); + if (diffS.exitCode === 0 && diffS.stdout.trim()) theirsExcerpt = diffS.stdout.slice(0, 500); + } catch { /* best-effort */ } + fileMap.set(filePath, { + path: filePath, + conflictType: null, + conflictMarkers: "", + oursExcerpt, + theirsExcerpt, + diffHunk: null + }); + } + } + mergeIntoFilesByLaneId.set(laneId, fileMap); + } + } + const sequentialConflictLaneIds = new Set(); const sequentialBlockedLaneIds = new Set(); const sequentialFilesByLaneId = new Map>(); @@ -3098,7 +3196,7 @@ export function createPrService({ const sequentialWorktreePath = path.join(sequentialTempRoot, "worktree"); try { - await runGitOrThrow(["worktree", "add", "--detach", sequentialWorktreePath, baseSha], { + await runGitOrThrow(["worktree", "add", "--detach", sequentialWorktreePath, sequentialStartSha], { cwd: projectRoot, timeoutMs: 60_000, }); @@ -3192,6 +3290,14 @@ export function createPrService({ conflictingFilesByLaneId.set(laneId, laneFiles); } + for (const [laneId, files] of mergeIntoFilesByLaneId.entries()) { + const laneFiles = conflictingFilesByLaneId.get(laneId) ?? new Map(); + for (const [filePath, file] of files.entries()) { + if (!laneFiles.has(filePath)) laneFiles.set(filePath, file); + } + conflictingFilesByLaneId.set(laneId, laneFiles); + } + const laneSummaries: IntegrationLaneSummary[] = sourceLaneIds.map((laneId) => { const laneSummary = laneSummariesById.get(laneId); const laneName = laneSummary?.laneName ?? laneId; @@ -3201,7 +3307,7 @@ export function createPrService({ let outcome: IntegrationLaneSummary["outcome"] = "clean"; if (!laneSummary?.headSha || blockedLaneIds.has(laneId) || sequentialBlockedLaneIds.has(laneId)) { outcome = "blocked"; - } else if (conflictsWith.length > 0 || sequentialConflictLaneIds.has(laneId)) { + } else if (conflictsWith.length > 0 || sequentialConflictLaneIds.has(laneId) || mergeIntoConflictLaneIds.has(laneId)) { outcome = "conflict"; } @@ -3243,6 +3349,8 @@ export function createPrService({ overallOutcome, createdAt: now, status: "proposed", + preferredIntegrationLaneId: mergeIntoLaneId || null, + mergeIntoHeadSha: mergeIntoHeadSha ?? null, linkedGroupId: null, linkedPrId: null, workflowDisplayState: "active", @@ -3256,7 +3364,7 @@ export function createPrService({ if (args.persist !== false) { db.run( - `insert into integration_proposals(id, project_id, source_lane_ids_json, base_branch, steps_json, pairwise_results_json, lane_summaries_json, overall_outcome, created_at, status) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `insert into integration_proposals(id, project_id, source_lane_ids_json, base_branch, steps_json, pairwise_results_json, lane_summaries_json, overall_outcome, created_at, status, preferred_integration_lane_id, merge_into_head_sha) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ proposalId, projectId, @@ -3267,7 +3375,9 @@ export function createPrService({ JSON.stringify(laneSummaries), overallOutcome, now, - "proposed" + "proposed", + mergeIntoLaneId || null, + mergeIntoHeadSha ?? null, ] ); } @@ -3347,14 +3457,23 @@ export function createPrService({ steps_json: string; integration_lane_id: string | null; integration_lane_name: string | null; + preferred_integration_lane_id: string | null; }>( - `select id, source_lane_ids_json, base_branch, steps_json, integration_lane_id, integration_lane_name from integration_proposals where id = ?`, + `select id, source_lane_ids_json, base_branch, steps_json, integration_lane_id, integration_lane_name, preferred_integration_lane_id from integration_proposals where id = ?`, [args.proposalId] ); if (!proposalRow) throw new Error(`Proposal not found: ${args.proposalId}`); const sourceLaneIds = JSON.parse(String(proposalRow.source_lane_ids_json)) as string[]; const existingIntegrationLaneId = asString(proposalRow.integration_lane_id).trim(); + const preferredFromRow = asString(proposalRow.preferred_integration_lane_id).trim() || null; + const preferredIntegrationLaneId = + args.preferredIntegrationLaneId !== undefined + ? (asString(args.preferredIntegrationLaneId).trim() || null) + : preferredFromRow; + if (preferredIntegrationLaneId && sourceLaneIds.includes(preferredIntegrationLaneId)) { + throw new Error("Preferred integration lane cannot be one of the source lanes."); + } let result: CreateIntegrationPrResult; if (existingIntegrationLaneId) { @@ -3369,9 +3488,11 @@ export function createPrService({ }); } else { const availableLanes = await laneService.list({ includeArchived: false }); + const dirtyCheckLaneIds = [...sourceLaneIds]; + if (preferredIntegrationLaneId) dirtyCheckLaneIds.push(preferredIntegrationLaneId); assertDirtyWorktreesAllowed({ lanes: availableLanes, - laneIds: sourceLaneIds, + laneIds: dirtyCheckLaneIds, allowDirtyWorktree: args.allowDirtyWorktree, }); @@ -3418,6 +3539,9 @@ export function createPrService({ || null ) : null, + ...(args.preferredIntegrationLaneId !== undefined + ? { preferred_integration_lane_id: preferredIntegrationLaneId } + : {}), integration_lane_id: result.integrationLaneId, linked_group_id: result.groupId, linked_pr_id: result.pr.id, @@ -3914,6 +4038,20 @@ export function createPrService({ if (args.body !== undefined) { sets.push("body = ?"); params.push(args.body); } if (args.draft !== undefined) { sets.push("draft = ?"); params.push(args.draft ? 1 : 0); } if (args.integrationLaneName !== undefined) { sets.push("integration_lane_name = ?"); params.push(args.integrationLaneName); } + if (args.preferredIntegrationLaneId !== undefined) { + sets.push("preferred_integration_lane_id = ?"); + params.push(args.preferredIntegrationLaneId?.trim() || null); + } + if (args.mergeIntoHeadSha !== undefined) { + sets.push("merge_into_head_sha = ?"); + params.push(args.mergeIntoHeadSha?.trim() || null); + } + if (args.clearIntegrationBinding) { + sets.push("integration_lane_id = ?"); + params.push(null); + sets.push("resolution_state_json = ?"); + params.push(null); + } if (sets.length === 0) return; params.push(args.proposalId); db.run(`update integration_proposals set ${sets.join(", ")} where id = ?`, params); @@ -3957,9 +4095,11 @@ export function createPrService({ const proposalRow = db.get<{ id: string; source_lane_ids_json: string; base_branch: string; steps_json: string; overall_outcome: string; integration_lane_name: string | null; - integration_lane_id: string | null; resolution_state_json: string | null; created_at: string; + integration_lane_id: string | null; preferred_integration_lane_id: string | null; + merge_into_head_sha: string | null; + resolution_state_json: string | null; created_at: string; }>( - `select id, source_lane_ids_json, base_branch, steps_json, overall_outcome, integration_lane_name, integration_lane_id, resolution_state_json, created_at from integration_proposals where id = ?`, + `select id, source_lane_ids_json, base_branch, steps_json, overall_outcome, integration_lane_name, integration_lane_id, preferred_integration_lane_id, merge_into_head_sha, resolution_state_json, created_at from integration_proposals where id = ?`, [args.proposalId] ); if (!proposalRow) throw new Error(`Proposal not found: ${args.proposalId}`); @@ -3975,6 +4115,17 @@ export function createPrService({ throw new Error(`Could not map base branch "${String(proposalRow.base_branch)}" to an active lane. Create or attach that lane first.`); } const laneMap = new Map(allLanes.map((l) => [l.id, l])); + const preferredIntegrationLaneId = asString(proposalRow.preferred_integration_lane_id).trim(); + if (preferredIntegrationLaneId && preflight.uniqueSourceLaneIds.includes(preferredIntegrationLaneId)) { + throw new Error("Preferred integration lane cannot be one of the source lanes."); + } + const dirtyCheckLaneIds = [...preflight.uniqueSourceLaneIds]; + if (preferredIntegrationLaneId) dirtyCheckLaneIds.push(preferredIntegrationLaneId); + assertDirtyWorktreesAllowed({ + lanes: allLanes, + laneIds: dirtyCheckLaneIds, + allowDirtyWorktree: args.allowDirtyWorktree, + }); const existingIntegrationLaneId = asString(proposalRow.integration_lane_id).trim(); if (existingIntegrationLaneId) { const existingLane = laneMap.get(existingIntegrationLaneId); @@ -4001,11 +4152,39 @@ export function createPrService({ } const shortId = args.proposalId.slice(0, 8); const integrationLaneName = String(proposalRow.integration_lane_name ?? "").trim() || `integration/${shortId}`; - const integrationLane = await laneService.createChild({ - parentLaneId: preflight.baseLane.id, - name: integrationLaneName, - description: `Integration lane for proposal ${args.proposalId}` - }); + let integrationLane: LaneSummary; + if (preferredIntegrationLaneId) { + const adopt = laneMap.get(preferredIntegrationLaneId); + if (!adopt) throw new Error(`Preferred integration lane not found: ${preferredIntegrationLaneId}`); + const storedMergeHead = asString(proposalRow.merge_into_head_sha).trim(); + try { + const currentHead = (await runGitOrThrow( + ["rev-parse", "HEAD"], + { cwd: adopt.worktreePath, timeoutMs: 10_000 } + )).trim(); + if (storedMergeHead && currentHead && storedMergeHead !== currentHead) { + logger.warn("prs.integration_merge_into_head_drift", { + proposalId: args.proposalId, + preferredIntegrationLaneId, + storedHead: storedMergeHead, + currentHead, + }); + } + } catch (error) { + logger.warn("prs.integration_merge_into_head_read_failed", { + proposalId: args.proposalId, + preferredIntegrationLaneId, + error: error instanceof Error ? error.message : String(error), + }); + } + integrationLane = adopt; + } else { + integrationLane = await laneService.createChild({ + parentLaneId: preflight.baseLane.id, + name: integrationLaneName, + description: `Integration lane for proposal ${args.proposalId}` + }); + } const mergedCleanLanes: string[] = []; const conflictingLanes: string[] = []; diff --git a/apps/desktop/src/main/services/state/kvDb.missionsMigration.test.ts b/apps/desktop/src/main/services/state/kvDb.missionsMigration.test.ts index 4d7bd8a41..4dd13cbae 100644 --- a/apps/desktop/src/main/services/state/kvDb.missionsMigration.test.ts +++ b/apps/desktop/src/main/services/state/kvDb.missionsMigration.test.ts @@ -81,6 +81,8 @@ describe("kvDb mission schema migration", () => { "integration_lane_name", "status", "integration_lane_id", + "preferred_integration_lane_id", + "merge_into_head_sha", "resolution_state_json", "pairwise_results_json", "lane_summaries_json" diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 2fda9cca5..1999703a5 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -1161,6 +1161,8 @@ function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) { try { db.run("alter table integration_proposals add column completed_at text"); } catch {} try { db.run("alter table integration_proposals add column cleanup_declined_at text"); } catch {} try { db.run("alter table integration_proposals add column cleanup_completed_at text"); } catch {} + try { db.run("alter table integration_proposals add column preferred_integration_lane_id text"); } catch {} + try { db.run("alter table integration_proposals add column merge_into_head_sha text"); } catch {} // Queue landing state table (crash recovery for sequential landing) db.run(` diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 37f2a3383..7af5bc8d5 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -61,6 +61,7 @@ import type { AgentTool, AgentChatApproveArgs, AgentChatCreateArgs, + AgentChatSuggestLaneNameArgs, AgentChatDisposeArgs, AgentChatEventEnvelope, AgentChatGetSummaryArgs, @@ -831,6 +832,7 @@ declare global { list: (args?: AgentChatListArgs) => Promise; getSummary: (args: AgentChatGetSummaryArgs) => Promise; create: (args: AgentChatCreateArgs) => Promise; + suggestLaneName: (args: AgentChatSuggestLaneNameArgs) => Promise; handoff: (args: AgentChatHandoffArgs) => Promise; send: (args: AgentChatSendArgs) => Promise; steer: (args: AgentChatSteerArgs) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index b42c899ac..233cec597 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -226,6 +226,7 @@ import type { AgentTool, AgentChatApproveArgs, AgentChatCreateArgs, + AgentChatSuggestLaneNameArgs, AgentChatDisposeArgs, AgentChatEventEnvelope, AgentChatGetSummaryArgs, @@ -1123,6 +1124,8 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.agentChatGetSummary, args), create: async (args: AgentChatCreateArgs): Promise => ipcRenderer.invoke(IPC.agentChatCreate, args), + suggestLaneName: async (args: AgentChatSuggestLaneNameArgs): Promise => + ipcRenderer.invoke(IPC.agentChatSuggestLaneName, args), handoff: async (args: AgentChatHandoffArgs): Promise => ipcRenderer.invoke(IPC.agentChatHandoff, args), send: async (args: AgentChatSendArgs): Promise => diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index ca4429849..61429cc32 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { At, CaretDown, Check, Image, Paperclip, PencilSimple, Square, X, PaperPlaneTilt, Cube, BookOpen } from "@phosphor-icons/react"; +import { At, Check, Image, Paperclip, PencilSimple, Square, X, PaperPlaneTilt, Cube, BookOpen, SquareSplitHorizontal, Plus, Trash } from "@phosphor-icons/react"; import { inferAttachmentType, + PARALLEL_CHAT_MAX_ATTACHMENTS, type AgentChatApprovalDecision, type AgentChatClaudePermissionMode, type AgentChatCursorConfigOption, @@ -49,6 +50,32 @@ type SlashCommandEntry = { source: "sdk" | "local"; }; +/** When set, permission/runtime controls bind to this slot (parallel model row configuration). */ +export type ParallelComposerControlSlot = { + sessionProvider: string; + interactionMode: AgentChatInteractionMode; + claudePermissionMode: AgentChatClaudePermissionMode; + codexApprovalPolicy: AgentChatCodexApprovalPolicy; + codexSandbox: AgentChatCodexSandbox; + codexConfigSource: AgentChatCodexConfigSource; + unifiedPermissionMode: AgentChatUnifiedPermissionMode; + cursorModeSnapshot: AgentChatCursorModeSnapshot | null; + onInteractionModeChange: (mode: AgentChatInteractionMode) => void; + onClaudeModeChange: (mode: AgentChatClaudePermissionMode) => void; + onClaudePermissionModeChange: (mode: AgentChatClaudePermissionMode) => void; + onCodexPresetChange: (next: { + codexApprovalPolicy: AgentChatCodexApprovalPolicy; + codexSandbox: AgentChatCodexSandbox; + codexConfigSource: AgentChatCodexConfigSource; + }) => void; + onCodexApprovalPolicyChange: (policy: AgentChatCodexApprovalPolicy) => void; + onCodexSandboxChange: (sandbox: AgentChatCodexSandbox) => void; + onCodexConfigSourceChange: (source: AgentChatCodexConfigSource) => void; + onUnifiedPermissionModeChange: (mode: AgentChatUnifiedPermissionMode) => void; + onCursorModeChange: (modeId: string) => void; + onCursorConfigChange: (configId: string, value: string | boolean) => void; +}; + /** Local-only commands that are always available regardless of provider. */ const LOCAL_SLASH_COMMANDS: SlashCommandEntry[] = [ { command: "/clear", label: "Clear", description: "Clear chat history", source: "local" }, @@ -328,6 +355,22 @@ export function AgentChatComposer({ onCancelSteer, onEditSteer, onOpenAiSettings, + parallelChatMode = false, + onParallelChatModeChange, + parallelModelSlots = [], + parallelConfiguringIndex = null, + onParallelConfiguringIndexChange, + onParallelAddModel, + onParallelRemoveModel, + onParallelSlotModelChange, + onParallelSlotReasoningChange, + parallelLaunchBusy = false, + parallelLaunchStatus = null, + parallelControlSlot = null, + parallelSlotExecutionModeOptions = [], + parallelSlotExecutionMode = null, + onParallelSlotExecutionModeChange, + showParallelChatToggle = false, }: { surfaceMode?: ChatSurfaceMode; layoutVariant?: "standard" | "grid-tile"; @@ -398,6 +441,22 @@ export function AgentChatComposer({ onCancelSteer?: (steerId: string) => void; onEditSteer?: (steerId: string, text: string) => void; onOpenAiSettings?: () => void; + parallelChatMode?: boolean; + onParallelChatModeChange?: (enabled: boolean) => void; + parallelModelSlots?: Array<{ modelId: string; reasoningEffort: string | null }>; + parallelConfiguringIndex?: number | null; + onParallelConfiguringIndexChange?: (index: number | null) => void; + onParallelAddModel?: () => void; + onParallelRemoveModel?: (index: number) => void; + onParallelSlotModelChange?: (index: number, modelId: string) => void; + onParallelSlotReasoningChange?: (index: number, effort: string | null) => void; + parallelLaunchBusy?: boolean; + parallelLaunchStatus?: string | null; + parallelControlSlot?: ParallelComposerControlSlot | null; + parallelSlotExecutionModeOptions?: ExecutionModeOption[]; + parallelSlotExecutionMode?: AgentChatExecutionMode | null; + onParallelSlotExecutionModeChange?: (mode: AgentChatExecutionMode) => void; + showParallelChatToggle?: boolean; }) { const [attachmentPickerOpen, setAttachmentPickerOpen] = useState(false); const [attachmentQuery, setAttachmentQuery] = useState(""); @@ -418,7 +477,10 @@ export function AgentChatComposer({ const uploadInputRef = useRef(null); const textareaRef = useRef(null); const fileAddInProgressRef = useRef(false); - const canAttach = !turnActive; + const canAttach = !turnActive && (!parallelChatMode || attachments.length < PARALLEL_CHAT_MAX_ATTACHMENTS); + const attachBlockedReason = parallelChatMode && attachments.length >= PARALLEL_CHAT_MAX_ATTACHMENTS + ? `Maximum ${PARALLEL_CHAT_MAX_ATTACHMENTS} attachments for parallel launch` + : null; const attachedPaths = useMemo(() => new Set(attachments.map((a) => a.path)), [attachments]); const selectedModel = useMemo(() => getModelById(modelId), [modelId]); @@ -452,6 +514,10 @@ export function AgentChatComposer({ return () => window.clearTimeout(timeout); }, [attachmentPickerOpen]); + useEffect(() => { + setAttachmentCursor((c) => Math.min(c, Math.max(attachmentResults.length - 1, 0))); + }, [attachmentResults.length]); + useEffect(() => { if (!attachmentPickerOpen) return; const query = attachmentQuery.trim(); @@ -478,23 +544,37 @@ export function AgentChatComposer({ const selectAttachment = (attachment: AgentChatFileRef) => { setAttachError(null); + if (parallelChatMode && attachments.length >= PARALLEL_CHAT_MAX_ATTACHMENTS) { + setAttachError(`You can attach up to ${PARALLEL_CHAT_MAX_ATTACHMENTS} files for parallel launch.`); + return; + } onAddAttachment(attachment); setAttachmentPickerOpen(false); }; const addFileAttachments = async (files: FileList | null | undefined) => { - if (!canAttach || !files?.length) return; + if (!files?.length) return; + if (turnActive) return; + if (parallelChatMode && attachments.length >= PARALLEL_CHAT_MAX_ATTACHMENTS) return; + if (!parallelChatMode && !canAttach) return; if (fileAddInProgressRef.current) return; fileAddInProgressRef.current = true; setAttachError(null); try { + let addedInBatch = 0; for (const file of Array.from(files)) { + if (parallelChatMode && attachments.length + addedInBatch >= PARALLEL_CHAT_MAX_ATTACHMENTS) { + setAttachError(`You can attach up to ${PARALLEL_CHAT_MAX_ATTACHMENTS} files for parallel launch.`); + break; + } const fileWithPath = file as File & { path?: string }; const hasRealPath = typeof fileWithPath.path === "string" && fileWithPath.path.trim().length > 0; if (hasRealPath) { const filePath = fileWithPath.path!; - onAddAttachment({ path: filePath, type: inferAttachmentType(filePath, file.type) }); + const t = inferAttachmentType(filePath, file.type); + onAddAttachment({ path: filePath, type: t }); + addedInBatch += 1; continue; } @@ -515,7 +595,9 @@ export function AgentChatComposer({ data: base64, filename: file.name || "clipboard.png", }); - onAddAttachment({ path: tempPath, type: inferAttachmentType(tempPath, file.type) }); + const t = inferAttachmentType(tempPath, file.type); + onAddAttachment({ path: tempPath, type: t }); + addedInBatch += 1; } catch { setAttachError(`Unable to attach "${file.name || "clipboard"}".`); } @@ -536,13 +618,23 @@ export function AgentChatComposer({ }; const nativeControlsDisabled = permissionModeLocked; - const claudeSelectionMode = claudePermissionMode === "plan" || interactionMode === "plan" + const slot = parallelControlSlot; + const sp = slot?.sessionProvider ?? sessionProvider ?? "unified"; + const im = slot?.interactionMode ?? interactionMode ?? "default"; + const cpmUse = slot?.claudePermissionMode ?? claudePermissionMode; + const capUse = slot?.codexApprovalPolicy ?? codexApprovalPolicy; + const csUse = slot?.codexSandbox ?? codexSandbox; + const ccsUse = slot?.codexConfigSource ?? codexConfigSource; + const upmUse = slot?.unifiedPermissionMode ?? unifiedPermissionMode; + const cmsUse = slot?.cursorModeSnapshot ?? cursorModeSnapshot; + + const claudeSelectionMode = cpmUse === "plan" || im === "plan" ? "plan" - : claudePermissionMode ?? "default"; + : cpmUse ?? "default"; const codexPreset = resolveCodexPermissionPreset({ - codexApprovalPolicy, - codexSandbox, - codexConfigSource, + codexApprovalPolicy: capUse, + codexSandbox: csUse, + codexConfigSource: ccsUse, }); const codexPresetOptions = useMemo( () => getPermissionOptions({ family: "openai", isCliWrapped: true }) @@ -568,6 +660,10 @@ export function AgentChatComposer({ codexConfigSource: "flags" as const, }; + if (parallelControlSlot) { + parallelControlSlot.onCodexPresetChange(next); + return; + } if (onCodexPresetChange) { onCodexPresetChange(next); return; @@ -580,15 +676,16 @@ export function AgentChatComposer({ onCodexConfigSourceChange, onCodexPresetChange, onCodexSandboxChange, + parallelControlSlot, ]); const claudeControlDetail = useMemo(() => { - if (sessionProvider !== "claude") return null; + if (sp !== "claude") return null; const option = CLAUDE_MODE_OPTIONS.find((item) => item.value === (hoveredClaudeMode ?? claudeSelectionMode)); return option?.detail ?? null; - }, [claudeSelectionMode, hoveredClaudeMode, sessionProvider]); + }, [claudeSelectionMode, hoveredClaudeMode, sp]); const codexCustomSummary = useMemo(() => { - if (sessionProvider !== "codex" || codexPreset !== "custom") return null; - if (codexConfigSource === "config-toml") { + if (sp !== "codex" || codexPreset !== "custom") return null; + if (ccsUse === "config-toml") { return "Custom Codex mode: config.toml controls approval and sandbox."; } const approvalLabel = { @@ -596,16 +693,16 @@ export function AgentChatComposer({ "on-request": "On request", "on-failure": "Guarded edit", "never": "Full auto", - }[codexApprovalPolicy ?? "on-request"]; + }[capUse ?? "on-request"]; const sandboxLabel = { "read-only": "Read only", "workspace-write": "Workspace write", "danger-full-access": "Danger full access", - }[codexSandbox ?? "workspace-write"]; - return `Custom Codex mode: ${codexConfigSource === "flags" ? "ADE flags" : "config.toml"} · ${approvalLabel} · ${sandboxLabel}`; - }, [codexApprovalPolicy, codexConfigSource, codexPreset, codexSandbox, sessionProvider]); + }[csUse ?? "workspace-write"]; + return `Custom Codex mode: ${ccsUse === "flags" ? "ADE flags" : "config.toml"} · ${approvalLabel} · ${sandboxLabel}`; + }, [capUse, ccsUse, codexPreset, csUse, sp]); const codexControlDetail = useMemo(() => { - if (sessionProvider !== "codex") return null; + if (sp !== "codex") return null; if (hoveredCodexPreset) { return codexPresetOptions.find((option) => option.value === hoveredCodexPreset)?.detail ?? null; } @@ -613,7 +710,7 @@ export function AgentChatComposer({ return codexCustomSummary; } return codexPresetOptions.find((option) => option.value === codexPreset)?.detail ?? null; - }, [codexCustomSummary, codexPreset, codexPresetOptions, hoveredCodexPreset, sessionProvider]); + }, [codexCustomSummary, codexPreset, codexPresetOptions, hoveredCodexPreset, sp]); const nativeControlPanel = useMemo(() => { const renderButtonGroup = ( label: string, @@ -657,10 +754,20 @@ export function AgentChatComposer({ ); - if (sessionProvider === "claude") { + if (sp === "claude") { return (
{renderButtonGroup("Claude", claudeSelectionMode, CLAUDE_MODE_OPTIONS, (mode) => { + if (parallelControlSlot) { + if (mode === "plan") { + parallelControlSlot.onInteractionModeChange("plan"); + parallelControlSlot.onClaudePermissionModeChange("plan"); + return; + } + parallelControlSlot.onInteractionModeChange("default"); + parallelControlSlot.onClaudePermissionModeChange(mode); + return; + } if (onClaudeModeChange) { onClaudeModeChange(mode); return; @@ -677,7 +784,7 @@ export function AgentChatComposer({ ); } - if (sessionProvider === "codex") { + if (sp === "codex") { return (
@@ -720,20 +827,20 @@ export function AgentChatComposer({ ); } - const cursorModeOption = resolveCursorModeOption(cursorModeSnapshot); - const cursorExtraOptions = (cursorModeSnapshot?.configOptions ?? []).filter((option) => { - if (option.id === cursorModeSnapshot?.modelConfigId) return false; + const cursorModeOption = resolveCursorModeOption(cmsUse); + const cursorExtraOptions = (cmsUse?.configOptions ?? []).filter((option) => { + if (option.id === cmsUse?.modelConfigId) return false; if (option.id === cursorModeOption?.id) return false; return true; }); - if (sessionProvider === "cursor" && (cursorModeSnapshot?.availableModeIds?.length || cursorModeOption)) { + if (sp === "cursor" && (cmsUse?.availableModeIds?.length || cursorModeOption)) { const modeValue = typeof cursorModeOption?.currentValue === "string" ? cursorModeOption.currentValue - : cursorModeSnapshot?.currentModeId ?? ""; + : cmsUse?.currentModeId ?? ""; const modeChoices = cursorModeOption?.options?.length ? cursorModeOption.options.map((option) => ({ value: option.value, label: option.label })) - : (cursorModeSnapshot?.availableModeIds ?? []).map((modeId) => ({ + : (cmsUse?.availableModeIds ?? []).map((modeId) => ({ value: modeId, label: cursorModeLabel(modeId), })); @@ -744,8 +851,11 @@ export function AgentChatComposer({ Mode onCursorConfigChange?.(option.id, event.target.value)} + disabled={nativeControlsDisabled || (!onCursorConfigChange && !parallelControlSlot)} + onChange={(event) => { + if (parallelControlSlot) parallelControlSlot.onCursorConfigChange(option.id, event.target.value); + else onCursorConfigChange?.(option.id, event.target.value); + }} className="min-w-0 bg-transparent font-sans text-[11px] text-fg/82 outline-none disabled:cursor-not-allowed disabled:text-muted-fg/35" > {choices.map((choice) => ( @@ -813,14 +929,18 @@ export function AgentChatComposer({ ); } - const runtimeLabel = sessionProvider === "cursor" ? "Cursor" : "ADE"; + const runtimeLabel = sp === "cursor" ? "Cursor" : "ADE"; return (
+ {/* Simulate button */}