Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/annotated-nav-owner-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

`{`/`}` and `hunk session navigate --next-comment`/`--prev-comment` no longer skip notes drawn outside the diff’s changed lines, such as after a reload moves the hunks.
70 changes: 59 additions & 11 deletions packages/hunk/src/core/review/annotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@
* *derivation* is shared, so the terminal and the producer hand the planner the same
* answer instead of two that agree by coincidence.
*
* File membership is broader than hunk membership: a file carrying review context but no
* note inside any hunk is still a stop on the annotated-file tour.
* A hunk counts as annotated when a note intersects it or resolves to it as its owner,
* matching the hunk where drawing places that note. File membership is broader still: a
* file carrying review context without annotations remains on the annotated-file tour.
*/
import { reviewGapOwnerHunkIndex, resolveReviewNoteAnchor } from "./anchors";
import { reviewHunkRanges, reviewRangesOverlap, type ReviewHunkSpan } from "./geometry";
import type { ReviewAnnotationIndex } from "./navigation";
import type { AgentAnnotation } from "../../extension-api/types";
import type { DiffFile } from "../changeset/model";
import type { ReviewLineAddressV1, ReviewRangeAnchorV1 } from "./types";

/** Whether one annotation lands inside a hunk's visible span on either side. */
export function reviewAnnotationOverlapsHunk(annotation: AgentAnnotation, hunk: ReviewHunkSpan) {
Expand All @@ -27,25 +30,70 @@ export function reviewAnnotationOverlapsHunk(annotation: AgentAnnotation, hunk:
);
}

