Skip to content

Commit ae5b4ec

Browse files
frenchie4111claude
andauthored
Add a button to hand PR merge conflicts to the agent (#294)
## Summary - The PR pane already knows when GitHub reports a conflicting merge, but acting on it meant switching to the chat and typing the request. This adds a one-click handoff: an **Ask the agent to fix conflicts** button appears above the CI-notify checkbox when `hasConflict === true` on an open PR. - Clicking it injects a message naming the PR, branch, and base into that worktree's agent chat, reusing the existing `deliverToWorktreeChat` routing — so it wakes a slept chat tab the same way the CI-failure notifier does. It lands in the transcript as a labelled `Ness · Merge conflicts` card, not as something the user typed. - 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. Two things the message deliberately leaves out, both commented at `buildMergeConflictMessage`: - **No file list.** The local base ref is often stale, so a `git merge-tree` preview from Ness would name files the agent then finds clean — it has git and can see the real answer. - **No merge-vs-rebase prescription.** Plenty of repos keep linear history. The agent can read the convention 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). ## Test plan - [x] `npm run typecheck` clean - [x] `npx electron-vite build` clean - [x] `npx vitest run` — new `merge-conflict-request.test.ts` covers the sentinel round-trip (guards the `merge-conflict` automation source being registered, without which the chat would render it as a plain user turn) and that the PR number / branch / base / URL make it into the body - [ ] **Not visually verified in a running app** — the button only renders when GitHub reports a conflicting PR, which I couldn't stage locally. Worth a manual look on a real conflicted PR before merge: button appearance, the sending → "Asked the agent" → idle transition, and the chat card rendering. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 424036e commit ae5b4ec

8 files changed

Lines changed: 184 additions & 0 deletions

File tree

src/main/index.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ import {
140140
describeWorktree,
141141
resolveWorktreeQuery
142142
} from './chat-delivery'
143+
import { buildMergeConflictMessage } from './merge-conflict-request'
143144
import { writeMcpConfigForTerminal, pruneMcpConfigs, getBridgeScriptPath } from './mcp-config'
144145
import { getControlServerInfo } from './control-server'
145146
import { recordActivity, getActivityLog, clearAllActivity, clearActivityForWorktree, sealAllActive, touchActivityMeta, finalizeActivity, type ActivityState, type PRState } from './activity'
@@ -1974,6 +1975,41 @@ function registerIpcHandlers(): void {
19741975
return true
19751976
})
19761977

