From 2031c2fa09304d50ac3dd6d9fbc4b34abef71fb2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 04:30:04 +0000 Subject: [PATCH 1/3] fix: record planVersions on create, sync, and editor saves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI sync was bumping plans.version without writing history rows, and creates never snapshotted v1, so Plan History stayed empty for the common path. Centralize snapshot writes and backfill a baseline when updating plans that have no history yet. Co-authored-by: Tyrus Malmström --- packages/ee/convex/README.md | 1 + packages/ee/convex/cli.ts | 61 ++++++++++-- packages/ee/convex/planVersioning.test.ts | 23 +++++ packages/ee/convex/planVersioning.ts | 81 ++++++++++++++++ packages/ee/convex/planVersions.ts | 16 ++-- packages/ee/convex/plans.ts | 93 +++++++++++++++---- .../ee/src/components/PlanHistoryDrawer.tsx | 2 +- 7 files changed, 242 insertions(+), 35 deletions(-) create mode 100644 packages/ee/convex/planVersioning.test.ts create mode 100644 packages/ee/convex/planVersioning.ts diff --git a/packages/ee/convex/README.md b/packages/ee/convex/README.md index 3caabbc0..67d6a5de 100644 --- a/packages/ee/convex/README.md +++ b/packages/ee/convex/README.md @@ -18,6 +18,7 @@ This directory contains the Convex backend for Agendex Cloud / EE. It powers aut - `plans.ts` - EE plan retrieval helpers and shared plan access - `planVisibility.ts` - shared low-value plan classification on ingest, metadata merge, and visibility gates for reads - `planVersions.ts` - plan history listing, snapshot reads, and restore flow +- `planVersioning.ts` - shared helpers that write `planVersions` snapshots on create, CLI sync, editor save, and restore - `planCleanup.ts` - internal dry-run audit and apply cleanup for existing low-value cloud rows and Codex subagent/title-family clones (maintainer-only) - `sharing.ts` - create and revoke share links - `comments.ts` - read, create, and delete plan comments diff --git a/packages/ee/convex/cli.ts b/packages/ee/convex/cli.ts index 8624059e..1e33f14d 100644 --- a/packages/ee/convex/cli.ts +++ b/packages/ee/convex/cli.ts @@ -17,6 +17,7 @@ import { mergePlanMetadata, metadataWithPlanValueAssessment, } from './planVisibility'; +import { ensureBaselinePlanVersion, recordPlanVersion } from './planVersioning'; import { stripLocalIpFromMetadata } from './privacy'; const DAEMON_HEARTBEAT_RETENTION_MS = 7 * 86_400_000; @@ -355,23 +356,54 @@ export const upsertPlan = internalMutation({ handler: async (ctx, args) => { const now = Date.now(); const continuityKey = plannotatorContinuityKey(args.metadata, args.filePath); + const updatedAt = args.updatedAt ?? now; if (args.existingId && args.existingVersion !== undefined) { - await ctx.db.patch(args.existingId, { - agent: args.agent, + const existing = await ctx.db.get(args.existingId); + if (existing) { + await ensureBaselinePlanVersion(ctx, { + ownerId: args.ownerId, + planId: args.existingId, + version: args.existingVersion, + snapshot: { + title: existing.title, + content: existing.content, + format: existing.format, + filePath: existing.filePath, + workspace: existing.workspace, + metadata: existing.metadata, + }, + createdAt: existing.updatedAt, + }); + } + + const newVersion = args.existingVersion + 1; + const snapshot = { title: args.title, content: args.content, format: args.format, filePath: args.filePath, workspace: args.workspace, metadata: args.metadata, + }; + await ctx.db.patch(args.existingId, { + agent: args.agent, + ...snapshot, ...(continuityKey ? { plannotatorContinuityKey: continuityKey } : {}), syncIdentityKey: args.syncIdentityKey, contentHash: args.contentHash, identityVersion: args.identityVersion, identityStrength: args.identityStrength, - version: args.existingVersion + 1, - updatedAt: args.updatedAt ?? now, + version: newVersion, + updatedAt, + }); + await recordPlanVersion(ctx, { + ownerId: args.ownerId, + planId: args.existingId, + version: newVersion, + snapshot, + source: 'cli_sync', + createdAt: updatedAt, }); await markSupersededPlannotatorSessions(ctx, { ownerId: args.ownerId, @@ -384,6 +416,7 @@ export const upsertPlan = internalMutation({ return args.existingId; } + const createdAt = args.createdAt ?? now; const planId = await ctx.db.insert('plans', { ownerId: args.ownerId, localPlanId: args.localPlanId, @@ -400,8 +433,24 @@ export const upsertPlan = internalMutation({ identityVersion: args.identityVersion, identityStrength: args.identityStrength, version: 1, - createdAt: args.createdAt ?? now, - updatedAt: args.updatedAt ?? now, + createdAt, + updatedAt, + }); + + await recordPlanVersion(ctx, { + ownerId: args.ownerId, + planId, + version: 1, + snapshot: { + title: args.title, + content: args.content, + format: args.format, + filePath: args.filePath, + workspace: args.workspace, + metadata: args.metadata, + }, + source: 'cli_sync', + createdAt, }); await markSupersededPlannotatorSessions(ctx, { diff --git a/packages/ee/convex/planVersioning.test.ts b/packages/ee/convex/planVersioning.test.ts new file mode 100644 index 00000000..ecfeaaf3 --- /dev/null +++ b/packages/ee/convex/planVersioning.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from 'bun:test'; +import { planContentChanged } from './planVersioning'; + +test('planContentChanged is false when title and content match', () => { + expect( + planContentChanged( + { title: 'Plan', content: '# Steps\n- do thing\n' }, + { title: 'Plan', content: '# Steps\n- do thing\n' }, + ), + ).toBe(false); +}); + +test('planContentChanged is true when title changes', () => { + expect( + planContentChanged({ title: 'Plan', content: 'body' }, { title: 'Plan v2', content: 'body' }), + ).toBe(true); +}); + +test('planContentChanged is true when content changes', () => { + expect( + planContentChanged({ title: 'Plan', content: 'old' }, { title: 'Plan', content: 'new' }), + ).toBe(true); +}); diff --git a/packages/ee/convex/planVersioning.ts b/packages/ee/convex/planVersioning.ts new file mode 100644 index 00000000..0c9011be --- /dev/null +++ b/packages/ee/convex/planVersioning.ts @@ -0,0 +1,81 @@ +import type { Id } from './_generated/dataModel'; +import type { MutationCtx } from './_generated/server'; + +export type PlanVersionSource = 'cli_sync' | 'editor' | 'restore'; + +export type PlanVersionSnapshot = { + title: string; + content: string; + format: string; + filePath?: string; + workspace?: string; + metadata?: unknown; +}; + +type PlanVersionWriteCtx = Pick; + +/** True when title or body changed enough to warrant a new history snapshot. */ +export function planContentChanged( + previous: { title: string; content: string }, + next: { title: string; content: string }, +): boolean { + return previous.title !== next.title || previous.content !== next.content; +} + +/** Insert an immutable planVersions row for the given plan version number. */ +export async function recordPlanVersion( + ctx: PlanVersionWriteCtx, + args: { + ownerId: string; + planId: Id<'plans'>; + version: number; + snapshot: PlanVersionSnapshot; + source?: PlanVersionSource; + createdAt?: number; + }, +): Promise> { + return await ctx.db.insert('planVersions', { + ownerId: args.ownerId, + planId: args.planId, + version: args.version, + title: args.snapshot.title, + content: args.snapshot.content, + format: args.snapshot.format, + filePath: args.snapshot.filePath, + workspace: args.snapshot.workspace, + metadata: args.snapshot.metadata, + ...(args.source ? { source: args.source } : {}), + createdAt: args.createdAt ?? Date.now(), + }); +} + +/** + * If a plan has never been snapshotted (common for rows created before history + * was wired into sync), capture the current live content at `version` so the + * next bump does not lose the pre-change state. + */ +export async function ensureBaselinePlanVersion( + ctx: PlanVersionWriteCtx, + args: { + ownerId: string; + planId: Id<'plans'>; + version: number; + snapshot: PlanVersionSnapshot; + createdAt?: number; + }, +): Promise { + const existing = await ctx.db + .query('planVersions') + .withIndex('by_plan', (q) => q.eq('planId', args.planId)) + .first(); + if (existing) return false; + + await recordPlanVersion(ctx, { + ownerId: args.ownerId, + planId: args.planId, + version: args.version, + snapshot: args.snapshot, + createdAt: args.createdAt, + }); + return true; +} diff --git a/packages/ee/convex/planVersions.ts b/packages/ee/convex/planVersions.ts index fbaf12ee..2ceb7e3d 100644 --- a/packages/ee/convex/planVersions.ts +++ b/packages/ee/convex/planVersions.ts @@ -4,6 +4,7 @@ import { mutation, query } from './_generated/server'; import { authComponent } from './auth'; import { requireFeature } from './entitlements'; import { assessPlanForVisibility, metadataWithPlanValueAssessment } from './planVisibility'; +import { recordPlanVersion } from './planVersioning'; export const listForPlan = query({ args: { planId: v.id('plans') }, @@ -124,27 +125,26 @@ export const restore = mutation({ content: snapshot.content, }); - await ctx.db.patch(args.planId, { + const restoredSnapshot = { title: snapshot.title, content: snapshot.content, format: snapshot.format, filePath: snapshot.filePath, workspace: snapshot.workspace, metadata, + }; + + await ctx.db.patch(args.planId, { + ...restoredSnapshot, version: newVersion, updatedAt: now, }); - await ctx.db.insert('planVersions', { + await recordPlanVersion(ctx, { ownerId: user._id, planId: args.planId, version: newVersion, - title: snapshot.title, - content: snapshot.content, - format: snapshot.format, - filePath: snapshot.filePath, - workspace: snapshot.workspace, - metadata, + snapshot: restoredSnapshot, source: 'restore', createdAt: now, }); diff --git a/packages/ee/convex/plans.ts b/packages/ee/convex/plans.ts index dd973e2f..7c60dfc7 100644 --- a/packages/ee/convex/plans.ts +++ b/packages/ee/convex/plans.ts @@ -12,6 +12,7 @@ import { isVisiblePlan, metadataWithPlanValueAssessment, } from './planVisibility'; +import { ensureBaselinePlanVersion, planContentChanged, recordPlanVersion } from './planVersioning'; import { hasActiveSubscriptionForUserId } from './subscriptions'; export const publishPlan = mutation({ @@ -49,35 +50,52 @@ export const publishPlan = mutation({ .first(); if (existing) { + if (!planContentChanged(existing, args)) { + return existing._id; + } + + await ensureBaselinePlanVersion(ctx, { + ownerId, + planId: existing._id, + version: existing.version, + snapshot: { + title: existing.title, + content: existing.content, + format: existing.format, + filePath: existing.filePath, + workspace: existing.workspace, + metadata: existing.metadata, + }, + createdAt: existing.updatedAt, + }); + const newVersion = existing.version + 1; - await ctx.db.patch(existing._id, { - agent: args.agent, + const snapshot = { title: args.title, content: args.content, format: args.format, filePath: args.filePath, workspace: args.workspace, metadata, + }; + await ctx.db.patch(existing._id, { + agent: args.agent, + ...snapshot, version: newVersion, updatedAt: now, }); - await ctx.db.insert('planVersions', { + await recordPlanVersion(ctx, { ownerId, planId: existing._id, version: newVersion, - title: args.title, - content: args.content, - format: args.format, - filePath: args.filePath, - workspace: args.workspace, - metadata, - source: 'cli_sync', + snapshot, + source: 'editor', createdAt: now, }); return existing._id; } - return await ctx.db.insert('plans', { + const planId = await ctx.db.insert('plans', { ownerId, localPlanId: args.localPlanId, agent: args.agent, @@ -91,6 +109,24 @@ export const publishPlan = mutation({ createdAt: now, updatedAt: now, }); + + await recordPlanVersion(ctx, { + ownerId, + planId, + version: 1, + snapshot: { + title: args.title, + content: args.content, + format: args.format, + filePath: args.filePath, + workspace: args.workspace, + metadata, + }, + source: 'editor', + createdAt: now, + }); + + return planId; }, }); @@ -339,16 +375,39 @@ export const updatePlanContent = mutation({ throw new ConvexError('Access denied'); } - if (plan.title === args.title && plan.content === args.content) { + if (!planContentChanged(plan, args)) { return; } + await ensureBaselinePlanVersion(ctx, { + ownerId: user._id, + planId: args.planId, + version: plan.version, + snapshot: { + title: plan.title, + content: plan.content, + format: plan.format, + filePath: plan.filePath, + workspace: plan.workspace, + metadata: plan.metadata, + }, + createdAt: plan.updatedAt, + }); + const newVersion = plan.version + 1; const now = Date.now(); const metadata = metadataWithPlanValueAssessment(plan.metadata, { title: args.title, content: args.content, }); + const snapshot = { + title: args.title, + content: args.content, + format: plan.format, + filePath: plan.filePath, + workspace: plan.workspace, + metadata, + }; await ctx.db.patch(args.planId, { title: args.title, @@ -358,22 +417,16 @@ export const updatePlanContent = mutation({ updatedAt: now, }); - await ctx.db.insert('planVersions', { + await recordPlanVersion(ctx, { ownerId: user._id, planId: args.planId, version: newVersion, - title: args.title, - content: args.content, - format: plan.format, - filePath: plan.filePath, - workspace: plan.workspace, - metadata, + snapshot, source: 'editor', createdAt: now, }); }, }); - export const deletePlan = mutation({ args: { planId: v.id('plans') }, handler: async (ctx, args) => { diff --git a/packages/ee/src/components/PlanHistoryDrawer.tsx b/packages/ee/src/components/PlanHistoryDrawer.tsx index 760ee47b..fa50d238 100644 --- a/packages/ee/src/components/PlanHistoryDrawer.tsx +++ b/packages/ee/src/components/PlanHistoryDrawer.tsx @@ -106,7 +106,7 @@ export function PlanHistoryDrawer({ planId, onClose }: { planId: string; onClose

No history yet

-

History will appear after your next edit.

+

Edits and CLI syncs will appear here as versions.

) : ( <> From fb4889ea810f48ceb10ee9a60f452e92cb5c3933 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 04:42:37 +0000 Subject: [PATCH 2/3] fix: guard CLI version writes and label backfill snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip planVersions bumps in upsertPlan when title/content are unchanged, still patching identity and path fields. Tag baseline history rows with source backfill so the drawer shows "Initial snapshot". Co-authored-by: Tyrus Malmström --- packages/ee/convex/cli.ts | 33 ++++++++++++++++++- packages/ee/convex/planVersioning.ts | 3 +- packages/ee/convex/schema.ts | 9 ++++- .../ee/src/components/PlanHistoryDrawer.tsx | 2 ++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/ee/convex/cli.ts b/packages/ee/convex/cli.ts index 1e33f14d..57dfb640 100644 --- a/packages/ee/convex/cli.ts +++ b/packages/ee/convex/cli.ts @@ -17,7 +17,7 @@ import { mergePlanMetadata, metadataWithPlanValueAssessment, } from './planVisibility'; -import { ensureBaselinePlanVersion, recordPlanVersion } from './planVersioning'; +import { ensureBaselinePlanVersion, planContentChanged, recordPlanVersion } from './planVersioning'; import { stripLocalIpFromMetadata } from './privacy'; const DAEMON_HEARTBEAT_RETENTION_MS = 7 * 86_400_000; @@ -360,6 +360,37 @@ export const upsertPlan = internalMutation({ if (args.existingId && args.existingVersion !== undefined) { const existing = await ctx.db.get(args.existingId); + const contentChanged = existing ? planContentChanged(existing, args) : true; + + // Non-content field updates (format/path/workspace/identity) still patch the + // live row, but must not create empty "CLI sync" history entries. + if (!contentChanged) { + await ctx.db.patch(args.existingId, { + agent: args.agent, + title: args.title, + content: args.content, + format: args.format, + filePath: args.filePath, + workspace: args.workspace, + metadata: args.metadata, + ...(continuityKey ? { plannotatorContinuityKey: continuityKey } : {}), + syncIdentityKey: args.syncIdentityKey, + contentHash: args.contentHash, + identityVersion: args.identityVersion, + identityStrength: args.identityStrength, + updatedAt, + }); + await markSupersededPlannotatorSessions(ctx, { + ownerId: args.ownerId, + canonicalPlanId: args.existingId, + canonicalLocalPlanId: args.localPlanId, + metadata: args.metadata, + filePath: args.filePath, + now, + }); + return args.existingId; + } + if (existing) { await ensureBaselinePlanVersion(ctx, { ownerId: args.ownerId, diff --git a/packages/ee/convex/planVersioning.ts b/packages/ee/convex/planVersioning.ts index 0c9011be..851b91f6 100644 --- a/packages/ee/convex/planVersioning.ts +++ b/packages/ee/convex/planVersioning.ts @@ -1,7 +1,7 @@ import type { Id } from './_generated/dataModel'; import type { MutationCtx } from './_generated/server'; -export type PlanVersionSource = 'cli_sync' | 'editor' | 'restore'; +export type PlanVersionSource = 'cli_sync' | 'editor' | 'restore' | 'backfill'; export type PlanVersionSnapshot = { title: string; @@ -75,6 +75,7 @@ export async function ensureBaselinePlanVersion( planId: args.planId, version: args.version, snapshot: args.snapshot, + source: 'backfill', createdAt: args.createdAt, }); return true; diff --git a/packages/ee/convex/schema.ts b/packages/ee/convex/schema.ts index 256b588f..ac3d2581 100644 --- a/packages/ee/convex/schema.ts +++ b/packages/ee/convex/schema.ts @@ -241,7 +241,14 @@ export default defineSchema({ filePath: v.optional(v.string()), workspace: v.optional(v.string()), metadata: v.optional(v.any()), - source: v.optional(v.union(v.literal('cli_sync'), v.literal('editor'), v.literal('restore'))), + source: v.optional( + v.union( + v.literal('cli_sync'), + v.literal('editor'), + v.literal('restore'), + v.literal('backfill'), + ), + ), createdAt: v.number(), }) .index('by_plan', ['planId']) diff --git a/packages/ee/src/components/PlanHistoryDrawer.tsx b/packages/ee/src/components/PlanHistoryDrawer.tsx index fa50d238..b5019401 100644 --- a/packages/ee/src/components/PlanHistoryDrawer.tsx +++ b/packages/ee/src/components/PlanHistoryDrawer.tsx @@ -14,6 +14,8 @@ function sourceLabel(source?: string): string { return 'Edited'; case 'restore': return 'Restored'; + case 'backfill': + return 'Initial snapshot'; default: return ''; } From a49830c6de30746d7dbfd26610b65983302dcbac Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 05:01:10 +0000 Subject: [PATCH 3/3] feat: render git-style hunks in plan version history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the flat Myers line list with numbered unified diffs, @@ hunk headers, expandable collapsed context, and a diff --git style header that includes title changes when present. Co-authored-by: Tyrus Malmström --- packages/ee/src/components/PlanDiffViewer.tsx | 299 +++++++++++------- .../ee/src/components/PlanHistoryDrawer.tsx | 9 +- packages/ee/src/lib/planDiff.test.ts | 47 +++ packages/ee/src/lib/planDiff.ts | 228 +++++++++++++ 4 files changed, 476 insertions(+), 107 deletions(-) create mode 100644 packages/ee/src/lib/planDiff.test.ts create mode 100644 packages/ee/src/lib/planDiff.ts diff --git a/packages/ee/src/components/PlanDiffViewer.tsx b/packages/ee/src/components/PlanDiffViewer.tsx index fc7ae0b6..e53c688a 100644 --- a/packages/ee/src/components/PlanDiffViewer.tsx +++ b/packages/ee/src/components/PlanDiffViewer.tsx @@ -1,137 +1,224 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; +import { + buildDiffSegments, + computeDiffLines, + formatHunkHeader, + hasDiffChanges, + type DiffLine, + type DiffSegment, +} from '../lib/planDiff.ts'; -type DiffLine = { - type: 'added' | 'removed' | 'unchanged'; - content: string; +const prefixes: Record = { + added: '+', + removed: '-', + unchanged: ' ', }; -// Myers' diff algorithm — O(n*d) time, O(n) space -function computeDiff(oldText: string, newText: string): DiffLine[] { - const oldLines = oldText.split('\n'); - const newLines = newText.split('\n'); - const n = oldLines.length; - const m = newLines.length; - const max = n + m; +function lineBackground(type: DiffLine['type']): string | undefined { + switch (type) { + case 'added': + return 'color-mix(in oklch, var(--success) 12%, transparent)'; + case 'removed': + return 'color-mix(in oklch, var(--danger) 12%, transparent)'; + default: + return undefined; + } +} - const v = new Int32Array(2 * max + 1); - const trace: Int32Array[] = []; +function lineColor(type: DiffLine['type']): string { + switch (type) { + case 'added': + return 'var(--success)'; + case 'removed': + return 'var(--danger)'; + default: + return 'var(--text)'; + } +} + +function DiffCodeLine({ line }: { line: DiffLine }) { + return ( +
+ + {line.oldLineNumber ?? ''} + + + {line.newLineNumber ?? ''} + + {prefixes[line.type]} + {line.content || '\u00A0'} +
+ ); +} - for (let d = 0; d <= max; d++) { - trace.push(v.slice()); - for (let k = -d; k <= d; k += 2) { - const idx = k + max; - let x: number; - if (k === -d || (k !== d && v[idx - 1]! < v[idx + 1]!)) { - x = v[idx + 1]!; - } else { - x = v[idx - 1]! + 1; - } - let y = x - k; - while (x < n && y < m && oldLines[x] === newLines[y]) { - x++; - y++; - } - v[idx] = x; - if (x >= n && y >= m) { - const result: DiffLine[] = []; - let cx = n; - let cy = m; - for (let bd = d; bd > 0; bd--) { - const prev = trace[bd - 1]!; - const bk = cx - cy; - const bIdx = bk + max; - let px: number; - if (bk === -bd || (bk !== bd && prev[bIdx - 1]! < prev[bIdx + 1]!)) { - px = prev[bIdx + 1]!; - } else { - px = prev[bIdx - 1]! + 1; - } - const py = px - bk; - while (cx > px && cy > py) { - cx--; - cy--; - result.push({ type: 'unchanged', content: oldLines[cx]! }); - } - if (cx > px) { - cx--; - result.push({ type: 'removed', content: oldLines[cx]! }); - } else if (cy > py) { - cy--; - result.push({ type: 'added', content: newLines[cy]! }); - } - } - while (cx > 0 && cy > 0) { - cx--; - cy--; - result.push({ type: 'unchanged', content: oldLines[cx]! }); - } - result.reverse(); - return result; - } - } +function CollapsedSegment({ + lines, + expanded, + onToggle, +}: { + lines: DiffLine[]; + expanded: boolean; + onToggle: () => void; +}) { + if (expanded) { + return ( +
+ + {lines.map((line, idx) => ( + + ))} +
+ ); } - return []; + return ( + + ); } -const lineStyles: Record = { - added: { - background: 'color-mix(in oklch, var(--success) 12%, transparent)', - color: 'var(--success)', - }, - removed: { - background: 'color-mix(in oklch, var(--danger) 12%, transparent)', - color: 'var(--danger)', - textDecoration: 'line-through', - textDecorationColor: 'color-mix(in oklch, var(--danger) 34%, transparent)', - }, - unchanged: { - color: 'var(--tertiary)', - }, -}; +function DiffSegmentView({ + segment, + index, + expanded, + onToggle, +}: { + segment: DiffSegment; + index: number; + expanded: boolean; + onToggle: () => void; +}) { + if (segment.kind === 'collapsed') { + return ; + } -const prefixes: Record = { - added: '+', - removed: '-', - unchanged: ' ', -}; + return ( +
+
+ {formatHunkHeader(segment.hunk)} +
+ {segment.hunk.lines.map((line, idx) => ( + + ))} +
+ ); +} export function PlanDiffViewer({ oldContent, newContent, + oldLabel = 'a/plan', + newLabel = 'b/plan', + oldTitle, + newTitle, }: { oldContent: string; newContent: string; + oldLabel?: string; + newLabel?: string; + oldTitle?: string; + newTitle?: string; }) { - const lines = useMemo(() => computeDiff(oldContent, newContent), [oldContent, newContent]); + const lines = useMemo(() => computeDiffLines(oldContent, newContent), [oldContent, newContent]); + const segments = useMemo(() => buildDiffSegments(lines), [lines]); + const [expandedCollapsed, setExpandedCollapsed] = useState>({}); - const hasChanges = lines.some((l) => l.type !== 'unchanged'); + const titleChanged = oldTitle != null && newTitle != null && oldTitle !== newTitle; + const changed = hasDiffChanges(lines) || titleChanged; - if (!hasChanges) { + if (!changed) { return ( -
+
No differences between these versions.
); } return ( -
- {lines.map((line, idx) => ( -
- - {prefixes[line.type]} - - {line.content || '\u00A0'} +
+
+
+ diff --git {oldLabel} {newLabel}
- ))} +
+ --- {oldLabel} + + +++ {newLabel} +
+
+ + {titleChanged && ( +
+
+ title +
+ + +
+ )} + + {hasDiffChanges(lines) ? ( + segments.map((segment, index) => ( + + setExpandedCollapsed((prev) => ({ + ...prev, + [index]: !prev[index], + })) + } + /> + )) + ) : ( +
+ Plan body unchanged (title differs). +
+ )}
); } diff --git a/packages/ee/src/components/PlanHistoryDrawer.tsx b/packages/ee/src/components/PlanHistoryDrawer.tsx index b5019401..ea8446b8 100644 --- a/packages/ee/src/components/PlanHistoryDrawer.tsx +++ b/packages/ee/src/components/PlanHistoryDrawer.tsx @@ -210,7 +210,14 @@ export function PlanHistoryDrawer({ planId, onClose }: { planId: string; onClose fromSnapshot === undefined || toSnapshot === undefined ? ( ) : ( - + ) ) : compareTo != null && versions.length === 1 ? ( toSnapshot === undefined ? ( diff --git a/packages/ee/src/lib/planDiff.test.ts b/packages/ee/src/lib/planDiff.test.ts new file mode 100644 index 00000000..029c6b04 --- /dev/null +++ b/packages/ee/src/lib/planDiff.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from 'bun:test'; +import { buildDiffSegments, computeDiffLines, formatHunkHeader, hasDiffChanges } from './planDiff'; + +test('computeDiffLines marks additions, removals, and unchanged lines with numbers', () => { + const lines = computeDiffLines('a\nb\nc\n', 'a\nx\nc\n'); + expect(lines).toEqual([ + { type: 'unchanged', content: 'a', oldLineNumber: 1, newLineNumber: 1 }, + { type: 'removed', content: 'b', oldLineNumber: 2, newLineNumber: null }, + { type: 'added', content: 'x', oldLineNumber: null, newLineNumber: 2 }, + { type: 'unchanged', content: 'c', oldLineNumber: 3, newLineNumber: 3 }, + { type: 'unchanged', content: '', oldLineNumber: 4, newLineNumber: 4 }, + ]); + expect(hasDiffChanges(lines)).toBe(true); +}); + +test('computeDiffLines returns only unchanged lines when texts match', () => { + const lines = computeDiffLines('hello\n', 'hello\n'); + expect(hasDiffChanges(lines)).toBe(false); + expect(lines).toEqual([ + { type: 'unchanged', content: 'hello', oldLineNumber: 1, newLineNumber: 1 }, + { type: 'unchanged', content: '', oldLineNumber: 2, newLineNumber: 2 }, + ]); +}); + +test('buildDiffSegments creates git-style hunks with collapsed context', () => { + const oldText = Array.from({ length: 20 }, (_, i) => `line-${i + 1}`).join('\n'); + const newLines = Array.from({ length: 20 }, (_, i) => `line-${i + 1}`); + newLines[9] = 'changed-10'; + const lines = computeDiffLines(oldText, newLines.join('\n')); + const segments = buildDiffSegments(lines, 2); + + expect(segments[0]?.kind).toBe('collapsed'); + expect(segments.some((s) => s.kind === 'hunk')).toBe(true); + + const hunk = segments.find((s) => s.kind === 'hunk'); + if (!hunk || hunk.kind !== 'hunk') throw new Error('expected hunk'); + expect(formatHunkHeader(hunk.hunk)).toBe('@@ -8,5 +8,5 @@'); + expect(hunk.hunk.lines.some((l) => l.type === 'removed' && l.content === 'line-10')).toBe(true); + expect(hunk.hunk.lines.some((l) => l.type === 'added' && l.content === 'changed-10')).toBe(true); +}); + +test('buildDiffSegments collapses identical files into one segment', () => { + const lines = computeDiffLines('a\nb\n', 'a\nb\n'); + const segments = buildDiffSegments(lines); + expect(segments).toHaveLength(1); + expect(segments[0]?.kind).toBe('collapsed'); +}); diff --git a/packages/ee/src/lib/planDiff.ts b/packages/ee/src/lib/planDiff.ts new file mode 100644 index 00000000..949ed85a --- /dev/null +++ b/packages/ee/src/lib/planDiff.ts @@ -0,0 +1,228 @@ +export type DiffLineType = 'added' | 'removed' | 'unchanged'; + +export type DiffLine = { + type: DiffLineType; + content: string; + /** 1-based line number in the old text, or null for pure additions. */ + oldLineNumber: number | null; + /** 1-based line number in the new text, or null for pure deletions. */ + newLineNumber: number | null; +}; + +export type DiffHunk = { + oldStart: number; + oldCount: number; + newStart: number; + newCount: number; + lines: DiffLine[]; +}; + +export type DiffSegment = + | { kind: 'hunk'; hunk: DiffHunk } + | { kind: 'collapsed'; lines: DiffLine[] }; + +const DEFAULT_CONTEXT = 3; + +/** + * Myers' shortest-edit diff (see jcoglan's walkthrough). + * Returns a unified line list with 1-based old/new line numbers. + */ +export function computeDiffLines(oldText: string, newText: string): DiffLine[] { + const a = splitLines(oldText); + const b = splitLines(newText); + const n = a.length; + const m = b.length; + const max = n + m; + + if (max === 0) return []; + + // v[k] stores the furthest x reached on diagonal k. Offset by `max` so k can be negative. + const offset = max; + const v = new Int32Array(2 * max + 1); + v[1 + offset] = 0; + const trace: Int32Array[] = []; + + for (let d = 0; d <= max; d++) { + trace.push(v.slice()); + for (let k = -d; k <= d; k += 2) { + const idx = k + offset; + let x: number; + if (k === -d || (k !== d && v[idx - 1]! < v[idx + 1]!)) { + x = v[idx + 1]!; // vertical move (insert) + } else { + x = v[idx - 1]! + 1; // horizontal move (delete) + } + let y = x - k; + while (x < n && y < m && a[x] === b[y]) { + x++; + y++; + } + v[idx] = x; + if (x >= n && y >= m) { + return backtrack(a, b, trace); + } + } + } + + return []; +} + +function backtrack(a: string[], b: string[], trace: Int32Array[]): DiffLine[] { + const result: DiffLine[] = []; + let x = a.length; + let y = b.length; + const offset = a.length + b.length; + + for (let d = trace.length - 1; d >= 0; d--) { + const v = trace[d]!; + const k = x - y; + + let prevK: number; + if (k === -d || (k !== d && v[k - 1 + offset]! < v[k + 1 + offset]!)) { + prevK = k + 1; + } else { + prevK = k - 1; + } + + const prevX = v[prevK + offset]!; + const prevY = prevX - prevK; + + while (x > prevX && y > prevY) { + x--; + y--; + result.push({ + type: 'unchanged', + content: a[x]!, + oldLineNumber: x + 1, + newLineNumber: y + 1, + }); + } + + if (d === 0) break; + + if (x === prevX) { + // vertical — insertion from b + y--; + result.push({ + type: 'added', + content: b[y]!, + oldLineNumber: null, + newLineNumber: y + 1, + }); + } else { + // horizontal — deletion from a + x--; + result.push({ + type: 'removed', + content: a[x]!, + oldLineNumber: x + 1, + newLineNumber: null, + }); + } + + x = prevX; + y = prevY; + } + + result.reverse(); + return result; +} + +/** + * Group a full unified diff into git-style hunks with surrounding context. + * Large unchanged stretches between hunks become expandable collapsed segments. + */ +export function buildDiffSegments( + lines: DiffLine[], + context: number = DEFAULT_CONTEXT, +): DiffSegment[] { + if (lines.length === 0) return []; + + const changeIndices: number[] = []; + for (let i = 0; i < lines.length; i++) { + if (lines[i]!.type !== 'unchanged') changeIndices.push(i); + } + + if (changeIndices.length === 0) { + return [{ kind: 'collapsed', lines }]; + } + + const ranges: Array<{ start: number; end: number }> = []; + let rangeStart = Math.max(0, changeIndices[0]! - context); + let rangeEnd = Math.min(lines.length, changeIndices[0]! + 1 + context); + + for (let i = 1; i < changeIndices.length; i++) { + const idx = changeIndices[i]!; + const nextStart = Math.max(0, idx - context); + const nextEnd = Math.min(lines.length, idx + 1 + context); + if (nextStart <= rangeEnd) { + rangeEnd = Math.max(rangeEnd, nextEnd); + } else { + ranges.push({ start: rangeStart, end: rangeEnd }); + rangeStart = nextStart; + rangeEnd = nextEnd; + } + } + ranges.push({ start: rangeStart, end: rangeEnd }); + + const segments: DiffSegment[] = []; + let cursor = 0; + + for (const range of ranges) { + if (range.start > cursor) { + segments.push({ kind: 'collapsed', lines: lines.slice(cursor, range.start) }); + } + const hunkLines = lines.slice(range.start, range.end); + segments.push({ kind: 'hunk', hunk: toHunk(hunkLines) }); + cursor = range.end; + } + + if (cursor < lines.length) { + segments.push({ kind: 'collapsed', lines: lines.slice(cursor) }); + } + + return segments; +} + +export function formatHunkHeader(hunk: DiffHunk): string { + return `@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`; +} + +export function hasDiffChanges(lines: DiffLine[]): boolean { + return lines.some((line) => line.type !== 'unchanged'); +} + +function toHunk(lines: DiffLine[]): DiffHunk { + let oldCount = 0; + let newCount = 0; + let oldStart = 0; + let newStart = 0; + + for (const line of lines) { + if (line.type === 'removed' || line.type === 'unchanged') { + oldCount++; + if (oldStart === 0 && line.oldLineNumber != null) oldStart = line.oldLineNumber; + } + if (line.type === 'added' || line.type === 'unchanged') { + newCount++; + if (newStart === 0 && line.newLineNumber != null) newStart = line.newLineNumber; + } + } + + // Pure insertions/deletions: git uses 0 when that side contributes no lines. + if (oldCount === 0) { + const firstNew = lines.find((l) => l.newLineNumber != null)?.newLineNumber ?? 1; + oldStart = Math.max(0, firstNew - 1); + } + if (newCount === 0) { + const firstOld = lines.find((l) => l.oldLineNumber != null)?.oldLineNumber ?? 1; + newStart = Math.max(0, firstOld - 1); + } + + return { oldStart, oldCount, newStart, newCount, lines }; +} + +function splitLines(text: string): string[] { + if (text.length === 0) return []; + return text.split('\n'); +}