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
10 changes: 9 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,15 @@ such detail into a token. New layout spacing must use the scale.
Comment status badges match the same control height.
- Resolved comments collapse to their header by default. A labeled chevron
expands the body and actions; reopening restores the expanded open state.
- Comment views default to open comments and offer Open, Resolved, and All filters.
- Comment views default to open comments and offer Open, Resolved, Outdated, and
All filters. The Comments panel lists Current comments, then an Outdated section
for comments whose code changed or left the diff. Outdated comments carry an
"Outdated" badge, a muted placement note, and their original code; they stay
out of code lines and render in the file's lineNumber 0 slot. File headers and
the file tree show muted outdated counts beside accent current counts. A
snapshot-notice-style banner points to open outdated comments. "Keep open"
moves an outdated comment to its file on the latest snapshot, still labeled
outdated.
Export uses one primary `Copy review` action plus a menu for broader scopes.
- Keep `Save comment` enabled when idle; empty submission focuses the labeled
editor and shows an inline error. Disable it while saving. Deleting a saved
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,11 @@ agent-ready XML.

Observations are immutable. New content creates another observation; matching
submissions associate new provenance with an existing review without changing
its captured diff. Comments and reviewed marks are
stored in SQLite. Display preferences remain in the browser.
its captured diff. Comments and reviewed marks belong to the checkout's stream
(or one piped capture) and are stored in SQLite: later snapshots re-anchor
comments to moved code, flag outdated ones, and keep marks on files whose patch
is unchanged. Deleting one snapshot keeps them; they end with the stream's last
snapshot. Display preferences remain in the browser.

## Piped diffs

Expand Down Expand Up @@ -327,7 +330,7 @@ WebMCP while the page is open; see [docs/webmcp.md](docs/webmcp.md).