/** Which of one file's hunks carry at least one annotation. */
export function reviewAnnotatedHunkIndices(file: DiffFile | undefined): ReadonlySet<number> {
/** A note target declared by the surface that knows where the note was written. */
export interface ReviewAnnotationTarget extends ReviewLineAddressV1 {
hunkIndex: number;
}

/** Resolve one annotation with the same preferred line and owner used by drawing. */
export function reviewAnnotationAnchor(
hunks: readonly ReviewHunkSpan[],
annotation: AgentAnnotation,
target?: ReviewAnnotationTarget,
): ReviewRangeAnchorV1 {
const preferred: ReviewLineAddressV1 | undefined = target
? { side: target.side, line: target.line }
: annotation.newRange
? { side: "new", line: annotation.newRange[0] }
: annotation.oldRange
? { side: "old", line: annotation.oldRange[0] }
: undefined;
const fallbackOwnerHunkIndex =
target?.hunkIndex ??
(preferred ? reviewGapOwnerHunkIndex(hunks, preferred.side, preferred.line) : undefined);

return resolveReviewNoteAnchor(hunks, {
...(annotation.oldRange ? { oldRange: annotation.oldRange } : {}),
...(annotation.newRange ? { newRange: annotation.newRange } : {}),
...(preferred ? { preferred } : {}),
...(fallbackOwnerHunkIndex !== undefined ? { fallbackOwnerHunkIndex } : {}),
});
}

/** Mark every intersecting hunk and resolved owner, using any declared surface target. */
export function reviewAnnotatedHunkIndices(
file: DiffFile | undefined,
declaredTarget?: (annotation: AgentAnnotation) => ReviewAnnotationTarget | undefined,
): ReadonlySet<number> {
const annotated = new Set<number>();
const annotations = file?.agent?.annotations;
if (!annotations) {
const hunks = file?.metadata.hunks;
if (!annotations || !hunks) {
return annotated;
}
file!.metadata.hunks.forEach((hunk, index) => {
if (annotations.some((annotation) => reviewAnnotationOverlapsHunk(annotation, hunk))) {
annotated.add(index);

for (const annotation of annotations) {
const anchor = reviewAnnotationAnchor(hunks, annotation, declaredTarget?.(annotation));
for (const hunkIndex of anchor.intersectingHunkIndices) {
annotated.add(hunkIndex);
}
});
if (anchor.ownerHunkIndex !== undefined) {
annotated.add(anchor.ownerHunkIndex);
}
}
return annotated;
}

/** Index the annotated files and hunks of one review, keyed by semantic file key. */
/**
* Index annotated files and drawing-owned hunks, keyed by semantic file key.
*
* The optional target callback supplies stored-note placement; without it, geometry alone
* determines the owner, as it does for sidecar annotations.
*/
export function buildReviewAnnotationIndex(
files: readonly DiffFile[],
keyByFileId: ReadonlyMap<string, string>,
declaredTarget?: (annotation: AgentAnnotation) => ReviewAnnotationTarget | undefined,
): ReviewAnnotationIndex {
const annotatedHunkIndicesByFileKey = new Map<string, ReadonlySet<number>>();
const annotatedFileKeys = new Set<string>();
Expand All @@ -58,7 +106,7 @@ export function buildReviewAnnotationIndex(
if (file.agent) {
annotatedFileKeys.add(fileKey);
}
const annotatedHunks = reviewAnnotatedHunkIndices(file);
const annotatedHunks = reviewAnnotatedHunkIndices(file, declaredTarget);
if (annotatedHunks.size > 0) {
annotatedHunkIndicesByFileKey.set(fileKey, annotatedHunks);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/hunk/src/core/review/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export interface ReviewRangeAnchorV1 {
preferred?: ReviewLineAddressV1;
/** Every hunk whose old or new range intersects the note, in file order. */
intersectingHunkIndices: number[];
/** The one hunk that renders the note; navigation uses the intersections instead. */
/** The one hunk that renders the note; annotated navigation includes this owner. */
ownerHunkIndex?: number;
}

Expand Down
38 changes: 6 additions & 32 deletions packages/hunk/src/ui/components/panes/DiffPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ import type { FileSourceStatus } from "../../diff/expandCollapsedRows";
import type { ActiveAddNoteAffordance } from "../../diff/DiffSectionBody";
import type { CursorHighlight } from "../../diff/cursorHighlight";
import { useIntermediateRenderAfterMount } from "../../hooks/useIntermediateRenderAfterMount";
import type { DraftReviewNote, StoredReviewNoteRenderMetadata } from "../../lib/reviewNoteMapping";
import {
storedReviewNoteMetadata,
storedReviewNoteTarget,
type DraftReviewNote,
} from "../../lib/reviewNoteMapping";
import {
createVisibleAgentNote,
reviewNoteSource,
Expand Down Expand Up @@ -122,36 +126,6 @@ type StartUserNoteAtHunk = {
bivarianceHack(fileId: string, hunkIndex: number, target?: ReviewNoteTargetV1): void;
}["bivarianceHack"];

/** Read terminal-only semantic note metadata without granting it to static sidecars. */
function storedReviewNoteMetadata(
annotation: AgentAnnotation,
): StoredReviewNoteRenderMetadata | undefined {
const candidate = annotation as AgentAnnotation & Partial<StoredReviewNoteRenderMetadata>;
return candidate.semanticallyStored === true && typeof candidate.reviewNoteId === "string"
? (candidate as AgentAnnotation & StoredReviewNoteRenderMetadata)
: undefined;
}

/** Read the semantic placement retained on stored terminal note projections. */
function storedReviewNoteTarget(
annotation: AgentAnnotation,
): { hunkIndex: number; side: "old" | "new"; line: number } | undefined {
const candidate = annotation as AgentAnnotation & {
hunkIndex?: unknown;
side?: unknown;
line?: unknown;
};
return Number.isInteger(candidate.hunkIndex) &&
(candidate.side === "old" || candidate.side === "new") &&
Number.isInteger(candidate.line)
? {
hunkIndex: candidate.hunkIndex as number,
side: candidate.side,
line: candidate.line as number,
}
: undefined;
}

/** Grant saved-note card actions from semantic ownership rather than presentation labels. */
export function storedReviewNoteActions({
editable,
Expand Down Expand Up @@ -643,7 +617,7 @@ export function DiffPane({
const notes: VisibleAgentNote[] = annotations.flatMap((annotation, index) => {
const source = reviewNoteSource(annotation);
const metadata = storedReviewNoteMetadata(annotation);
const storedTarget = metadata ? storedReviewNoteTarget(annotation) : undefined;
const storedTarget = storedReviewNoteTarget(annotation);
if (
metadata &&
draftNote?.kind === "edit" &&
Expand Down
60 changes: 60 additions & 0 deletions packages/hunk/src/ui/hooks/useTerminalReview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,66 @@ describe("useTerminalReview", () => {
}
});

test("annotated navigation follows stored note ownership after a reload moves hunks", async () => {
const before = Array.from({ length: 60 }, (_unused, index) => `line ${index + 1}`);
const initialAfter = [...before];
initialAfter[29] = "line 30 changed";
const reloadedAfter = [...before];
reloadedAfter[0] = "line 1 changed";
reloadedAfter[59] = "line 60 changed";
const alpha = createDiffFile("alpha", "alpha.ts", "alpha old\n", "alpha new\n");
const initialBeta = createDiffFile("beta", "beta.ts", lines(...before), lines(...initialAfter));
const { controllerRef, setFilesRef, setup } = await renderTerminalReview([alpha, initialBeta]);

try {
await flush(setup);
let noteId = "";
await act(async () => {
const controller = expectValue(controllerRef.current);
controller.startUserNote("beta", 0, { side: "new", line: 30 });
controller.updateDraftNote("Stored note");
noteId = controller.saveDraftNote()?.id ?? "";
});
await flush(setup);
expect(expectValue(controllerRef.current).userNotesByFileId.beta?.[0]).toMatchObject({
id: noteId,
hunkIndex: 0,
line: 30,
});

await act(async () => {
expectValue(setFilesRef.current)([
alpha,
createDiffFile("beta", "beta.ts", lines(...before), lines(...reloadedAfter)),
]);
});
await flush(setup);

const controller = expectValue(controllerRef.current);
expect(
controller.visibleFiles.find((file) => file.id === "beta")?.metadata.hunks,
).toHaveLength(2);
expect(controller.userNotesByFileId.beta).toHaveLength(1);
expect(controller.userNotesByFileId.beta?.[0]).toMatchObject({
id: noteId,
hunkIndex: 0,
line: 30,
});
await act(async () => {
controller.selectHunk("alpha", 0);
controller.moveSelection("annotated-hunk", 1);
});
await flush(setup);

expect(expectValue(controllerRef.current).selectedFile?.id).toBe("beta");
expect(expectValue(controllerRef.current).selectedHunkIndex).toBe(0);
} finally {
await act(async () => {
setup.renderer.destroy();
});
}
});

test("live comments validate markup at the published live width", async () => {
const noteGeometry: { current: { layout: "split" | "unified"; width: number } | null } = {
current: { layout: "unified", width: 120 },
Expand Down
3 changes: 2 additions & 1 deletion packages/hunk/src/ui/hooks/useTerminalReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import {
storedDraftToDraftNote,
storedNoteToLiveComment,
storedNoteToUserNote,
storedReviewNoteTarget,
type DraftReviewNote,
type UserReviewNote,
} from "../lib/reviewNoteMapping";
Expand Down Expand Up @@ -535,7 +536,7 @@ export function useTerminalReview({
// Which files and hunks carry notes, for the shared annotated-navigation planner. Built
// from the merged stream, so a live comment that just arrived is navigable immediately.
const annotations = useMemo(
() => buildReviewAnnotationIndex(allFiles, keyByFileId),
() => buildReviewAnnotationIndex(allFiles, keyByFileId, storedReviewNoteTarget),
[allFiles, keyByFileId],
);
// The shared normalization rule, not a terminal copy: a selected file the filter hides
Expand Down
29 changes: 8 additions & 21 deletions packages/hunk/src/ui/lib/agentAnnotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import type { DiffFile } from "../../core/changeset/model";
import type { ReviewNoteSource } from "../../core/run/commandInputs";
import type { AgentAnnotation } from "../../extension-api/types";
import { sanitizeTerminalLine } from "../../lib/terminalText";
import { reviewAnnotationOverlapsHunk } from "../../core/review/annotations";
import { resolveReviewNoteAnchor, reviewGapOwnerHunkIndex } from "../../core/review/anchors";
import {
reviewAnnotationAnchor,
reviewAnnotationOverlapsHunk,
type ReviewAnnotationTarget,
} from "../../core/review/annotations";
import type { ReviewHunkSpan } from "../../core/review/geometry";
import type { ReviewLineAddressV1, ReviewRangeAnchorV1 } from "../../core/review/types";
import type { ReviewRangeAnchorV1 } from "../../core/review/types";
import { fileLabel } from "./files";

export interface VisibleAgentNote {
Expand Down Expand Up @@ -112,9 +115,7 @@ export function annotationAnchor(annotation: AgentAnnotation): AnnotationAnchor
}

/** One note's declared target, from the surface that knows where the note was written. */
export interface VisibleNoteTarget extends ReviewLineAddressV1 {
hunkIndex: number;
}
export type VisibleNoteTarget = ReviewAnnotationTarget;

/**
* Builds one note the review stream draws, resolving where it hangs through core.
Expand All @@ -130,24 +131,10 @@ export function createVisibleAgentNote(
note: Omit<VisibleAgentNote, "anchor"> & { target?: VisibleNoteTarget },
): VisibleAgentNote {
const { target, ...visible } = note;
const rangeAnchor = annotationAnchor(note.annotation);
const preferred: ReviewLineAddressV1 | undefined = target
? { side: target.side, line: target.line }
: rangeAnchor
? { side: rangeAnchor.side, line: rangeAnchor.lineNumber }
: undefined;
const fallbackOwnerHunkIndex =
target?.hunkIndex ??
(preferred ? reviewGapOwnerHunkIndex(hunks, preferred.side, preferred.line) : undefined);

return {
...visible,
anchor: resolveReviewNoteAnchor(hunks, {
...(note.annotation.oldRange ? { oldRange: note.annotation.oldRange } : {}),
...(note.annotation.newRange ? { newRange: note.annotation.newRange } : {}),
...(preferred ? { preferred } : {}),
...(fallbackOwnerHunkIndex !== undefined ? { fallbackOwnerHunkIndex } : {}),
}),
anchor: reviewAnnotationAnchor(hunks, note.annotation, target),
};
}

Expand Down
35 changes: 35 additions & 0 deletions packages/hunk/src/ui/lib/reviewNoteMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*/
import type { LiveComment } from "../../core/liveComments";
import { reviewLineAnchor } from "../../core/review/anchors";
import type { ReviewAnnotationTarget } from "../../core/review/annotations";
import type { ReviewHunkSpan } from "../../core/review/geometry";
import {
isRenderableStoredReviewNote,
Expand All @@ -37,6 +38,40 @@ export interface StoredReviewNoteRenderMetadata {
semanticallyStored: true;
}

/** Read terminal-only semantic note metadata without granting it to static sidecars. */
export function storedReviewNoteMetadata(
annotation: AgentAnnotation,
): StoredReviewNoteRenderMetadata | undefined {
const candidate = annotation as AgentAnnotation & Partial<StoredReviewNoteRenderMetadata>;
return candidate.semanticallyStored === true && typeof candidate.reviewNoteId === "string"
? (candidate as AgentAnnotation & StoredReviewNoteRenderMetadata)
: undefined;
}

/** Read the semantic placement retained on stored terminal note projections. */
export function storedReviewNoteTarget(
annotation: AgentAnnotation,
): ReviewAnnotationTarget | undefined {
if (!storedReviewNoteMetadata(annotation)) {
return undefined;
}

const candidate = annotation as AgentAnnotation & {
hunkIndex?: unknown;
side?: unknown;
line?: unknown;
};
return Number.isInteger(candidate.hunkIndex) &&
(candidate.side === "old" || candidate.side === "new") &&
Number.isInteger(candidate.line)
? {
hunkIndex: candidate.hunkIndex as number,
side: candidate.side,
line: candidate.line as number,
}
: undefined;
}

/** Compatibility shape accepted by older terminal-only test projections. */
type OptionalStoredReviewNoteRenderMetadata = Partial<StoredReviewNoteRenderMetadata>;

Expand Down
Loading
Loading