diff --git a/AGENTS.md b/AGENTS.md
index 8d535c03..98e887a9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -285,8 +285,9 @@ During normal plan review, an Archive sidebar tab provides the same browsing via
| Endpoint | Method | Purpose |
| --------------------- | ------ | ------------------------------------------ |
-| `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin, mode?, diffType, base, hideWhitespace, gitContext, agentCwd? }`. Workspace mode returns `mode: "workspace"` with folder-prefixed paths and no `gitContext`. |
-| `/api/diff/switch` | POST | Switch diff type, base branch, or whitespace mode (body: `{ diffType, base?, hideWhitespace? }`) |
+| `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin, mode?, diffType, base, hideWhitespace, gitContext, agentCwd?, semanticDiff? }`. Workspace mode returns `mode: "workspace"` with folder-prefixed paths and no `gitContext`. |
+| `/api/diff/switch` | POST | Switch diff type, base branch, or whitespace mode (body: `{ diffType, base?, hideWhitespace? }`). Response includes `semanticDiff?`. |
+| `/api/semantic-diff` | GET | Runs semantic diff for the active patch and returns parsed sem output or an unavailable/error response (`?fileExt=` / `?fileExts=` optional). |
| `/api/file-content` | GET | Returns `{ oldContent, newContent }` for expandable diff context (`?path=&oldPath=&base=`) |
| `/api/git-add` | POST | Stage/unstage a file (body: `{ filePath, undo? }`) |
| `/api/feedback` | POST | Submit review (body: feedback, annotations, agentSwitch) |
@@ -312,9 +313,9 @@ During normal plan review, an Archive sidebar tab provides the same browsing via
| `/api/agents/jobs` | POST | Launch an agent job (body: `{ provider, command, label }`) |
| `/api/agents/jobs` | DELETE | Kill all running agent jobs |
| `/api/agents/jobs/:id` | DELETE | Kill a specific agent job |
-| `/api/pr-diff-scope` | POST | Switch between layer and full-stack diff scope |
+| `/api/pr-diff-scope` | POST | Switch between layer and full-stack diff scope. Response includes `semanticDiff?`. |
| `/api/pr-list` | GET | List PRs for the current repo (cached 30s) |
-| `/api/pr-switch` | POST | Switch to a different PR in-place (body: `{ url }`) |
+| `/api/pr-switch` | POST | Switch to a different PR in-place (body: `{ url }`). Response includes `semanticDiff?`. |
| `/api/tour/:jobId` | GET | Fetch Code Tour result (greeting, stops, checklist) for a completed tour job |
| `/api/tour/:jobId/checklist` | PUT | Persist checklist item state for a Code Tour |
| `/api/code-nav/resolve` | POST | Search for symbol definitions and references via ripgrep (body: `{ symbol, filePath, line, charStart, side, language? }`) |
diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts
index be371fb5..5fede8a7 100644
--- a/apps/pi-extension/server.test.ts
+++ b/apps/pi-extension/server.test.ts
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { createServer as createNetServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -23,6 +23,8 @@ const originalCwd = process.cwd();
const originalHome = process.env.HOME;
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
const originalPort = process.env.PLANNOTATOR_PORT;
+const originalSemPath = process.env.PLANNOTATOR_SEM_PATH;
+const originalDataDir = process.env.PLANNOTATOR_DATA_DIR;
function makeTempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
@@ -68,6 +70,47 @@ function initRepo(): string {
return repoDir;
}
+function makeMockSem(dir: string, options: {
+ runCwdLogPath?: string;
+ inputLogPath?: string;
+} = {}): string {
+ const semPath = join(dir, "sem");
+ writeFileSync(
+ semPath,
+ [
+ "#!/usr/bin/env bash",
+ "set -euo pipefail",
+ 'if [ "${1:-}" = "--version" ]; then',
+ ' echo "sem 0.8.0"',
+ " exit 0",
+ "fi",
+ ...(options.runCwdLogPath ? [`pwd >> ${JSON.stringify(options.runCwdLogPath)}`] : []),
+ ...(options.inputLogPath ? [`cat > ${JSON.stringify(options.inputLogPath)}`] : ["cat >/dev/null"]),
+ "cat <<'JSON'",
+ JSON.stringify({
+ summary: { fileCount: 1, added: 1, modified: 0, deleted: 0, moved: 0, renamed: 0, reordered: 0, binary: 0, orphan: 0, total: 1 },
+ changes: [
+ {
+ entityId: "src/app.ts::function::created",
+ changeType: "added",
+ entityType: "function",
+ entityName: "created",
+ filePath: "src/app.ts",
+ startLine: 1,
+ endLine: 3,
+ },
+ ],
+ binaryChanges: [],
+ }),
+ "JSON",
+ "",
+ ].join("\n"),
+ "utf-8",
+ );
+ chmodSync(semPath, 0o755);
+ return semPath;
+}
+
function initJjRepo(): string {
const repoDir = initRepo();
writeFileSync(join(repoDir, "spacey.ts"), "const x = 1;\n", "utf-8");
@@ -131,6 +174,16 @@ afterEach(() => {
} else {
process.env.PLANNOTATOR_PORT = originalPort;
}
+ if (originalSemPath === undefined) {
+ delete process.env.PLANNOTATOR_SEM_PATH;
+ } else {
+ process.env.PLANNOTATOR_SEM_PATH = originalSemPath;
+ }
+ if (originalDataDir === undefined) {
+ delete process.env.PLANNOTATOR_DATA_DIR;
+ } else {
+ process.env.PLANNOTATOR_DATA_DIR = originalDataDir;
+ }
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
@@ -139,6 +192,148 @@ afterEach(() => {
describe("pi review server", () => {
const testIfJj = hasJj() ? test : test.skip;
+ const semanticRawPatch = [
+ "diff --git a/src/app.ts b/src/app.ts",
+ "new file mode 100644",
+ "index 0000000..1111111",
+ "--- /dev/null",
+ "+++ b/src/app.ts",
+ "@@ -0,0 +1,3 @@",
+ "+export function created() {",
+ "+ return true;",
+ "+}",
+ "",
+ ].join("\n");
+
+ test("advertises semantic diff availability and serves parsed sem output", async () => {
+ const dir = makeTempDir("plannotator-pi-sem-server-");
+ const dataDir = makeTempDir("plannotator-pi-sem-data-");
+ const cwdLogPath = join(dir, "cwd-log");
+ process.env.PLANNOTATOR_DATA_DIR = dataDir;
+ process.env.PLANNOTATOR_SEM_PATH = makeMockSem(dir, { runCwdLogPath: cwdLogPath });
+ process.env.PLANNOTATOR_PORT = String(await reservePort());
+
+ const server = await startReviewServer({
+ rawPatch: semanticRawPatch,
+ gitRef: "test",
+ origin: "pi",
+ htmlContent: "
review",
+ });
+
+ try {
+ const diffPayload = await fetch(`${server.url}/api/diff`).then((response) => response.json()) as {
+ semanticDiff?: { available: boolean; semVersion?: string; semSource?: string };
+ };
+ expect(diffPayload.semanticDiff).toMatchObject({
+ available: true,
+ semVersion: "0.8.0",
+ semSource: "env",
+ });
+
+ const semanticPayload = await fetch(`${server.url}/api/semantic-diff?fileExt=.ts`).then((response) => response.json()) as {
+ status: string;
+ summary?: { added: number; fileCount: number };
+ changes?: Array<{ entityType: string; entityName: string; filePath: string }>;
+ };
+ expect(semanticPayload).toMatchObject({
+ status: "ok",
+ summary: { added: 1, fileCount: 1 },
+ changes: [
+ { entityType: "function", entityName: "created", filePath: "src/app.ts" },
+ ],
+ });
+ expect(realpathSync(readFileSync(cwdLogPath, "utf-8").trim())).toBe(
+ realpathSync(join(dataDir, "semantic-diff", "patch-only")),
+ );
+ } finally {
+ server.stop();
+ }
+ });
+
+ test("runs semantic diff from the local agent cwd when one is available", async () => {
+ const dir = makeTempDir("plannotator-pi-sem-agent-");
+ const agentCwd = makeTempDir("plannotator-pi-sem-agent-cwd-");
+ const cwdLogPath = join(dir, "cwd-log");
+ process.env.PLANNOTATOR_SEM_PATH = makeMockSem(dir, { runCwdLogPath: cwdLogPath });
+ process.env.PLANNOTATOR_PORT = String(await reservePort());
+
+ const server = await startReviewServer({
+ rawPatch: semanticRawPatch,
+ gitRef: "test",
+ origin: "pi",
+ agentCwd,
+ htmlContent: "review",
+ });
+
+ try {
+ const semanticPayload = await fetch(`${server.url}/api/semantic-diff`).then((response) => response.json()) as {
+ status: string;
+ };
+ expect(semanticPayload.status).toBe("ok");
+ expect(realpathSync(readFileSync(cwdLogPath, "utf-8").trim())).toBe(realpathSync(agentCwd));
+ } finally {
+ server.stop();
+ }
+ });
+
+ test("runs semantic diff from the local git context cwd in local review mode", async () => {
+ const dir = makeTempDir("plannotator-pi-sem-local-");
+ const repoDir = initRepo();
+ const cwdLogPath = join(dir, "cwd-log");
+ const gitContext = await getVcsContext(repoDir);
+ process.env.PLANNOTATOR_SEM_PATH = makeMockSem(dir, { runCwdLogPath: cwdLogPath });
+ process.env.PLANNOTATOR_PORT = String(await reservePort());
+
+ const server = await startReviewServer({
+ rawPatch: semanticRawPatch,
+ gitRef: "test",
+ origin: "pi",
+ diffType: "unstaged",
+ gitContext,
+ htmlContent: "review",
+ });
+
+ try {
+ const semanticPayload = await fetch(`${server.url}/api/semantic-diff`).then((response) => response.json()) as {
+ status: string;
+ };
+ expect(semanticPayload.status).toBe("ok");
+ expect(realpathSync(readFileSync(cwdLogPath, "utf-8").trim())).toBe(realpathSync(repoDir));
+ } finally {
+ server.stop();
+ }
+ });
+
+ test("hides semantic diff from /api/diff when sem cannot be resolved", async () => {
+ const dir = makeTempDir("plannotator-pi-sem-missing-server-");
+ process.env.PLANNOTATOR_SEM_PATH = join(dir, "missing-sem");
+ process.env.PLANNOTATOR_PORT = String(await reservePort());
+
+ const server = await startReviewServer({
+ rawPatch: semanticRawPatch,
+ gitRef: "test",
+ origin: "pi",
+ htmlContent: "review",
+ });
+
+ try {
+ const diffPayload = await fetch(`${server.url}/api/diff`).then((response) => response.json()) as {
+ semanticDiff?: { available: boolean };
+ };
+ expect(diffPayload.semanticDiff).toEqual({ available: false });
+
+ const semanticPayload = await fetch(`${server.url}/api/semantic-diff`).then((response) => response.json()) as {
+ status: string;
+ reason?: string;
+ };
+ expect(semanticPayload).toMatchObject({
+ status: "unavailable",
+ reason: "sem-path-missing",
+ });
+ } finally {
+ server.stop();
+ }
+ });
test("serves review diff parity endpoints including drafts, uploads, and editor annotations", async () => {
const homeDir = makeTempDir("plannotator-pi-home-");
@@ -368,9 +563,13 @@ describe("pi review server", () => {
const homeDir = makeTempDir("plannotator-pi-home-");
const root = makeTempDir("plannotator-pi-workspace-");
const apiDir = join(root, "api");
+ const semDir = makeTempDir("plannotator-pi-workspace-switch-sem-");
+ const cwdLogPath = join(semDir, "cwd-log");
+ const inputLogPath = join(semDir, "input.patch");
mkdirSync(apiDir, { recursive: true });
process.env.HOME = homeDir;
process.env.PLANNOTATOR_PORT = String(await reservePort());
+ process.env.PLANNOTATOR_SEM_PATH = makeMockSem(semDir, { runCwdLogPath: cwdLogPath, inputLogPath });
git(apiDir, ["init"]);
git(apiDir, ["branch", "-M", "main"]);
@@ -408,22 +607,36 @@ describe("pi review server", () => {
agentCwd?: string;
diffType?: string;
diffOptions?: Array<{ id: string }>;
+ semanticDiff?: { available: boolean };
};
expect(diffPayload.mode).toBe("workspace");
expect(diffPayload.diffType).toBe("workspace-current");
expect(diffPayload.diffOptions?.map((option) => option.id)).toContain("workspace-last");
expect(diffPayload.agentCwd).toBe(root);
+ expect(diffPayload.semanticDiff).toEqual(expect.objectContaining({ available: true }));
expect("workspace" in diffPayload).toBe(false);
+ const semanticPayload = await fetch(`${server.url}/api/semantic-diff`).then((response) => response.json()) as {
+ status: string;
+ };
+ expect(semanticPayload.status).toBe("ok");
+ expect(realpathSync(readFileSync(cwdLogPath, "utf-8").trim())).toBe(realpathSync(root));
+ expect(readFileSync(inputLogPath, "utf-8")).toContain("diff --git a/api/tracked.txt b/api/tracked.txt");
+
const switchResponse = await fetch(`${server.url}/api/diff/switch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ diffType: "workspace-last", hideWhitespace: true }),
});
expect(switchResponse.status).toBe(200);
- const switched = await switchResponse.json() as { diffType?: string; diffOptions?: Array<{ id: string }> };
+ const switched = await switchResponse.json() as {
+ diffType?: string;
+ diffOptions?: Array<{ id: string }>;
+ semanticDiff?: { available: boolean };
+ };
expect(switched.diffType).toBe("workspace-last");
expect(switched.diffOptions?.map((option) => option.id)).toContain("workspace-current");
+ expect(switched.semanticDiff).toEqual(expect.objectContaining({ available: true }));
const currentResponse = await fetch(`${server.url}/api/diff/switch`, {
method: "POST",
diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts
index 2efd4783..c4f1e1b4 100644
--- a/apps/pi-extension/server/serverReview.ts
+++ b/apps/pi-extension/server/serverReview.ts
@@ -95,6 +95,16 @@ import {
validateCodeNavRequest,
extractChangedFiles,
} from "../generated/code-nav.js";
+import {
+ createDefaultSemanticDiffRuntime,
+ getSemanticDiffAvailability,
+ getSemanticDiffScratchCwd,
+ runSemanticDiff,
+ semanticDiffCacheKey,
+ semanticDiffFileExtsFromSearchParams,
+ SemanticDiffResponseCache,
+} from "../generated/semantic-diff.js";
+import type { SemanticDiffAvailability, SemanticDiffResponse } from "../generated/semantic-diff-types.js";
import {
canStageFiles,
detectRemoteDefaultCompareTarget,
@@ -301,6 +311,69 @@ export async function startReviewServer(options: {
return workspace.getPromptContext();
}
const tour = createTourSession();
+ const semanticDiffScratchCwd = getSemanticDiffScratchCwd();
+ function resolveSemanticDiffCwd(): string {
+ if (workspace) return workspace.root;
+ if (options.worktreePool && prMeta) {
+ const poolPath = options.worktreePool.resolve(prMeta.url);
+ if (poolPath) return poolPath;
+ }
+ if (options.agentCwd) return options.agentCwd;
+ if (options.gitContext) {
+ const vcsCwd = resolveVcsCwd(currentDiffType as DiffType, options.gitContext.cwd);
+ if (vcsCwd) return vcsCwd;
+ if (options.gitContext.cwd) return options.gitContext.cwd;
+ }
+ return semanticDiffScratchCwd;
+ }
+ const semanticDiffCache = new SemanticDiffResponseCache();
+ const semanticDiffAvailabilityCache = new Map>();
+
+ function createSemanticDiffRuntime(cwd: string) {
+ return {
+ ...createDefaultSemanticDiffRuntime(),
+ cwd,
+ };
+ }
+
+ function getSemanticDiffAvailabilityForCwd(cwd: string): Promise {
+ const cached = semanticDiffAvailabilityCache.get(cwd);
+ if (cached) return cached;
+
+ const next: Promise = 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;
+ }
+
+ async function getSemanticDiffAdvert() {
+ const availability = await getSemanticDiffAvailabilityForCwd(resolveSemanticDiffCwd());
+ return {
+ available: availability.available,
+ ...(availability.semVersion ? { semVersion: availability.semVersion } : {}),
+ ...(availability.semSource ? { semSource: availability.semSource } : {}),
+ };
+ }
+
+ async function getSemanticDiff(url: URL): Promise {
+ 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",
@@ -543,8 +616,11 @@ export async function startReviewServer(options: {
}),
...(isPRMode && initialViewedFiles.length > 0 && { viewedFiles: initialViewedFiles }),
...(currentError && { error: currentError }),
+ semanticDiff: await getSemanticDiffAdvert(),
serverConfig: getServerConfig(gitUser),
});
+ } else if (url.pathname === "/api/semantic-diff" && req.method === "GET") {
+ json(res, await getSemanticDiff(url));
} else if (url.pathname === "/api/diff/switch" && req.method === "POST") {
if (!hasLocalAccess && !workspace) {
json(res, { error: "Not available without local file access" }, 400);
@@ -578,6 +654,7 @@ export async function startReviewServer(options: {
diffOptions: workspace.diffOptions,
hideWhitespace: currentHideWhitespace,
...(currentError ? { error: currentError } : {}),
+ semanticDiff: await getSemanticDiffAdvert(),
});
return;
}
@@ -622,6 +699,7 @@ export async function startReviewServer(options: {
hideWhitespace: currentHideWhitespace,
...(updatedContext ? { gitContext: updatedContext } : {}),
...(currentError ? { error: currentError } : {}),
+ semanticDiff: await getSemanticDiffAdvert(),
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to switch diff";
@@ -649,6 +727,7 @@ export async function startReviewServer(options: {
gitRef: currentGitRef,
prDiffScope: currentPRDiffScope,
...(currentError ? { error: currentError } : {}),
+ semanticDiff: await getSemanticDiffAdvert(),
});
return;
}
@@ -675,6 +754,7 @@ export async function startReviewServer(options: {
rawPatch: currentPatch,
gitRef: currentGitRef,
prDiffScope: currentPRDiffScope,
+ semanticDiff: await getSemanticDiffAdvert(),
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to switch PR diff scope";
@@ -756,6 +836,7 @@ export async function startReviewServer(options: {
repoInfo,
...(switchedViewedFiles.length > 0 && { viewedFiles: switchedViewedFiles }),
...(currentError ? { error: currentError } : {}),
+ semanticDiff: await getSemanticDiffAdvert(),
});
} catch (err) {
return json(res, { error: err instanceof Error ? err.message : "Failed to switch PR" }, 500);
diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh
index b9759a26..457babe6 100755
--- a/apps/pi-extension/vendor.sh
+++ b/apps/pi-extension/vendor.sh
@@ -7,7 +7,7 @@ cd "$(dirname "$0")"
rm -rf generated
mkdir -p generated generated/ai/providers
-for f in feedback-templates prompts review-core diff-paths jj-core vcs-core review-args storage draft project pr-types pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file config external-annotation agent-jobs worktree worktree-pool html-to-markdown url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir; do
+for f in feedback-templates prompts review-core diff-paths jj-core vcs-core review-args storage draft project pr-types pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file config external-annotation agent-jobs worktree worktree-pool html-to-markdown url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff; do
src="../../packages/shared/$f.ts"
printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts"
done
diff --git a/docs/semantic-diff-handoff.md b/docs/semantic-diff-handoff.md
new file mode 100644
index 00000000..7a9504b9
--- /dev/null
+++ b/docs/semantic-diff-handoff.md
@@ -0,0 +1,340 @@
+# Semantic Diff Handoff
+
+PR: https://github.com/backnotprop/plannotator/pull/871
+
+Branch: `feat/sem-diff`
+
+## What Was Built
+
+This PR adds a semantic diff overview to the code review UI. Instead of starting reviewers in a raw file list, Plannotator can now show a compact entity-level overview like:
+
+```txt
+packages/ui/components/Settings.tsx
+ added function isBuiltInFont
+ modified function ReviewDisplayTab
+```
+
+The view is backed by the Ataraxy `sem` CLI. Plannotator sends the active review patch to `sem diff --patch --format json`, receives structured JSON, then renders grouped semantic rows in a Dockview panel.
+
+The semantic diff panel is intended to be the default landing view when sem is available. The existing `All files` panel remains available directly underneath it in the file tree.
+
+## Main User Flow
+
+1. The review server creates or updates `currentPatch`.
+2. The server chooses a semantic diff cwd:
+ - workspace root for workspace reviews
+ - PR worktree/local checkout if present
+ - local Git/JJ repo cwd when available
+ - neutral scratch directory for patch-only reviews
+3. The server runs `sem diff --patch --format json` with `currentPatch` on stdin.
+4. The server parses sem JSON into `SemanticDiffResponse`.
+5. The UI fetches `/api/semantic-diff`.
+6. The semantic panel renders file groups and entity rows.
+7. Clicking a semantic row opens the existing diff file view and selects the relevant line range.
+
+The important architectural rule is:
+
+```txt
+review mode -> currentPatch -> sem diff --patch -> semantic overview UI
+```
+
+Local code improves cwd/blob resolution, but patch-only mode still works from hunk content.
+
+## How It Was Built
+
+### Shared Sem Runner
+
+Source:
+
+- `packages/shared/semantic-diff.ts`
+- `packages/shared/semantic-diff-types.ts`
+- `packages/shared/semantic-diff.test.ts`
+
+Responsibilities:
+
+- Resolve the sem binary.
+- Prefer `PLANNOTATOR_SEM_PATH` when explicitly configured.
+- Prefer the managed Plannotator sidecar under the user data dir.
+- Fall back to PATH only after managed sem.
+- Avoid executing repo-local sem packages from reviewed code.
+- On Windows, do not spawn bare `sem`; require an absolute resolved `sem.exe`, managed sem, or explicit env path.
+- Run `sem diff --patch --format json`.
+- Normalize optional `fileExt` and `fileExts` query filters.
+- Parse sem JSON into stable Plannotator response types.
+- Cache responses by patch, cwd, and file extension filter.
+
+### Bun Review Server
+
+Source:
+
+- `packages/server/review.ts`
+- `packages/server/review-workspace.test.ts`
+
+Responsibilities:
+
+- Advertise semantic diff availability in `/api/diff` and diff-switch responses.
+- Serve parsed semantic diff from `GET /api/semantic-diff`.
+- Track the active `currentPatch`.
+- Resolve semantic cwd independently from agent cwd.
+- Cache sem availability per cwd.
+- Cache semantic diff results for the active patch.
+
+### Pi/Node Review Server Mirror
+
+Source:
+
+- `apps/pi-extension/server/serverReview.ts`
+- `apps/pi-extension/server.test.ts`
+- `apps/pi-extension/vendor.sh`
+
+Responsibilities:
+
+- Mirror the Bun server semantic diff behavior.
+- Vendor shared semantic diff modules into `apps/pi-extension/generated/`.
+- Keep route parity for `/api/semantic-diff`.
+
+### GitHub and GitLab PR Inputs
+
+GitHub source:
+
+- `packages/shared/pr-github.ts`
+
+GitLab source:
+
+- `packages/shared/pr-gitlab.ts`
+- `packages/shared/pr-gitlab.test.ts`
+
+Important GitLab note:
+
+- GitLab now uses `raw_diffs`, not reconstructed JSON diffs.
+- This preserves collapsed/generated file content and binary diff markers.
+- Public GitLab fixture testing showed JSON reconstruction missed binary changes and could omit collapsed generated files.
+
+## Frontend Asset Map
+
+### Review App Entrypoints
+
+- `apps/review/index.html`
+- `apps/review/index.tsx`
+- `apps/review/vite.config.ts`
+- `apps/review/dist/index.html` after build
+
+The review app imports the review editor package and builds a single-file review UI.
+
+### Hook App Bundled Review HTML
+
+- `apps/hook/index.html`
+- `apps/hook/index.tsx`
+- `apps/hook/vite.config.ts`
+- `apps/hook/dist/review.html` after build
+
+`apps/hook/dist/review.html` is the bundled review editor HTML copied from the review build during hook build.
+
+### Main Review UI Wiring
+
+- `packages/review-editor/App.tsx`
+
+Key responsibilities:
+
+- Tracks `semanticDiffAvailable`.
+- Opens semantic diff as the initial default panel when advertised.
+- Falls back to `All files` if initial semantic load errors.
+- Applies semantic diff availability updates after diff switches and PR switches.
+- Passes semantic diff state and handlers through `ReviewStateContext`.
+
+### Semantic Diff Panel
+
+- `packages/review-editor/dock/panels/ReviewSemanticDiffPanel.tsx`
+
+Key responsibilities:
+
+- Fetches `GET /api/semantic-diff`.
+- Handles loading, empty, error, unavailable, and ready states.
+- Groups semantic changes by file path.
+- Renders the terminal-like semantic diff rows.
+- Opens the existing diff file panel when a row is clicked.
+- Converts semantic line metadata into Plannotator line selection.
+
+### Dockview Registration
+
+- `packages/review-editor/dock/reviewPanelTypes.ts`
+- `packages/review-editor/dock/reviewPanelComponents.ts`
+- `packages/review-editor/dock/ReviewStateContext.tsx`
+
+Key responsibilities:
+
+- Defines `REVIEW_PANEL_TYPES.SEMANTIC_DIFF`.
+- Defines `REVIEW_SEMANTIC_DIFF_PANEL_ID`.
+- Registers `ReviewSemanticDiffPanel` as a Dockview component.
+- Exposes semantic diff state and callbacks to panel components.
+
+### Left File Tree Entry
+
+- `packages/review-editor/components/FileTree.tsx`
+
+Key responsibilities:
+
+- Shows the `Semantic diff` navigation entry above `All files`.
+- Only shows it when `semanticDiffAvailable` is true.
+- Keeps file selection inactive while semantic diff or all-files overview is active.
+
+### PR Switch Hook Type Wiring
+
+- `packages/review-editor/hooks/usePRStack.ts`
+
+Key responsibilities:
+
+- Carries semantic diff advert data through PR scope/switch responses.
+- Uses shared `SemanticDiffAdvert` from `@plannotator/shared/semantic-diff-types`.
+
+### Semantic Diff Styles
+
+- `packages/review-editor/index.css`
+
+Relevant CSS block:
+
+- `.semantic-diff-panel`
+- `.semantic-diff-terminal`
+- `.semantic-diff-file`
+- `.semantic-diff-row`
+- `.semantic-diff-symbol-*`
+- `.semantic-diff-summary`
+- `.semantic-diff-retry`
+- `.semantic-diff-loading`
+- `.semantic-diff-error`
+- `.semantic-diff-empty`
+
+The styling intentionally mirrors sem's terminal output shape: file boxes, pipe glyphs, change symbols, entity type, entity name, and status label.
+
+## API Surface
+
+### `GET /api/diff`
+
+Now includes:
+
+```ts
+semanticDiff?: {
+ available: boolean;
+ semVersion?: string;
+ semSource?: string;
+}
+```
+
+### `GET /api/semantic-diff`
+
+Returns one of:
+
+- `SemanticDiffOkResponse`
+- `SemanticDiffUnavailableResponse`
+- `SemanticDiffErrorResponse`
+
+Optional query filters:
+
+- `?fileExt=.ts`
+- `?fileExts=.ts,.tsx`
+
+### Diff Switch and PR Switch Responses
+
+These responses also include the semantic diff advert when available:
+
+- `/api/diff/switch`
+- `/api/pr-diff-scope`
+- `/api/pr-switch`
+
+## Install And Binary Behavior
+
+Installer changes install sem as an optional sidecar dependency. Semantic diff is non-fatal:
+
+- If sem is available, the semantic diff UI can open.
+- If sem is unavailable, Plannotator hides/falls back to the existing all-files diff path.
+- If sem errors during initial auto-open, the UI falls back to `All files`.
+
+Relevant installer files:
+
+- `scripts/install.sh`
+- `scripts/install.ps1`
+- `scripts/install.cmd`
+
+Managed sem path:
+
+```txt
+/vendor/sem//sem
+/vendor/sem//sem.exe
+```
+
+## Review Modes Covered
+
+### Local Git/JJ
+
+Uses the local repo cwd and active VCS patch.
+
+### Workspace / Multi-Repo
+
+Aggregates child repo patches with workspace-prefixed paths, then runs sem from the workspace root. Sem can fall back to hunk content when child repo blob SHAs are not resolvable from the parent.
+
+### GitHub PR
+
+Uses `gh pr diff` patch text. If local checkout/worktree exists, semantic diff runs from that cwd. Otherwise it runs from scratch.
+
+### GitLab MR
+
+Uses GitLab `raw_diffs` via `glab api`, preserving raw unified patch text and binary markers.
+
+### PR Full-Stack
+
+Requires local checkout/worktree to create the full-stack patch. Once `currentPatch` exists, semantic diff uses the same pipeline.
+
+### Raw / Shared / Patch-Only
+
+Runs sem from a neutral scratch cwd with patch text on stdin.
+
+## Verification Run
+
+Latest local verification after the final hardening pass:
+
+```sh
+bun test
+# 1357 pass, 0 fail
+
+bunx tsc --noEmit -p packages/shared/tsconfig.json
+bunx tsc --noEmit -p packages/server/tsconfig.json
+bunx tsc --noEmit -p packages/ui/tsconfig.json
+bunx tsc --noEmit -p apps/pi-extension/tsconfig.json
+
+bun run build:pi
+
+git diff --check
+```
+
+All passed.
+
+## Useful Public GitLab Fixtures
+
+These were used to validate GitLab patch behavior without running the full app:
+
+- Normal TypeScript: `https://gitlab.com/gitlab-org/gitlab-vscode-extension/-/merge_requests/3226`
+- Renamed TypeScript: `https://gitlab.com/gitlab-org/gitlab-vscode-extension/-/merge_requests/3220`
+- Rename plus new TypeScript: `https://gitlab.com/gitlab-org/gitlab-vscode-extension/-/merge_requests/3059`
+- TS plus Vue: `https://gitlab.com/gitlab-org/gitlab-vscode-extension/-/merge_requests/3176`
+- Binary marker: `https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/4794`
+- Binary add/delete: `https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/4448`
+
+## Current Known Tradeoffs
+
+- Workspace semantic diff runs once against the aggregated workspace patch. This is simple and works for normal hunk-backed changes, but a future precision improvement could run sem per child repo and merge/prefix the semantic results.
+- Checkout-less PR and raw-patch reviews rely on hunk content rather than local blob resolution. This is expected and still useful.
+- Sem language coverage is controlled by sem itself. Unsupported file extensions can still fall back to chunk-style behavior inside sem.
+
+## Final PR State At Handoff
+
+PR:
+
+```txt
+https://github.com/backnotprop/plannotator/pull/871
+```
+
+Implementation head before this handoff doc was added:
+
+```txt
+13d44b02d6cf6c1fd050cbf552bd494f2a729e5a
+```
diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx
index c7c87337..8675fa53 100644
--- a/packages/review-editor/App.tsx
+++ b/packages/review-editor/App.tsx
@@ -14,6 +14,7 @@ import { GitLabIcon } from '@plannotator/ui/components/GitLabIcon';
import { RepoIcon } from '@plannotator/ui/components/RepoIcon';
import { PullRequestIcon } from '@plannotator/ui/components/PullRequestIcon';
import { getPlatformLabel, getMRLabel, getMRNumberLabel, getDisplayRepo } from '@plannotator/shared/pr-types';
+import type { SemanticDiffAdvert } from '@plannotator/shared/semantic-diff-types';
import { configStore, useConfigValue } from '@plannotator/ui/config';
import { loadDiffFont } from '@plannotator/ui/utils/diffFonts';
import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch';
@@ -73,6 +74,7 @@ import {
REVIEW_PR_SUMMARY_PANEL_ID,
REVIEW_PR_COMMENTS_PANEL_ID,
REVIEW_PR_CHECKS_PANEL_ID,
+ REVIEW_SEMANTIC_DIFF_PANEL_ID,
REVIEW_ALL_FILES_PANEL_ID,
REVIEW_CODE_NAV_PANEL_ID,
} from './dock/reviewPanelTypes';
@@ -98,6 +100,7 @@ interface DiffData {
prStackInfo?: PRStackInfo | null;
prDiffScope?: PRDiffScope;
prDiffScopeOptions?: PRDiffScopeOption[];
+ semanticDiff?: SemanticDiffAdvert;
}
function getFileTabTitle(filePath: string): string {
@@ -112,6 +115,8 @@ const ReviewApp: React.FC = () => {
const [annotations, setAnnotations] = useState([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState(null);
const [isAllFilesActive, setIsAllFilesActive] = useState(false);
+ const [isSemanticDiffActive, setIsSemanticDiffActive] = useState(false);
+ const [semanticDiffAvailable, setSemanticDiffAvailable] = useState(false);
const [isDiffPanelActive, setIsDiffPanelActive] = useState(false);
const [allFilesVisibleFile, setAllFilesVisibleFile] = useState(null);
const [pendingSelection, setPendingSelection] = useState(null);
@@ -272,6 +277,7 @@ const ReviewApp: React.FC = () => {
const filesRef = useRef(files);
filesRef.current = files;
const needsInitialDiffPanel = useRef(true);
+ const semanticDiffAutoFallbackPending = useRef(false);
// PR context (lifted from sidebar so center dock PR panels can access it)
const { prContext, isLoading: isPRContextLoading, error: prContextError, fetchContext: fetchPRContext } = usePRContext(prMetadata ?? null);
@@ -281,6 +287,7 @@ const ReviewApp: React.FC = () => {
const openDiffFile = useCallback((filePath: string) => {
const file = files.find(candidate => candidate.path === filePath);
if (!file) return;
+ semanticDiffAutoFallbackPending.current = false;
if (!dockApi) {
const fileIndex = files.findIndex(candidate => candidate.path === filePath);
@@ -444,7 +451,9 @@ const ReviewApp: React.FC = () => {
existing.api.setTitle(`References: ${request.symbol}`);
existing.api.setActive();
} else {
- const refPanel = isAllFilesActive
+ const refPanel = isSemanticDiffActive
+ ? REVIEW_SEMANTIC_DIFF_PANEL_ID
+ : isAllFilesActive
? REVIEW_ALL_FILES_PANEL_ID
: REVIEW_DIFF_PANEL_ID;
dockApi.addPanel({
@@ -455,7 +464,7 @@ const ReviewApp: React.FC = () => {
initialHeight: 250,
});
}
- }, [codeNav.resolve, dockApi, isAllFilesActive, gitContext, agentCwd]);
+ }, [codeNav.resolve, dockApi, isAllFilesActive, isSemanticDiffActive, gitContext, agentCwd]);
// Check AI capabilities on mount
useEffect(() => {
@@ -594,8 +603,14 @@ const ReviewApp: React.FC = () => {
// Sync activeFileIndex when user switches between dock tabs
event.api.onDidActivePanelChange((panel) => {
- if (!panel) { setIsAllFilesActive(false); setIsDiffPanelActive(false); return; }
+ if (!panel) {
+ setIsAllFilesActive(false);
+ setIsSemanticDiffActive(false);
+ setIsDiffPanelActive(false);
+ return;
+ }
setIsAllFilesActive(panel.id === REVIEW_ALL_FILES_PANEL_ID);
+ setIsSemanticDiffActive(panel.id === REVIEW_SEMANTIC_DIFF_PANEL_ID);
setIsDiffPanelActive(isReviewDiffPanelId(panel.id));
if (!isReviewDiffPanelId(panel.id)) return;
const filePath = getReviewDiffPanelFilePath(panel.params);
@@ -614,7 +629,10 @@ const ReviewApp: React.FC = () => {
event.api.totalPanels === 1 && event.api.groups.length === 1
? event.api.groups[0]?.panels[0]
: undefined;
- const hideHeaders = lonePanel?.id === REVIEW_DIFF_PANEL_ID || lonePanel?.id === REVIEW_ALL_FILES_PANEL_ID;
+ const hideHeaders =
+ lonePanel?.id === REVIEW_DIFF_PANEL_ID ||
+ lonePanel?.id === REVIEW_SEMANTIC_DIFF_PANEL_ID ||
+ lonePanel?.id === REVIEW_ALL_FILES_PANEL_ID;
for (const group of event.api.groups) {
group.header.hidden = hideHeaders;
}
@@ -703,6 +721,7 @@ const ReviewApp: React.FC = () => {
const openAllFilesPanel = useCallback(() => {
if (!dockApi) return;
+ semanticDiffAutoFallbackPending.current = false;
const existing = dockApi.getPanel(REVIEW_ALL_FILES_PANEL_ID);
if (existing) { existing.api.setActive(); return; }
dockApi.addPanel({
@@ -712,7 +731,59 @@ const ReviewApp: React.FC = () => {
});
}, [dockApi]);
- // Open the all-files panel on first load.
+ const openSemanticDiffPanel = useCallback((options?: { autoFallbackOnError?: boolean }) => {
+ if (!dockApi) return;
+ semanticDiffAutoFallbackPending.current = options?.autoFallbackOnError === true;
+ if (!semanticDiffAvailable) {
+ openAllFilesPanel();
+ return;
+ }
+ const existing = dockApi.getPanel(REVIEW_SEMANTIC_DIFF_PANEL_ID);
+ if (existing) { existing.api.setActive(); return; }
+ dockApi.addPanel({
+ id: REVIEW_SEMANTIC_DIFF_PANEL_ID,
+ component: REVIEW_PANEL_TYPES.SEMANTIC_DIFF,
+ title: 'Semantic diff',
+ });
+ }, [dockApi, openAllFilesPanel, semanticDiffAvailable]);
+
+ const handleSemanticDiffUnavailable = useCallback(() => {
+ semanticDiffAutoFallbackPending.current = false;
+ setSemanticDiffAvailable(false);
+ dockApi?.getPanel(REVIEW_SEMANTIC_DIFF_PANEL_ID)?.api.close();
+ openAllFilesPanel();
+ }, [dockApi, openAllFilesPanel]);
+
+ const handleSemanticDiffLoadSuccess = useCallback(() => {
+ semanticDiffAutoFallbackPending.current = false;
+ }, []);
+
+ const handleSemanticDiffLoadError = useCallback(() => {
+ if (!semanticDiffAutoFallbackPending.current) return false;
+ if (dockApi?.activePanel?.id !== REVIEW_SEMANTIC_DIFF_PANEL_ID) {
+ // The user has already moved on; don't steal focus by auto-opening All files.
+ semanticDiffAutoFallbackPending.current = false;
+ return false;
+ }
+ semanticDiffAutoFallbackPending.current = false;
+ dockApi?.getPanel(REVIEW_SEMANTIC_DIFF_PANEL_ID)?.api.close();
+ openAllFilesPanel();
+ return true;
+ }, [dockApi, openAllFilesPanel]);
+
+ const applySemanticDiffAdvert = useCallback((semanticDiff?: SemanticDiffAdvert) => {
+ if (!semanticDiff) return;
+ const available = semanticDiff.available === true;
+ setSemanticDiffAvailable(available);
+ if (!available) {
+ semanticDiffAutoFallbackPending.current = false;
+ dockApi?.getPanel(REVIEW_SEMANTIC_DIFF_PANEL_ID)?.api.close();
+ if (isSemanticDiffActive) openAllFilesPanel();
+ }
+ }, [dockApi, isSemanticDiffActive, openAllFilesPanel]);
+
+ // Open the All files overview on first load. Semantic diff stays available via
+ // the file-tree nav entry, but it's no longer the default landing view.
useEffect(() => {
if (!dockApi || !needsInitialDiffPanel.current || files.length === 0) return;
needsInitialDiffPanel.current = false;
@@ -802,6 +873,7 @@ const ReviewApp: React.FC = () => {
viewedFiles?: string[];
error?: string;
isWSL?: boolean;
+ semanticDiff?: SemanticDiffAdvert;
serverConfig?: { displayName?: string; gitUser?: string };
}) => {
// Initialize config store with server-provided values (config file > cookie > default)
@@ -850,6 +922,7 @@ const ReviewApp: React.FC = () => {
}
if (data.error) setDiffError(data.error);
if (data.isWSL) setIsWSL(true);
+ setSemanticDiffAvailable(data.semanticDiff?.available === true);
// Mark diff type setup as pending on first run (local mode only)
if (data.diffType && data.mode !== 'workspace' && !data.prMetadata && data.gitContext && data.gitContext.vcsType !== 'p4' && data.gitContext.vcsType !== 'jj' && needsDiffTypeSetup()) {
setDiffTypeSetupPending(true);
@@ -865,6 +938,7 @@ const ReviewApp: React.FC = () => {
});
setFiles(demoFiles);
setWorkspaceDiffOptions(null);
+ setSemanticDiffAvailable(false);
})
.finally(() => setIsLoading(false));
}, []);
@@ -1124,6 +1198,7 @@ const ReviewApp: React.FC = () => {
rawPatch: string; gitRef: string;
repoInfo?: { display: string; branch?: string };
viewedFiles?: string[]; error?: string;
+ semanticDiff?: SemanticDiffAdvert;
}) {
const isPRSwitch = !!data.prMetadata;
const nextFiles = parseDiffToFiles(data.rawPatch);
@@ -1151,6 +1226,7 @@ const ReviewApp: React.FC = () => {
setViewedFiles(data.viewedFiles ? new Set(data.viewedFiles) : new Set());
}
setDiffError(data.error || null);
+ applySemanticDiffAdvert(data.semanticDiff);
resetStagedFiles();
}
@@ -1187,9 +1263,11 @@ const ReviewApp: React.FC = () => {
gitContext?: GitContext;
diffOptions?: DiffOption[];
error?: string;
+ semanticDiff?: SemanticDiffAdvert;
};
const nextFiles = parseDiffToFiles(data.rawPatch);
+ applySemanticDiffAdvert(data.semanticDiff);
if (options?.preserveFile) {
// Whitespace toggle: update patch in-place, keep the active file.
@@ -1257,7 +1335,7 @@ const ReviewApp: React.FC = () => {
} finally {
setIsLoadingDiff(false);
}
- }, [dockApi, resetStagedFiles, selectedBase, diffHideWhitespace, files, activeFileIndex, openDiffFile]);
+ }, [dockApi, resetStagedFiles, selectedBase, diffHideWhitespace, files, activeFileIndex, openDiffFile, applySemanticDiffAdvert]);
// Switch the base branch the current diff compares against.
// Only triggers a refetch when the active mode actually uses a base.
@@ -1380,6 +1458,7 @@ const ReviewApp: React.FC = () => {
// Build ReviewState value for dock panel context
const reviewStateValue = useMemo(() => ({
files,
+ rawPatch: diffData?.rawPatch ?? '',
focusedFileIndex: activeFileIndex,
focusedFilePath: files[activeFileIndex]?.path ?? null,
diffStyle,
@@ -1446,13 +1525,18 @@ const ReviewApp: React.FC = () => {
openDiffFile,
onAllFilesVisibleFileChange: setAllFilesVisibleFile,
isAllFilesActive,
+ isSemanticDiffActive,
+ semanticDiffAvailable,
+ onSemanticDiffUnavailable: handleSemanticDiffUnavailable,
+ onSemanticDiffLoadError: handleSemanticDiffLoadError,
+ onSemanticDiffLoadSuccess: handleSemanticDiffLoadSuccess,
openTourPanel: handleOpenTour,
onCodeNavRequest: handleCodeNavRequest,
codeNavResult: codeNav.result,
codeNavIsLoading: codeNav.isLoading,
codeNavActiveSymbol: codeNav.activeSymbol,
}), [
- files, activeFileIndex, diffStyle, diffOverflow, diffIndicators,
+ files, diffData?.rawPatch, activeFileIndex, diffStyle, diffOverflow, diffIndicators,
diffLineDiffType, diffShowLineNumbers, diffShowBackground,
diffExpandUnchanged, diffFontFamily, diffFontSize, activeDiffBase, committedBase, feedbackDiffContext, prReviewScopeLabel, prDiffScope,
allAnnotations, externalAnnotations,
@@ -1466,7 +1550,8 @@ const ReviewApp: React.FC = () => {
handleAskAI, handleViewAIResponse, handleClickAIMarker,
aiHistoryForSelection, agentJobs.jobs, prMetadata, prContext,
isPRContextLoading, prContextError, fetchPRContext, platformUser, openDiffFile,
- handleOpenTour, isAllFilesActive, handleAddAnnotationForFile,
+ handleOpenTour, isAllFilesActive, isSemanticDiffActive, semanticDiffAvailable,
+ handleSemanticDiffUnavailable, handleSemanticDiffLoadError, handleSemanticDiffLoadSuccess, handleAddAnnotationForFile,
handleCodeNavRequest, codeNav.result, codeNav.isLoading, codeNav.activeSymbol,
]);
@@ -2159,6 +2244,9 @@ const ReviewApp: React.FC = () => {
openSemanticDiffPanel()}
+ isSemanticDiffActive={isSemanticDiffActive}
+ semanticDiffAvailable={semanticDiffAvailable}
onSelectAllFiles={openAllFilesPanel}
isAllFilesActive={isAllFilesActive}
scrollHighlightIndex={isAllFilesActive && allFilesVisibleFile ? files.findIndex(f => f.path === allFilesVisibleFile) : undefined}
diff --git a/packages/review-editor/components/DiffViewer.tsx b/packages/review-editor/components/DiffViewer.tsx
index 7be8cd37..1b2deb3f 100644
--- a/packages/review-editor/components/DiffViewer.tsx
+++ b/packages/review-editor/components/DiffViewer.tsx
@@ -429,6 +429,47 @@ export const DiffViewer: React.FC = ({
return retryScrollToSearchMatch(containerRef.current, activeSearchMatch);
}, [activeSearchMatch, filePath, diffStyle, diffOverflow, diffIndicators, lineDiffType, disableLineNumbers, disableBackground, expandUnchanged, viewport]);
+ // Scroll to the selected line range — drives "jump to entity" from semantic-diff
+ // clicks and AI "scroll to lines". Mirrors the scroll-to-annotation behavior used
+ // by sidebar comments (center the target, smooth). pierre tags the selected rows
+ // with `[data-selected-line]` inside the diff shadow DOM once it applies
+ // `selectedLines`, so we retry across frames until it appears.
+ //
+ // Only scroll when the target is off-screen: a manual drag-select also sets
+ // pendingSelection, but its lines are by definition already visible, so we leave
+ // the view untouched and avoid yanking it on every selection.
+ useEffect(() => {
+ if (!pendingSelection || !containerRef.current) return;
+ const container = containerRef.current;
+ let cancelled = false;
+ let attempts = 0;
+ const MAX_ATTEMPTS = 30;
+
+ const tryScroll = () => {
+ if (cancelled) return;
+ const target = getSearchRoots(container)
+ .map((root) => (root as ParentNode).querySelector?.('[data-selected-line]') ?? null)
+ .find((el): el is Element => el != null);
+ if (target) {
+ const targetRect = target.getBoundingClientRect();
+ const viewRect = container.getBoundingClientRect();
+ const fullyVisible = targetRect.top >= viewRect.top && targetRect.bottom <= viewRect.bottom;
+ if (!fullyVisible) {
+ target.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
+ }
+ return;
+ }
+ attempts += 1;
+ if (attempts < MAX_ATTEMPTS) requestAnimationFrame(tryScroll);
+ };
+
+ const raf = requestAnimationFrame(tryScroll);
+ return () => {
+ cancelled = true;
+ cancelAnimationFrame(raf);
+ };
+ }, [pendingSelection, filePath, augmentedDiff, viewport]);
+
// Map annotations to @pierre/diffs format
const lineAnnotations = useMemo(() => {
return annotations
diff --git a/packages/review-editor/components/FileHeader.tsx b/packages/review-editor/components/FileHeader.tsx
index f456be0e..1134ca17 100644
--- a/packages/review-editor/components/FileHeader.tsx
+++ b/packages/review-editor/components/FileHeader.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { DiffOptionsPopover } from './DiffOptionsPopover';
+import { SemanticFileBadge } from './SemanticFileBadge';
interface FileHeaderProps {
filePath: string;
@@ -202,6 +203,7 @@ export const FileHeader: React.FC = ({
>
)}
+
diff --git a/packages/review-editor/components/FileTree.tsx b/packages/review-editor/components/FileTree.tsx
index f5c3d602..c503f44d 100644
--- a/packages/review-editor/components/FileTree.tsx
+++ b/packages/review-editor/components/FileTree.tsx
@@ -60,6 +60,9 @@ interface FileTreeProps {
activeSearchMatchId?: string | null;
onSelectSearchMatch?: (matchId: string) => void;
onStepSearchMatch?: (direction: 1 | -1) => void;
+ onSelectSemanticDiff?: () => void;
+ isSemanticDiffActive?: boolean;
+ semanticDiffAvailable?: boolean;
onSelectAllFiles?: () => void;
isAllFilesActive?: boolean;
scrollHighlightIndex?: number;
@@ -112,6 +115,9 @@ export const FileTree: React.FC = ({
activeSearchMatchId,
onSelectSearchMatch,
onStepSearchMatch,
+ onSelectSemanticDiff,
+ isSemanticDiffActive = false,
+ semanticDiffAvailable = false,
onSelectAllFiles,
isAllFilesActive = false,
scrollHighlightIndex,
@@ -452,6 +458,19 @@ export const FileTree: React.FC = ({
)
) : (
<>
+ {semanticDiffAvailable && onSelectSemanticDiff && (
+
+ )}
{onSelectAllFiles && (