From 359e938479970996914e32051a1fd20e5f016eb2 Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Fri, 21 Aug 2026 07:45:27 -0400 Subject: [PATCH 1/2] Add a button to hand PR merge conflicts to the agent The PR pane already knows when GitHub reports a conflicting merge, but resolving it meant switching to the chat and typing the request. This adds a one-click handoff that injects the ask into the worktree's agent chat, waking a slept tab the same way the CI-failure notifier does. Deliberately a button rather than the standing opt-in CI failures get: a branch that conflicts with its base usually conflicts with every other in-flight branch too, so firing this automatically would put the whole workspace to work over one bad merge base. Co-Authored-By: Claude Opus 4.7 --- src/main/index.ts | 36 +++++++++++++++ src/main/merge-conflict-request.test.ts | 45 ++++++++++++++++++ src/main/merge-conflict-request.ts | 22 +++++++++ src/renderer/build-backend.ts | 2 + src/renderer/components/JsonModeChat.tsx | 8 ++++ src/renderer/components/PRStatusPanel.tsx | 56 +++++++++++++++++++++++ src/renderer/types.ts | 8 ++++ src/shared/state/json-claude.ts | 2 + 8 files changed, 179 insertions(+) create mode 100644 src/main/merge-conflict-request.test.ts create mode 100644 src/main/merge-conflict-request.ts diff --git a/src/main/index.ts b/src/main/index.ts index 69b367dc4..372d2315e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -140,6 +140,7 @@ import { describeWorktree, resolveWorktreeQuery } from './chat-delivery' +import { buildMergeConflictMessage } from './merge-conflict-request' import { writeMcpConfigForTerminal, pruneMcpConfigs, getBridgeScriptPath } from './mcp-config' import { getControlServerInfo } from './control-server' import { recordActivity, getActivityLog, clearAllActivity, clearActivityForWorktree, sealAllActive, touchActivityMeta, finalizeActivity, type ActivityState, type PRState } from './activity' @@ -1974,6 +1975,41 @@ function registerIpcHandlers(): void { return true }) + // Manual counterpart to the CI-failure notifier: the user asks the agent + // to go resolve this PR's conflicts. Never fires on its own — see + // buildMergeConflictMessage. + transport.onRequest( + 'prs:requestConflictFix', + (_ctx, worktreePath: string): { ok: boolean; error?: string } => { + if (typeof worktreePath !== 'string' || !worktreePath) { + return { ok: false, error: 'No worktree' } + } + const state = store.getSnapshot().state + const pr = state.prs.byPath[worktreePath] + if (!pr) return { ok: false, error: 'No PR for this branch' } + const result = deliverToWorktreeChat( + state, + chatDeliveryDeps, + worktreePath, + buildMergeConflictMessage(pr) + ) + if (!result.ok) { + return { + ok: false, + error: + result.reason === 'no-chat-tab' + ? 'No agent chat tab in this worktree' + : "Couldn't wake the agent chat tab" + } + } + log( + 'merge-conflict', + `asked ${result.sessionId} to fix conflicts on ${worktreePath}${result.woke ? ' (woke tab)' : ''}` + ) + return { ok: true } + } + ) + transport.onRequest('announcements:refresh', async (_ctx) => { await announcementsPoller.refresh() return true diff --git a/src/main/merge-conflict-request.test.ts b/src/main/merge-conflict-request.test.ts new file mode 100644 index 000000000..0f6f11ad8 --- /dev/null +++ b/src/main/merge-conflict-request.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest' + +import { buildMergeConflictMessage } from './merge-conflict-request' +import type { PRStatus } from '../shared/state/prs' +import { parseAutomatedMessage } from '../shared/state/json-claude' + +function pr(overrides: Partial = {}): PRStatus { + return { + number: 7, + title: 'Add a thing', + state: 'open', + url: 'https://github.com/o/r/pull/7', + branch: 'feat/thing', + headSha: 'abc123', + author: null, + checks: [], + checksOverall: 'none', + hasConflict: true, + reviews: [], + reviewDecision: 'none', + baseBranch: 'main', + isDefaultBase: true, + assignees: [], + linkedIssues: [], + labels: [], + ...overrides + } +} + +describe('buildMergeConflictMessage', () => { + it('round-trips through the automation sentinel so the chat renders a card', () => { + const parsed = parseAutomatedMessage(buildMergeConflictMessage(pr())) + expect(parsed?.source).toBe('merge-conflict') + }) + + it('names the PR, its branch, and the base it conflicts with', () => { + const body = parseAutomatedMessage( + buildMergeConflictMessage(pr({ number: 42, branch: 'fix/login', baseBranch: 'develop' })) + )?.body + expect(body).toContain('#42') + expect(body).toContain('fix/login') + expect(body).toContain('develop') + expect(body).toContain('https://github.com/o/r/pull/7') + }) +}) diff --git a/src/main/merge-conflict-request.ts b/src/main/merge-conflict-request.ts new file mode 100644 index 000000000..86fb4e7cb --- /dev/null +++ b/src/main/merge-conflict-request.ts @@ -0,0 +1,22 @@ +import type { PRStatus } from '../shared/state/prs' +import { wrapAutomatedMessage } from '../shared/state/json-claude' + +/** Compose the message injected into the agent chat when the user asks for + * help with a conflicted PR. Unlike CI failures this is never automatic: + * a branch that conflicts with its base usually conflicts with every other + * in-flight branch too, so auto-firing would cascade agents across the + * whole workspace over one bad merge base. + * + * Deliberately doesn't enumerate the conflicted files. The local base ref + * is often stale, so a `git merge-tree` preview from here would name files + * the agent then finds clean — it has git and can see the real answer. */ +export function buildMergeConflictMessage(pr: PRStatus): string { + const body = [ + `PR #${pr.number} (${pr.branch}) has merge conflicts with ${pr.baseBranch}. Please resolve them.`, + '', + `Fetch the latest ${pr.baseBranch}, merge it into this branch, and resolve each conflict. Verify the build still passes, then commit and push so the PR updates.`, + '', + pr.url + ].join('\n') + return wrapAutomatedMessage('merge-conflict', body) +} diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts index f1344d442..2a93fee10 100644 --- a/src/renderer/build-backend.ts +++ b/src/renderer/build-backend.ts @@ -433,6 +433,8 @@ export function buildBackend( req('ciNotify:setOverride', path, enabled), setNotifyChatOnCiFailure: (enabled: boolean) => req('config:setNotifyChatOnCiFailure', enabled), + requestMergeConflictFix: (worktreePath: string) => + req('prs:requestConflictFix', worktreePath), setAlias: (path: string, alias: string) => req('aliases:set', path, alias), clearAlias: (path: string) => req('aliases:clear', path), diff --git a/src/renderer/components/JsonModeChat.tsx b/src/renderer/components/JsonModeChat.tsx index 2502a038c..ef7b90c62 100644 --- a/src/renderer/components/JsonModeChat.tsx +++ b/src/renderer/components/JsonModeChat.tsx @@ -761,6 +761,14 @@ function automationLabel( brand: true } } + // The one automation the user fires by hand, so it says who asked. + if (source === 'merge-conflict') { + return { + label: 'Ness · Merge conflicts', + note: 'you asked the agent to resolve them', + brand: false + } + } return { label: 'Ness · CI failure', note: 'sent automatically', brand: false } } diff --git a/src/renderer/components/PRStatusPanel.tsx b/src/renderer/components/PRStatusPanel.tsx index 389f24c8b..67c6af4fa 100644 --- a/src/renderer/components/PRStatusPanel.tsx +++ b/src/renderer/components/PRStatusPanel.tsx @@ -1155,6 +1155,11 @@ export function PRStatusPanel({ )} + {worktree && + pr.hasConflict === true && + pr.state !== 'merged' && + pr.state !== 'closed' && } + {worktree && ( ('idle') + const [error, setError] = useState(null) + + useEffect(() => { + if (phase !== 'sent') return + const t = setTimeout(() => setPhase('idle'), 4000) + return () => clearTimeout(t) + }, [phase]) + + const send = useCallback(async () => { + setPhase('sending') + setError(null) + try { + const result = await backend.requestMergeConflictFix(worktreePath) + setPhase(result.ok ? 'sent' : 'idle') + if (!result.ok) setError(result.error || 'Failed to reach the agent') + } catch (err) { + setPhase('idle') + setError(err instanceof Error ? err.message : String(err)) + } + }, [backend, worktreePath]) + + return ( +
+ + {error &&
{error}
} +
+ ) +} + /** Per-worktree opt in/out of the "tell the agent when CI fails" injection. * Toggling back to the global default drops the override entirely so the * worktree tracks future changes to the setting. */ diff --git a/src/renderer/types.ts b/src/renderer/types.ts index f88d117a0..7905eb1bf 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -53,6 +53,11 @@ export interface FileWriteResult { error?: string } +export interface MergeConflictFixResult { + ok: boolean + error?: string +} + export interface FileDiffSides { original: string modified: string @@ -445,6 +450,9 @@ export interface ElectronAPI { * global `notifyChatOnCiFailure` setting again. */ setCiNotifyOverride(path: string, enabled: boolean | null): Promise setNotifyChatOnCiFailure(enabled: boolean): Promise + /** Injects a "resolve this PR's conflicts" turn into the worktree's agent + * chat, waking a slept tab if needed. */ + requestMergeConflictFix(worktreePath: string): Promise setScratchpadText(worktreePath: string, text: string): Promise setAlias(path: string, alias: string): Promise clearAlias(path: string): Promise diff --git a/src/shared/state/json-claude.ts b/src/shared/state/json-claude.ts index 6979733cc..857cc575e 100644 --- a/src/shared/state/json-claude.ts +++ b/src/shared/state/json-claude.ts @@ -74,12 +74,14 @@ export interface JsonClaudeMessageBlock { * Extend the union when a new automation learns to talk to the chat. */ export type JsonClaudeAutomationSource = | 'ci-failure' + | 'merge-conflict' | 'worktree-message' | 'worktree-kickoff' | 'worktree-autoname' const AUTOMATION_SOURCES: readonly string[] = [ 'ci-failure', + 'merge-conflict', 'worktree-message', 'worktree-kickoff', 'worktree-autoname' From ca2abda45b71f3e7389c7afe097ac7ebe1e93998 Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Fri, 21 Aug 2026 08:25:21 -0400 Subject: [PATCH 2/2] Don't prescribe merge over rebase in the conflict handoff Plenty of repos keep linear history, so telling the agent to merge the base branch in was wrong for half of them. The agent is sitting in the repo and can read the convention off git log and CLAUDE.md, so name the goal and let it pick. Co-Authored-By: Claude Opus 4.7 --- src/main/merge-conflict-request.ts | 9 +++++++-- src/renderer/components/PRStatusPanel.tsx | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/merge-conflict-request.ts b/src/main/merge-conflict-request.ts index 86fb4e7cb..fd8c7ce2b 100644 --- a/src/main/merge-conflict-request.ts +++ b/src/main/merge-conflict-request.ts @@ -9,12 +9,17 @@ import { wrapAutomatedMessage } from '../shared/state/json-claude' * * Deliberately doesn't enumerate the conflicted files. The local base ref * is often stale, so a `git merge-tree` preview from here would name files - * the agent then finds clean — it has git and can see the real answer. */ + * the agent then finds clean — it has git and can see the real answer. + * + * Names no strategy for the same reason: rebase-vs-merge is a per-repo + * convention the agent can read off `git log` and CLAUDE.md, and Ness has + * no setting that records it (`mergeStrategy` is how a PR lands on main, + * which says nothing about how a branch syncs with its base). */ export function buildMergeConflictMessage(pr: PRStatus): string { const body = [ `PR #${pr.number} (${pr.branch}) has merge conflicts with ${pr.baseBranch}. Please resolve them.`, '', - `Fetch the latest ${pr.baseBranch}, merge it into this branch, and resolve each conflict. Verify the build still passes, then commit and push so the PR updates.`, + `Bring the branch up to date with the latest ${pr.baseBranch} — rebase or merge, whichever matches this repo's convention — and resolve each conflict. Verify the build still passes, then push so the PR updates (force-with-lease if you rebased).`, '', pr.url ].join('\n') diff --git a/src/renderer/components/PRStatusPanel.tsx b/src/renderer/components/PRStatusPanel.tsx index 67c6af4fa..9a849880c 100644 --- a/src/renderer/components/PRStatusPanel.tsx +++ b/src/renderer/components/PRStatusPanel.tsx @@ -1211,7 +1211,7 @@ function FixConflictsButton({ worktreePath }: { worktreePath: string }): JSX.Ele