Skip to content

Commit 50db321

Browse files
committed
feat: inline diff review — Codex-style unified diffs in chat messages
Replaces flat 'Apply/Diff' buttons with inline unified diff viewers: - Line numbers + gutter signs (+/-/space) - Color-coded add (green) / delete (red) / context lines - Collapsible per-file with file path header - +N/-N change count badges - Per-file Apply button + Reject button - 'Apply all N files' batch button for multi-file proposals - NEW badge for created files - Expandable output (max 64 lines visible, scrollable) - Computed from original file content via getFileContent prop
1 parent 5566765 commit 50db321

File tree

3 files changed

+238
-30
lines changed

3 files changed

+238
-30
lines changed

components/agent-panel.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2189,6 +2189,8 @@ export function AgentPanel() {
21892189
agentMode={agentMode}
21902190
onShowDiff={handleShowDiff}
21912191
onQuickApply={handleQuickApply}
2192+
onApplyAll={(proposals) => proposals.forEach(handleQuickApply)}
2193+
getFileContent={(filePath) => getFile(filePath)?.content}
21922194
onDeleteMessage={handleDeleteMessage}
21932195
onRegenerate={handleRegenerate}
21942196
onEditAndResend={handleEditAndResend}

components/chat/inline-diff.tsx

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
'use client'
2+
3+
import { useState, useMemo } from 'react'
4+
import { Icon } from '@iconify/react'
5+
import type { EditProposal } from '@/lib/edit-parser'
6+
7+
interface Props {
8+
proposal: EditProposal
9+
original?: string
10+
onApply: (proposal: EditProposal) => void
11+
onReject?: () => void
12+
}
13+
14+
interface DiffLine {
15+
type: 'add' | 'del' | 'ctx'
16+
content: string
17+
oldNum?: number
18+
newNum?: number
19+
}
20+
21+
/**
22+
* Compute a simple unified diff between two strings.
23+
* Uses a basic LCS-based line diff — no external deps.
24+
*/
25+
function computeDiff(oldText: string, newText: string): DiffLine[] {
26+
const oldLines = oldText.split('\n')
27+
const newLines = newText.split('\n')
28+
const lines: DiffLine[] = []
29+
30+
// Simple diff: find common prefix, suffix, then show changes
31+
let prefixLen = 0
32+
while (prefixLen < oldLines.length && prefixLen < newLines.length && oldLines[prefixLen] === newLines[prefixLen]) {
33+
prefixLen++
34+
}
35+
36+
let suffixLen = 0
37+
while (
38+
suffixLen < oldLines.length - prefixLen &&
39+
suffixLen < newLines.length - prefixLen &&
40+
oldLines[oldLines.length - 1 - suffixLen] === newLines[newLines.length - 1 - suffixLen]
41+
) {
42+
suffixLen++
43+
}
44+
45+
// Context lines before changes (show up to 3)
46+
const ctxStart = Math.max(0, prefixLen - 3)
47+
for (let i = ctxStart; i < prefixLen; i++) {
48+
lines.push({ type: 'ctx', content: oldLines[i], oldNum: i + 1, newNum: i + 1 })
49+
}
50+
51+
// Deleted lines
52+
const oldEnd = oldLines.length - suffixLen
53+
for (let i = prefixLen; i < oldEnd; i++) {
54+
lines.push({ type: 'del', content: oldLines[i], oldNum: i + 1 })
55+
}
56+
57+
// Added lines
58+
const newEnd = newLines.length - suffixLen
59+
for (let i = prefixLen; i < newEnd; i++) {
60+
lines.push({ type: 'add', content: newLines[i], newNum: i + 1 })
61+
}
62+
63+
// Context lines after changes (show up to 3)
64+
const ctxEnd = Math.min(oldLines.length, oldEnd + 3)
65+
for (let i = oldEnd; i < ctxEnd; i++) {
66+
const newIdx = i - oldEnd + newEnd
67+
lines.push({ type: 'ctx', content: oldLines[i], oldNum: i + 1, newNum: newIdx + 1 })
68+
}
69+
70+
return lines
71+
}
72+
73+
/**
74+
* Inline unified diff viewer — renders directly in chat message.
75+
* Codex-inspired: line numbers, gutter signs, approve/reject buttons.
76+
*/
77+
export function InlineDiff({ proposal, original, onApply, onReject }: Props) {
78+
const [collapsed, setCollapsed] = useState(false)
79+
const fileName = proposal.filePath.split('/').pop() || proposal.filePath
80+
const isNewFile = !original
81+
82+
const diffLines = useMemo(() => {
83+
if (isNewFile) {
84+
return proposal.content.split('\n').map((line, i): DiffLine => ({
85+
type: 'add', content: line, newNum: i + 1,
86+
}))
87+
}
88+
return computeDiff(original, proposal.content)
89+
}, [original, proposal.content, isNewFile])
90+
91+
const additions = diffLines.filter(l => l.type === 'add').length
92+
const deletions = diffLines.filter(l => l.type === 'del').length
93+
94+
return (
95+
<div className="rounded-lg border border-[var(--border)] overflow-hidden my-1.5">
96+
{/* File header */}
97+
<div className="flex items-center gap-2 px-3 py-1.5 bg-[color-mix(in_srgb,var(--bg-elevated)_90%,transparent)] border-b border-[var(--border)]">
98+
<button onClick={() => setCollapsed(!collapsed)} className="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
99+
<Icon icon={collapsed ? 'lucide:chevron-right' : 'lucide:chevron-down'} width={12} className="text-[var(--text-disabled)] shrink-0" />
100+
<Icon icon="lucide:file-diff" width={12} className="text-[var(--text-tertiary)] shrink-0" />
101+
<span className="text-[11px] font-mono text-[var(--text-primary)] truncate">{proposal.filePath}</span>
102+
{isNewFile && (
103+
<span className="text-[9px] px-1.5 py-0.5 rounded bg-[color-mix(in_srgb,#34d399_15%,transparent)] text-[color-mix(in_srgb,#34d399_80%,var(--brand))] font-medium">NEW</span>
104+
)}
105+
</button>
106+
<div className="flex items-center gap-2 shrink-0">
107+
{additions > 0 && <span className="text-[10px] font-mono text-[color-mix(in_srgb,#34d399_80%,var(--brand))]">+{additions}</span>}
108+
{deletions > 0 && <span className="text-[10px] font-mono text-red-400">-{deletions}</span>}
109+
<button
110+
onClick={() => onApply(proposal)}
111+
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium transition-colors cursor-pointer
112+
bg-[color-mix(in_srgb,#34d399_12%,transparent)] text-[color-mix(in_srgb,#34d399_80%,var(--brand))]
113+
hover:bg-[color-mix(in_srgb,#34d399_20%,transparent)]
114+
border border-[color-mix(in_srgb,#34d399_25%,transparent)]"
115+
>
116+
<Icon icon="lucide:check" width={10} />
117+
Apply
118+
</button>
119+
{onReject && (
120+
<button
121+
onClick={onReject}
122+
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium transition-colors cursor-pointer
123+
text-[var(--text-disabled)] hover:text-red-400
124+
hover:bg-[color-mix(in_srgb,red_8%,transparent)]"
125+
>
126+
<Icon icon="lucide:x" width={10} />
127+
</button>
128+
)}
129+
</div>
130+
</div>
131+
132+
{/* Diff content */}
133+
{!collapsed && (
134+
<div className="overflow-x-auto max-h-64 overflow-y-auto text-[11px] font-mono leading-[18px]">
135+
{diffLines.length === 0 ? (
136+
<div className="px-3 py-2 text-[var(--text-disabled)] text-[10px]">No changes</div>
137+
) : (
138+
<table className="w-full border-collapse">
139+
<tbody>
140+
{diffLines.map((line, i) => (
141+
<tr
142+
key={i}
143+
className={
144+
line.type === 'add'
145+
? 'bg-[color-mix(in_srgb,#34d399_6%,transparent)]'
146+
: line.type === 'del'
147+
? 'bg-[color-mix(in_srgb,red_6%,transparent)]'
148+
: ''
149+
}
150+
>
151+
{/* Old line number */}
152+
<td className="w-8 text-right pr-1 select-none text-[9px] text-[var(--text-disabled)] align-top">
153+
{line.type !== 'add' ? line.oldNum : ''}
154+
</td>
155+
{/* New line number */}
156+
<td className="w-8 text-right pr-1 select-none text-[9px] text-[var(--text-disabled)] align-top">
157+
{line.type !== 'del' ? line.newNum : ''}
158+
</td>
159+
{/* Gutter sign */}
160+
<td className={`w-4 text-center select-none align-top ${
161+
line.type === 'add' ? 'text-[color-mix(in_srgb,#34d399_80%,var(--brand))]' :
162+
line.type === 'del' ? 'text-red-400' : 'text-[var(--text-disabled)]'
163+
}`}>
164+
{line.type === 'add' ? '+' : line.type === 'del' ? '-' : ' '}
165+
</td>
166+
{/* Content */}
167+
<td className={`pl-1 pr-3 whitespace-pre ${
168+
line.type === 'add' ? 'text-[color-mix(in_srgb,#34d399_90%,var(--text-primary))]' :
169+
line.type === 'del' ? 'text-red-300' : 'text-[var(--text-secondary)]'
170+
}`}>
171+
{line.content || '\u00A0'}
172+
</td>
173+
</tr>
174+
))}
175+
</tbody>
176+
</table>
177+
)}
178+
</div>
179+
)}
180+
</div>
181+
)
182+
}
183+
184+
/**
185+
* Batch diff view for multiple edit proposals.
186+
*/
187+
export function InlineDiffGroup({
188+
proposals,
189+
getOriginal,
190+
onApply,
191+
onApplyAll,
192+
}: {
193+
proposals: EditProposal[]
194+
getOriginal: (filePath: string) => string | undefined
195+
onApply: (proposal: EditProposal) => void
196+
onApplyAll: () => void
197+
}) {
198+
if (proposals.length === 0) return null
199+
200+
return (
201+
<div className="space-y-1">
202+
{proposals.map((proposal, i) => (
203+
<InlineDiff
204+
key={`${proposal.filePath}-${i}`}
205+
proposal={proposal}
206+
original={getOriginal(proposal.filePath)}
207+
onApply={onApply}
208+
/>
209+
))}
210+
{proposals.length > 1 && (
211+
<button
212+
onClick={onApplyAll}
213+
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-medium transition-colors cursor-pointer w-full justify-center
214+
bg-[color-mix(in_srgb,#34d399_10%,transparent)] text-[color-mix(in_srgb,#34d399_80%,var(--brand))]
215+
hover:bg-[color-mix(in_srgb,#34d399_18%,transparent)]
216+
border border-[color-mix(in_srgb,#34d399_20%,transparent)]"
217+
>
218+
<Icon icon="lucide:check-check" width={13} />
219+
Apply all {proposals.length} files
220+
</button>
221+
)}
222+
</div>
223+
)
224+
}

components/chat/message-list.tsx

Lines changed: 12 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useChatAppearance } from '@/context/chat-appearance-context'
1212
import type { ChatMessage } from '@/lib/chat-stream'
1313
import type { EditProposal } from '@/lib/edit-parser'
1414
import { AgentActivityFeed } from '@/components/chat/agent-activity-feed'
15+
import { InlineDiffGroup } from '@/components/chat/inline-diff'
1516

1617
interface MessageListProps {
1718
messages: ChatMessage[]
@@ -23,6 +24,8 @@ interface MessageListProps {
2324
agentMode: string
2425
onShowDiff: (proposal: EditProposal, messageId: string) => void
2526
onQuickApply: (proposal: EditProposal) => void
27+
onApplyAll?: (proposals: EditProposal[]) => void
28+
getFileContent?: (filePath: string) => string | undefined
2629
onDeleteMessage: (id: string) => void
2730
onRegenerate: (id: string) => void
2831
onEditAndResend: (id: string) => void
@@ -190,6 +193,8 @@ export function MessageList({
190193
agentMode,
191194
onShowDiff,
192195
onQuickApply,
196+
onApplyAll,
197+
getFileContent,
193198
onDeleteMessage,
194199
onRegenerate,
195200
onEditAndResend,
@@ -562,37 +567,14 @@ export function MessageList({
562567
)}
563568
</div>
564569

565-
{/* Edit proposal buttons */}
570+
{/* Inline diff review */}
566571
{msg.editProposals && msg.editProposals.length > 0 && (
567-
<div className="flex flex-col gap-1 mt-1.5">
568-
{msg.editProposals.map((proposal, i) => (
569-
<div key={i} className="flex items-center gap-1 flex-wrap">
570-
<button
571-
onClick={() => onQuickApply(proposal)}
572-
className="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[11px] font-medium border transition-colors cursor-pointer"
573-
style={{
574-
borderColor:
575-
'color-mix(in srgb, var(--color-additions) 40%, transparent)',
576-
backgroundColor:
577-
'color-mix(in srgb, var(--color-additions) 12%, transparent)',
578-
color: 'var(--color-additions)',
579-
}}
580-
title="Apply changes to editor"
581-
>
582-
<Icon icon="lucide:play" width={13} height={13} />
583-
Apply to {proposal.filePath.split('/').pop()}
584-
</button>
585-
<button
586-
onClick={() => onShowDiff(proposal, msg.id)}
587-
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-[11px] font-medium transition-colors cursor-pointer text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
588-
title="Review changes in diff viewer first"
589-
>
590-
<Icon icon="lucide:git-compare" width={13} height={13} />
591-
Diff
592-
</button>
593-
</div>
594-
))}
595-
</div>
572+
<InlineDiffGroup
573+
proposals={msg.editProposals}
574+
getOriginal={(filePath) => getFileContent?.(filePath)}
575+
onApply={onQuickApply}
576+
onApplyAll={() => onApplyAll?.(msg.editProposals!)}
577+
/>
596578
)}
597579
</div>
598580
)

0 commit comments

Comments
 (0)