feat(review): add semantic diff overview (#871)

* feat(review): add semantic diff overview

* fix(review): address semantic diff review findings

* fix(review): harden semantic diff fallback

* fix(review): avoid repo-local sem execution

* fix(review): normalize semantic diff cwd

* test(review): cover semantic diff local cwd

* fix(gitlab): fetch raw MR diffs

* fix(review): harden sem path resolution

* docs(review): add semantic diff handoff

* feat(review): semantic diff cards, header sem badges, jump-to-line

- Restyle the semantic panel as real bordered cards (shadcn surface) instead
  of ASCII box-drawing; centered column, grid-aligned entity rows.
- Hide orphan (module-level) changes from the rows, matching sem's default;
  the count still surfaces in the summary line.
- Add a 'sem · N' hover popover to each diff file header showing that file's
  semantic changes, reusing the same row component as the panel.
- Clicking an entity (panel or popover) scrolls the diff to the lines via
  pierre's [data-selected-line]; centers only when off-screen so manual
  drag-selection isn't disturbed.
- Extract shared rows/helpers into semanticDiffShared; share one cached
  /api/semantic-diff fetch across header badges.

* feat(review): land on All files by default

Semantic diff stays available via the file-tree nav entry and header badges,
but it's no longer the initial landing view.

* chore(review): drop dead change-symbol entries, log badge fetch failures

- Remove unreachable 'moved'/'renamed' entries from the changeSymbols table
  (getChangeSymbol early-returns for those before the lookup).
- Log a console.error once per patch when the file-header badge's semantic
  diff fetch fails or returns a non-ok status, so a systemic failure leaves a
  trace instead of every badge silently showing nothing.

* feat(review): flatten semantic diff panel into grouped list

- Dissolve the per-file bordered cards into flat sections: the file path is now
  a quiet underlined header (single hairline) with entity rows flush beneath,
  hierarchy from whitespace + hover tint instead of boxes.
- Split the path into muted directory + emphasized filename for faster scanning.
- Nudge the add/remove glyphs (⊕/⊖) ~15% larger, line-height pinned so rows
  don't grow; modified/rename/reorder glyphs unchanged.
This commit is contained in:
Michael Ramos
2026-06-08 22:23:54 -07:00
committed by GitHub
parent c23df4db43
commit c3ba55e889
30 changed files with 3214 additions and 169 deletions
+83
View File
@@ -14,6 +14,16 @@ import type { Origin } from "@plannotator/shared/agents";
import { type DiffType, type GitContext, runVcsDiff, getVcsFileContentsForDiff, canStageFiles, stageFile, unstageFile, resolveVcsCwd, validateFilePath, getVcsContext, detectRemoteDefaultCompareTarget, gitRuntime } from "./vcs";
import { basename } from "node:path";
import { parseWorktreeDiffType, resolveBaseBranch } from "@plannotator/shared/review-core";
import {
createDefaultSemanticDiffRuntime,
getSemanticDiffAvailability,
getSemanticDiffScratchCwd,
runSemanticDiff,
semanticDiffCacheKey,
semanticDiffFileExtsFromSearchParams,
SemanticDiffResponseCache,
} from "@plannotator/shared/semantic-diff";
import type { SemanticDiffAvailability, SemanticDiffResponse } from "@plannotator/shared/semantic-diff-types";
import {
getPRDiffScopeOptions,
getPRStackInfo,
@@ -205,6 +215,68 @@ export async function startReviewServer(
if (!workspace) return undefined;
return workspace.getPromptContext();
};
const semanticDiffScratchCwd = getSemanticDiffScratchCwd();
const resolveSemanticDiffCwd = (): string => {
if (workspace) return workspace.root;
if (options.worktreePool && prMetadata) {
const poolPath = options.worktreePool.resolve(prMetadata.url);
if (poolPath) return poolPath;
}
if (options.agentCwd) return options.agentCwd;
if (gitContext) {
const vcsCwd = resolveVcsCwd(currentDiffType as DiffType, gitContext.cwd);
if (vcsCwd) return vcsCwd;
if (gitContext.cwd) return gitContext.cwd;
}
return semanticDiffScratchCwd;
};
const semanticDiffCache = new SemanticDiffResponseCache();
const semanticDiffAvailabilityCache = new Map<string, Promise<SemanticDiffAvailability>>();
const createSemanticDiffRuntime = (cwd: string) => ({
...createDefaultSemanticDiffRuntime(),
cwd,
});
const getSemanticDiffAvailabilityForCwd = (cwd: string): Promise<SemanticDiffAvailability> => {
const cached = semanticDiffAvailabilityCache.get(cwd);
if (cached) return cached;
const next: Promise<SemanticDiffAvailability> = getSemanticDiffAvailability(createSemanticDiffRuntime(cwd)).catch((error) => ({
available: false,
reason: "sem-probe-failed",
message: error instanceof Error ? error.message : String(error),
}));
semanticDiffAvailabilityCache.set(cwd, next);
return next;
};
const getSemanticDiffAdvert = async () => {
const availability = await getSemanticDiffAvailabilityForCwd(resolveSemanticDiffCwd());
return {
available: availability.available,
...(availability.semVersion && { semVersion: availability.semVersion }),
...(availability.semSource && { semSource: availability.semSource }),
};
};
const getSemanticDiff = async (url: URL): Promise<SemanticDiffResponse> => {
const cwd = resolveSemanticDiffCwd();
const fileExts = semanticDiffFileExtsFromSearchParams(url.searchParams);
const cacheKey = semanticDiffCacheKey({ rawPatch: currentPatch, cwd, fileExts });
const cached = semanticDiffCache.get(cacheKey, currentPatch);
if (cached) return cached;
const result = await runSemanticDiff(
{ rawPatch: currentPatch, cwd, fileExts },
createSemanticDiffRuntime(cwd),
);
if (result.status === "ok") {
semanticDiffCache.set(cacheKey, currentPatch, result);
}
return result;
};
const agentJobs = createAgentJobHandler({
mode: "review",
getServerUrl: () => serverUrl,
@@ -511,10 +583,16 @@ export async function startReviewServer(
}),
...(isPRMode && initialViewedFiles.length > 0 && { viewedFiles: initialViewedFiles }),
...(currentError && { error: currentError }),
semanticDiff: await getSemanticDiffAdvert(),
serverConfig: getServerConfig(gitUser),
});
}
// API: Get semantic diff content
if (url.pathname === "/api/semantic-diff" && req.method === "GET") {
return Response.json(await getSemanticDiff(url));
}
// API: Switch diff type (requires local file access)
if (url.pathname === "/api/diff/switch" && req.method === "POST") {
if (!hasLocalAccess && !workspace) {
@@ -556,6 +634,7 @@ export async function startReviewServer(
diffOptions: workspace.diffOptions,
hideWhitespace: currentHideWhitespace,
...(currentError && { error: currentError }),
semanticDiff: await getSemanticDiffAdvert(),
});
}
@@ -606,6 +685,7 @@ export async function startReviewServer(
hideWhitespace: currentHideWhitespace,
...(updatedContext && { gitContext: updatedContext }),
...(currentError && { error: currentError }),
semanticDiff: await getSemanticDiffAdvert(),
});
} catch (err) {
const message =
@@ -636,6 +716,7 @@ export async function startReviewServer(
gitRef: currentGitRef,
prDiffScope: currentPRDiffScope,
...(currentError && { error: currentError }),
semanticDiff: await getSemanticDiffAdvert(),
});
}
@@ -663,6 +744,7 @@ export async function startReviewServer(
rawPatch: currentPatch,
gitRef: currentGitRef,
prDiffScope: currentPRDiffScope,
semanticDiff: await getSemanticDiffAdvert(),
});
} catch (err) {
const message =
@@ -789,6 +871,7 @@ export async function startReviewServer(
repoInfo,
...(switchedViewedFiles.length > 0 && { viewedFiles: switchedViewedFiles }),
...(currentError ? { error: currentError } : {}),
semanticDiff: await getSemanticDiffAdvert(),
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to switch PR";