1978+
// Manual counterpart to the CI-failure notifier: the user asks the agent
1979+
// to go resolve this PR's conflicts. Never fires on its own — see
1980+
// buildMergeConflictMessage.
1981+
transport.onRequest(
1982+
'prs:requestConflictFix',
1983+
(_ctx, worktreePath: string): { ok: boolean; error?: string } => {
1984+
if (typeof worktreePath !== 'string' || !worktreePath) {
1985+
return { ok: false, error: 'No worktree' }
1986+
}
1987+
const state = store.getSnapshot().state
1988+
const pr = state.prs.byPath[worktreePath]
1989+
if (!pr) return { ok: false, error: 'No PR for this branch' }
1990+
const result = deliverToWorktreeChat(
1991+
state,
1992+
chatDeliveryDeps,
1993+
worktreePath,
1994+
buildMergeConflictMessage(pr)
1995+
)
1996+
if (!result.ok) {
1997+
return {
1998+
ok: false,
1999+
error:
2000+
result.reason === 'no-chat-tab'
2001+
? 'No agent chat tab in this worktree'
2002+
: "Couldn't wake the agent chat tab"
2003+
}
2004+
}
2005+
log(
2006+
'merge-conflict',
2007+
`asked ${result.sessionId} to fix conflicts on ${worktreePath}${result.woke ? ' (woke tab)' : ''}`
2008+
)
2009+
return { ok: true }
2010+
}
2011+
)
2012+
19772013
transport.onRequest('announcements:refresh', async (_ctx) => {
19782014
await announcementsPoller.refresh()
19792015
return true
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, it, expect } from 'vitest'
2+
3+
import { buildMergeConflictMessage } from './merge-conflict-request'
4+
import type { PRStatus } from '../shared/state/prs'
5+
import { parseAutomatedMessage } from '../shared/state/json-claude'
6+
7+
function pr(overrides: Partial<PRStatus> = {}): PRStatus {
8+
return {
9+
number: 7,
10+
title: 'Add a thing',
11+
state: 'open',
12+
url: 'https://github.com/o/r/pull/7',
13+
branch: 'feat/thing',
14+
headSha: 'abc123',
15+
author: null,
16+
checks: [],
17+
checksOverall: 'none',
18+
hasConflict: true,
19+
reviews: [],
20+
reviewDecision: 'none',
21+
baseBranch: 'main',
22+
isDefaultBase: true,
23+
assignees: [],
24+
linkedIssues: [],
25+
labels: [],
26+
...overrides
27+
}
28+
}
29+
30+
describe('buildMergeConflictMessage', () => {
31+
it('round-trips through the automation sentinel so the chat renders a card', () => {
32+
const parsed = parseAutomatedMessage(buildMergeConflictMessage(pr()))
33+
expect(parsed?.source).toBe('merge-conflict')
34+
})
35+
36+
it('names the PR, its branch, and the base it conflicts with', () => {
37+
const body = parseAutomatedMessage(
38+
buildMergeConflictMessage(pr({ number: 42, branch: 'fix/login', baseBranch: 'develop' }))
39+
)?.body
40+
expect(body).toContain('#42')
41+
expect(body).toContain('fix/login')
42+
expect(body).toContain('develop')
43+
expect(body).toContain('https://github.com/o/r/pull/7')
44+
})
45+
})

src/main/merge-conflict-request.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { PRStatus } from '../shared/state/prs'
2+
import { wrapAutomatedMessage } from '../shared/state/json-claude'
3+
4+
/** Compose the message injected into the agent chat when the user asks for
5+
* help with a conflicted PR. Unlike CI failures this is never automatic:
6+
* a branch that conflicts with its base usually conflicts with every other
7+
* in-flight branch too, so auto-firing would cascade agents across the
8+
* whole workspace over one bad merge base.
9+
*
10+
* Deliberately doesn't enumerate the conflicted files. The local base ref
11+
* is often stale, so a `git merge-tree` preview from here would name files
12+
* the agent then finds clean — it has git and can see the real answer.
13+
*
14+
* Names no strategy for the same reason: rebase-vs-merge is a per-repo
15+
* convention the agent can read off `git log` and CLAUDE.md, and Ness has
16+
* no setting that records it (`mergeStrategy` is how a PR lands on main,
17+
* which says nothing about how a branch syncs with its base). */
18+
export function buildMergeConflictMessage(pr: PRStatus): string {
19+
const body = [
20+
`PR #${pr.number} (${pr.branch}) has merge conflicts with ${pr.baseBranch}. Please resolve them.`,
21+
'',
22+
`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).`,
23+
'',
24+
pr.url
25+
].join('\n')
26+
return wrapAutomatedMessage('merge-conflict', body)
27+
}

src/renderer/build-backend.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,8 @@ export function buildBackend(
436436
req('ciNotify:setOverride', path, enabled),
437437
setNotifyChatOnCiFailure: (enabled: boolean) =>
438438
req('config:setNotifyChatOnCiFailure', enabled),
439+
requestMergeConflictFix: (worktreePath: string) =>
440+
req('prs:requestConflictFix', worktreePath),
439441
setAlias: (path: string, alias: string) => req('aliases:set', path, alias),
440442
clearAlias: (path: string) => req('aliases:clear', path),
441443

src/renderer/components/JsonModeChat.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,14 @@ function automationLabel(
761761
brand: true
762762
}
763763
}
764+
// The one automation the user fires by hand, so it says who asked.
765+
if (source === 'merge-conflict') {
766+
return {
767+
label: 'Ness · Merge conflicts',
768+
note: 'you asked the agent to resolve them',
769+
brand: false
770+
}
771+
}
764772
return { label: 'Ness · CI failure', note: 'sent automatically', brand: false }
765773
}
766774

src/renderer/components/PRStatusPanel.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1193,6 +1193,11 @@ export function PRStatusPanel({
11931193
</div>
11941194
)}
11951195

