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
34 changes: 23 additions & 11 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,15 +204,17 @@ such detail into a token. New layout spacing must use the scale.
16px icons, and 1.7px strokes, including menu and dialog triggers. Hover and
expanded states use the same neutral surface feedback. On mobile, both rows
use 8px action gaps and the same trailing inset so icon columns align.
- Header uses `--panel`, a quiet brand, and the review trigger as its primary
- Header uses `--panel`, a quiet brand, and the review identity as its primary
context. Keep the secondary scope heading and muted monospace path inline;
hide the path at 768–1011px and both below 768px. Group ghost Refresh/theme
actions at the trailing edge; share `--hover` feedback with the trigger.
- Projects drawer: the outermost full-height column, `--projects-width` (16rem)
on `--panel` with a separator edge. Its header matches `--topbar-height` and
holds the brand and a ghost “Hide projects” action (panel-left icon). Below it:
a search row styled like the file filter that opens the review picker, a quiet
semibold “Projects” caption, then repositories ordered by latest arrival.
a “Search projects” field styled like the file filter that filters current
streams in place (Escape clears it; matching repositories expand), a quiet
semibold “Projects” caption with a trailing compact ghost “Browse all reviews”
icon action that opens the review picker, then repositories ordered by latest arrival.
Repository rows match tree-row height with a chevron, repository icon, medium
name and muted monospace age. Stream rows indent under them: branch icon, name,
trailing monospace changed-file count, and a muted metadata line (worktree,
Expand Down Expand Up @@ -340,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 All @@ -363,16 +373,18 @@ such detail into a token. New layout spacing must use the scale.

### Overlays

- Project navigation uses a compact borderless ghost action after the brand
divider, with a semibold repository name, quieter branch, and repository
icon. Give the repository name priority when long context labels truncate.
- The header identifies the open review after the brand divider with static,
non-interactive text at control height: a semibold repository name, quieter
branch, and repository icon. Give the repository name priority when long context labels truncate.
Piped reviews use a terminal icon, “Piped”, and collection time; omit repository,
branch, worktree, and directory identity.
Use a search icon and shortcut hint, never a dropdown chevron. Click and
Cmd/Ctrl+K open the same searchable “Switch review” dialog. Position it at
Switching lives in the projects drawer; never add a dropdown chevron.
Cmd/Ctrl+K and the drawer's browse action open the searchable “Switch review”
dialog. Position it at
`--topbar-height` plus `--space-6` on desktop (64px default), `--space-4` on
mobile (16px default). Bound picker width to 35rem and viewport gutters. The
picker owns focus and stays available during context errors.
picker owns focus and stays available during context errors. Choosing a
review moves focus to the drawer's current stream.
- Search leads the picker; keep “Switch review” as its screen-reader title.
Label global search “Search reviews” and Piped history search “Search Piped”.
Use one integrated field (36px input) with close at its right edge and the
Expand Down Expand Up @@ -432,7 +444,7 @@ Do not add motion for decoration. New animation must honor
- Keep scope and desktop layout choice visible. View options and collapse/expand
all are adjacent icon buttons with action titles. Put wrap, inline detail,
code theme, and narrow layout choice in View options.
- Mobile retains the review trigger's repository/branch or Piped/time context; truncate long
- Mobile retains the review identity's repository/branch or Piped/time context; truncate long
labels and hide its shortcut hint. Hide the secondary heading/path and refresh
text, preserving accessible labels and theme/sidebar controls.
Keep the linked-worktree icon visible when its text label is hidden.
Expand Down
28 changes: 18 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ go install github.com/flexdinesh/diffx/cmd/diffx@main

## Usage

Capture the selected checkout once in one foreground process:
Capture the selected checkout in one foreground process:

```sh
diffx
Expand All @@ -45,9 +45,10 @@ Collection and ingestion run in-process. The same process serves the web UI,
REST and MCP until Ctrl-C. The path defaults to the current directory and resolves
to its containing checkout, including linked worktrees. Only that checkout is
collected. The browser opens the captured snapshot; later edits and branch switches
do not change it. Run the command again to capture a newer diff. Observations and
comments persist across restarts in the shared local database. Failed collection
never substitutes an empty diff.
do not change it until you explicitly refresh, which captures a new snapshot of
the same checkout. Observations and comments persist across restarts in the
shared local database; each process shows only its own checkout or piped diff.
Failed collection never substitutes an empty diff.

Only one local instance runs. Starting another prompts before replacing it;
scripts must pass `--replace`. Replacement requests authenticated graceful
Expand Down Expand Up @@ -256,12 +257,16 @@ the bundled plugins invoke the Go collector.
The Projects drawer lists repositories by recent activity, each with its current
streams: one per source, checkout, branch and comparison. Each stream shows its
newest snapshot only; clean or merged streams and superseded snapshots are hidden.
A dot marks streams with changes you have not opened in this browser. Collapse the
drawer from its header; it stays collapsed across visits. When a newer snapshot
A dot marks streams with changes you have not opened in this browser. Search
projects to filter current streams by repository, branch, worktree, host or agent.
Collapse the drawer from its header; it stays collapsed across visits. The header
names the open review's repository, branch and worktree. In a local checkout
process, **Refresh** (Alt+R) collects the checkout again and opens the new snapshot.
Collapsed files and folders are remembered across reloads in the same tab. When a newer snapshot
arrives for the stream you are reviewing, a notice offers **View latest** without
replacing the open review.

Search reviews (Ctrl/Cmd+K) to reach older or empty snapshots. Select a
Browse all reviews (Ctrl/Cmd+K) to reach older or empty snapshots. Select a
repository, then an observation. Search/filter stored metadata to find
a branch, worktree, host, source or agent run. Switch between its collected all,
staged and unstaged scopes. Use the file tree to navigate, mark files as reviewed
Expand All @@ -270,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 @@ -322,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: 1 addition & 1 deletion apps/web/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ A local review workspace that combines a browser-based Git diff with comments th

## Operating Context

- A developer runs `diffx` from a Git repository. The Go server opens the review UI in a browser.
- A developer runs `diffx` from a Git repository. The Go server opens the review UI in a browser, shows only that checkout, and collects it again when the developer refreshes.
- Reviews can cover all, staged, or unstaged changes. The file tree, reviewed-file marks, and comments help track progress.
- Comments can be copied as agent-ready XML. The same review comments are available to compatible coding agents through MCP and, in supported browsers, WebMCP.
- The CLI can also display a piped Git patch as a fixed review.
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>
);
}
Loading
Loading