Local data lives in `$XDG_STATE_HOME/diffx/state.db`, or
`~/.local/state/diffx/state.db`. `--state memory` disables persistence.
Current schema 10 state persists across restarts. Older, newer or unrecognized
Current schema 11 state persists across restarts. Older, newer or unrecognized
databases are refused without an automatic reset. To start fresh, stop the
server, back up its state directory, and explicitly choose a new database path.
Legacy commands and pending producer payloads are retired.
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useReviewState } from "./app-state.tsx";
import { OutdatedCommentsNotice } from "./CommentsPanel.tsx";
import { DiffWorkspace } from "./DiffWorkspace.tsx";
import { FileExplorerNav, SidebarResizer } from "./FileExplorerNav.tsx";
import { Header } from "./Header.tsx";
Expand All @@ -14,6 +15,7 @@ export function App() {
<WebMCPTools />
<Header />
<NewerSnapshotNotice />
<OutdatedCommentsNotice />
<SidebarBackdrop />
<div className="workspace">
<FileExplorerNav />
Expand Down
144 changes: 84 additions & 60 deletions apps/web/src/CommentsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { useState } from "react";
import { ChevronDownIcon, Trash2Icon } from "lucide-react";
import {
ChevronDownIcon,
MessageSquareWarningIcon,
Trash2Icon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Expand All @@ -25,32 +29,33 @@ import {
useReviewState,
} from "./app-state.tsx";
import { DraftComment, ReviewCommentCard } from "./review.tsx";
import { capabilityEnabled, useSession } from "./session-context.tsx";
import {
anchored,
commentApplicability,
commentStale,
placedInline,
placementFor,
type ReviewComment,
} from "./review-model.ts";

type CommentState = "open" | "resolved" | "stale";
type CommentFilter = CommentState | "all";
type CommentFilter = "open" | "resolved" | "outdated" | "all";

const COMMENT_FILTERS: readonly CommentFilter[] = [
"open",
"resolved",
"stale",
"outdated",
"all",
];
const DELETE_FILTERS: readonly CommentFilter[] = [
"all",
"open",
"resolved",
"stale",
"outdated",
];

function filterLabel(filter: CommentFilter) {
if (filter === "open") return "Open";
if (filter === "resolved") return "Resolved";
if (filter === "stale") return "Stale";
if (filter === "outdated") return "Outdated";
return "All";
}

Expand All @@ -63,15 +68,17 @@ export function CommentsPanel() {
const { draft } = useDraft();
const review = useReviewState();
const { navigateComment } = useNavigation();
const state = (comment: ReviewComment): CommentState =>
commentApplicability(comment, repository) === "stale"
? "stale"
: comment.status;
// Lifecycle filters by status; Outdated selects comments written against earlier code.
const matches = (comment: ReviewComment, value: CommentFilter) =>
value === "all" ||
(value === "outdated"
? commentStale(comment, repository)
: comment.status === value);
const counts = {
open: review.comments.filter((comment) => state(comment) === "open").length,
resolved: review.comments.filter((comment) => state(comment) === "resolved")
open: review.comments.filter((comment) => matches(comment, "open")).length,
resolved: review.comments.filter((comment) => matches(comment, "resolved"))
.length,
stale: review.comments.filter((comment) => state(comment) === "stale")
outdated: review.comments.filter((comment) => matches(comment, "outdated"))
.length,
};
const activeCount =
Expand All @@ -82,14 +89,11 @@ export function CommentsPanel() {
: deleteSelection === "all"
? review.comments.length
: counts[deleteSelection];
const currentRound = review.rounds.find((round) => round.current);
const earlierRounds = review.rounds.filter((round) => !round.current);
const visible = (comment: ReviewComment) =>
filter === "all" || filter === state(comment);
const currentComments = currentRound?.comments.filter(visible) ?? [];
const visibleEarlierComments = earlierRounds.reduce(
(count, round) => count + round.comments.filter(visible).length,
0,
const currentComments = review.currentComments.filter((comment) =>
matches(comment, filter),
);
const outdatedComments = review.outdatedComments.filter((comment) =>
matches(comment, filter),
);
return (
<TabsContent
Expand Down Expand Up @@ -129,7 +133,7 @@ export function CommentsPanel() {
if (
value === "open" ||
value === "resolved" ||
value === "stale" ||
value === "outdated" ||
value === "all"
)
setFilter(value);
Expand Down Expand Up @@ -211,7 +215,7 @@ export function CommentsPanel() {
)}
</div>
{draft &&
(!anchored(draft, repository) ? (
(!placedInline(placementFor(draft, repository).applicability) ? (
<DraftComment />
) : (
<Button
Expand All @@ -223,55 +227,40 @@ export function CommentsPanel() {
Continue draft
</Button>
))}
{currentRound && !!currentComments.length && (
<section className="review-round" aria-label="Current review">
<h2>Current review</h2>
{!!currentComments.length && (
<section className="review-round" aria-label="Current comments">
<h2>Current</h2>
{currentComments.map((comment) => (
<ReviewCommentCard key={comment.id} comment={comment} sidebar />
))}
</section>
)}
{earlierRounds.map((round, index) => {
const open = round.comments.filter(
(comment) => state(comment) === "open",
).length;
const resolved = round.comments.filter(
(comment) => state(comment) === "resolved",
).length;
const stale = round.comments.length - open - resolved;
const comments = round.comments.filter(visible);
if (!comments.length) return null;
return (
<details className="review-round earlier-review" key={round.key}>
<summary>
Earlier review {earlierRounds.length - index} · {open} open ·{" "}
{resolved} resolved
{!!stale && ` · ${stale} stale`}
</summary>
{comments.map((comment) => (
<ReviewCommentCard key={comment.id} comment={comment} sidebar />
))}
</details>
);
})}
{!!outdatedComments.length && (
<section
id="outdated-comments"
className="review-round outdated-review"
aria-label="Outdated comments"
>
<h2>Outdated</h2>
<p className="review-round-hint">
Written against earlier code. Resolve them, keep them open on their
file, or delete them.
</p>
{outdatedComments.map((comment) => (
<ReviewCommentCard key={comment.id} comment={comment} sidebar />
))}
</section>
)}
{!review.comments.length && (
<p className="comment-empty">
Hover a code line and click + to leave feedback. Select several lines
to comment on a range. Saved comments persist through the local server
and can be copied for your coding agent.
</p>
)}
{!!review.comments.length &&
!currentRound &&
!!visibleEarlierComments && (
<p className="comment-empty">
No comments in this review yet. Changes after copied feedback start
a new review round; add comments from the Files tab.
</p>
)}
{!!review.comments.length &&
!currentComments.length &&
!visibleEarlierComments && (
!outdatedComments.length && (
<p className="comment-empty">No {filter} comments.</p>
)}
<Dialog
Expand Down Expand Up @@ -321,3 +310,38 @@ export function CommentsPanel() {
</TabsContent>
);
}

/** Points to open comments whose code changed since they were written. */
export function OutdatedCommentsNotice() {
const { capabilities } = useSession();
const review = useReviewState();
const { showOutdatedComments } = useNavigation();
const open = review.outdatedComments.filter(
(comment) => comment.status === "open",
).length;
const visible = capabilityEnabled(capabilities.review.comments) && open > 0;
return (
<div className="outdated-notice" role="status" hidden={!visible}>
{visible && (
<>
<MessageSquareWarningIcon
className="size-(--icon-sm)"
aria-hidden="true"
/>
<p>
{open} open {open === 1 ? "comment is" : "comments are"} outdated by
code changes
</p>
<Button
type="button"
variant="outline-muted"
size="sm"
onClick={showOutdatedComments}
>
Review outdated
</Button>
</>
)}
</div>
);
}
74 changes: 61 additions & 13 deletions apps/web/src/DiffWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,14 @@ import { DiffToolbar } from "./DiffToolbar.tsx";
import { DiffComparison } from "./DiffComparison.tsx";
import { themesFor } from "./display-options.ts";
import { DraftComment, ReviewCommentCard } from "./review.tsx";
import type { CommentAnnotation } from "./review-model.ts";
import {
commentInFile,
commentStale,
placedInline,
placedOutdated,
placementFor,
type CommentAnnotation,
} from "./review-model.ts";
import { ServerMetrics } from "./ServerMetrics.tsx";
import { WorkspaceFeedback } from "./WorkspaceFeedback.tsx";
import { capabilityEnabled, useSession } from "./session-context.tsx";
Expand Down Expand Up @@ -234,19 +241,33 @@ export function DiffWorkspace() {
)
.filter(
(comment) =>
comment.path === file.path &&
commentInFile(comment, file) &&
comment.scope === mode &&
comment.fingerprint === file.fingerprint &&
comment.id !== draft?.id,
)
.map((comment) => ({
side:
comment.target === "file" && file.status === "D"
? "deletions"
: comment.side,
lineNumber: comment.end,
metadata: { kind: "saved", comment },
}));
.flatMap((comment): DiffLineAnnotation<CommentAnnotation>[] => {
const placement = placementFor(comment, repository);
const fileSide = file.status === "D" ? "deletions" : "additions";
// Inline comments sit on their (possibly moved) lines; outdated
// ones stay at file level with their original code.
if (placedInline(placement.applicability))
return [
{
side: comment.target === "file" ? fileSide : placement.side,
lineNumber: comment.target === "file" ? 0 : placement.end,
metadata: { kind: "saved", comment },
},
];
return placedOutdated(placement.applicability)
? [
{
side: fileSide,
lineNumber: 0,
metadata: { kind: "saved", comment },
},
]
: [];
});
if (
draft &&
draft.path === file.path &&
Expand Down Expand Up @@ -296,6 +317,7 @@ export function DiffWorkspace() {
files,
diff.items,
review.comments,
repository,
draft,
mode,
collapsed,
Expand Down Expand Up @@ -553,8 +575,17 @@ export function DiffWorkspace() {
const currentDraft = actions.current.draft;
const ranges = (context.item.annotations ?? []).flatMap(
(annotation): CommentLineRange[] => {
if (annotation.metadata.kind === "saved")
return [annotation.metadata.comment];
if (annotation.metadata.kind === "saved") {
const { comment } = annotation.metadata;
const placement = placementFor(
comment,
actions.current.repository,
);
return comment.target !== "file" &&
placedInline(placement.applicability)
? [placement]
: [];
}
return currentDraft && currentDraft.path === context.item.id
? [currentDraft]
: [];
Expand Down Expand Up @@ -709,8 +740,25 @@ export function DiffWorkspace() {
)}
renderHeaderMetadata={(item) => {
const file = allFiles.find((file) => file.path === item.id);
const outdated =
commentsEnabled && file
? review.comments.filter(
(comment) =>
comment.scope === mode &&
commentInFile(comment, file) &&
commentStale(comment, repository),
).length
: 0;
return file ? (
<div className="diff-file-actions">
{outdated > 0 && (
<span
className="file-outdated-count"
title={`${outdated} outdated review ${outdated === 1 ? "comment" : "comments"} shown at the top of this file`}
>
{outdated} outdated
</span>
)}
<Button
type="button"
className="review-button"
Expand Down
Loading
Loading