1196+
{worktree &&
1197+
pr.hasConflict === true &&
1198+
pr.state !== 'merged' &&
1199+
pr.state !== 'closed' && <FixConflictsButton worktreePath={worktree.path} />}
1200+
11961201
{worktree && (
11971202
<CiNotifyToggle
11981203
worktreePath={worktree.path}
@@ -1210,6 +1215,57 @@ export function PRStatusPanel({
12101215
)
12111216
}
12121217

1218+
/** Hands the conflict off to the worktree's agent. A button rather than the
1219+
* standing opt-in CI failures get: a branch that conflicts with its base
1220+
* usually conflicts with every other in-flight branch too, so firing this
1221+
* automatically would put the whole workspace to work over one bad merge
1222+
* base. */
1223+
function FixConflictsButton({ worktreePath }: { worktreePath: string }): JSX.Element {
1224+
const backend = useBackend()
1225+
const [phase, setPhase] = useState<'idle' | 'sending' | 'sent'>('idle')
1226+
const [error, setError] = useState<string | null>(null)
1227+
1228+
useEffect(() => {
1229+
if (phase !== 'sent') return
1230+
const t = setTimeout(() => setPhase('idle'), 4000)
1231+
return () => clearTimeout(t)
1232+
}, [phase])
1233+
1234+
const send = useCallback(async () => {
1235+
setPhase('sending')
1236+
setError(null)
1237+
try {
1238+
const result = await backend.requestMergeConflictFix(worktreePath)
1239+
setPhase(result.ok ? 'sent' : 'idle')
1240+
if (!result.ok) setError(result.error || 'Failed to reach the agent')
1241+
} catch (err) {
1242+
setPhase('idle')
1243+
setError(err instanceof Error ? err.message : String(err))
1244+
}
1245+
}, [backend, worktreePath])
1246+
1247+
return (
1248+
<div className="space-y-1 mt-1.5">
1249+
<button
1250+
onClick={() => void send()}
1251+
disabled={phase !== 'idle'}
1252+
title="Post a message into this worktree's agent chat asking it to bring the branch up to date with its base and resolve the conflicts."
1253+
className="w-full text-xs bg-danger/15 hover:bg-danger/25 text-danger px-2 py-1.5 rounded flex items-center justify-center gap-1.5 cursor-pointer disabled:cursor-default disabled:opacity-70"
1254+
>
1255+
{phase === 'sending' ? (
1256+
<Loader2 className="icon-xs animate-spin" />
1257+
) : phase === 'sent' ? (
1258+
<Check className="icon-xs" />
1259+
) : (
1260+
<GitMergeConflict className="icon-xs" />
1261+
)}
1262+
{phase === 'sent' ? 'Asked the agent' : 'Ask the agent to fix conflicts'}
1263+
</button>
1264+
{error && <div className="text-xs text-danger leading-snug break-words">{error}</div>}
1265+
</div>
1266+
)
1267+
}
1268+
12131269
/** Per-worktree opt in/out of the "tell the agent when CI fails" injection.
12141270
* Toggling back to the global default drops the override entirely so the
12151271
* worktree tracks future changes to the setting. */

src/renderer/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ export interface FileWriteResult {
5353
error?: string
5454
}
5555

56+
export interface MergeConflictFixResult {
57+
ok: boolean
58+
error?: string
59+
}
60+
5661
export interface FileDiffSides {
5762
original: string
5863
modified: string
@@ -448,6 +453,9 @@ export interface ElectronAPI {
448453
* global `notifyChatOnCiFailure` setting again. */
449454
setCiNotifyOverride(path: string, enabled: boolean | null): Promise<boolean>
450455
setNotifyChatOnCiFailure(enabled: boolean): Promise<boolean>
456+
/** Injects a "resolve this PR's conflicts" turn into the worktree's agent
457+
* chat, waking a slept tab if needed. */
458+
requestMergeConflictFix(worktreePath: string): Promise<MergeConflictFixResult>
451459
setScratchpadText(worktreePath: string, text: string): Promise<boolean>
452460
setAlias(path: string, alias: string): Promise<boolean>
453461
clearAlias(path: string): Promise<boolean>

src/shared/state/json-claude.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,14 @@ export interface JsonClaudeMessageBlock {
7474
* Extend the union when a new automation learns to talk to the chat. */
7575
export type JsonClaudeAutomationSource =
7676
| 'ci-failure'
77+
| 'merge-conflict'
7778
| 'worktree-message'
7879
| 'worktree-kickoff'
7980
| 'worktree-autoname'
8081

8182
const AUTOMATION_SOURCES: readonly string[] = [
8283
'ci-failure',
84+
'merge-conflict',
8385
'worktree-message',
8486
'worktree-kickoff',
8587
'worktree-autoname'

0 commit comments

Comments
 (0)