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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Review comments, handed to the session as one prompt.** A diff could give
the session a *reference* — a file, a line range — but never an instruction,
so anything you actually wanted changed had to be typed out by hand while
looking straight at it. Right-clicking a selection, in the Review dock or in a
pull request's Files changed, now also offers *Comment on lines*: a box opens
between the lines themselves, and the note is held against them instead of
being written out. Comments collect at the foot of the panel in the order you
read the diff, each removable on its own,
and one *Send* hands the whole batch over as a single prompt — pasted into the
session unsent, so the last word before the agent starts is still yours. They
outlive leaving the tab but not a restart: a line number written before an
edit points somewhere else after it.
- **Every worktree gets a dev-server port of its own.** Two worktrees of one
project both ran `pnpm dev` and both wanted the same port, so the second one
lost — and the setup script that had just installed the dependencies had no
Expand Down
88 changes: 88 additions & 0 deletions frontend/src/components/diff/CommentBatch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Send, X } from "lucide-react"
import { useSyncExternalStore } from "react"
import { toast } from "sonner"
import { IconAction } from "@/components/common/IconAction"
import { Button } from "@/components/ui/button"
import { splitPath } from "@/lib/git/lang-badge"
import {
clearReviewComments,
composeReviewComments,
removeReviewComment,
reviewComments,
subscribeReviewComments,
} from "@/lib/review-comments"

interface CommentBatchProps {
/** What is under review: the checkout's path, or the pull request's URL. */
target: string
/** Write into the session's terminal; false when no session took it. */
onInject: (text: string) => boolean
}

// CommentBatch is the strip at the foot of a review panel: the comments written
// so far, and the one action that hands them all to the session as a single
// prompt. Nothing at all until the first comment is written.
export function CommentBatch({ target, onInject }: CommentBatchProps) {
const comments = useSyncExternalStore(subscribeReviewComments, () => reviewComments(target))

if (comments.length === 0) {
return null
}

// Comments are the one thing here the user typed, so they survive a failed
// send: only a write the session took clears them.
const send = () => {
if (!onInject(composeReviewComments(comments))) {
toast.error("Open a session to send these comments to")
return
}
clearReviewComments(target)
}

return (
<div className="flex-none border-t border-border">
<ul className="max-h-40 overflow-y-auto p-1">
{comments.map((comment) => (
<li
key={comment.id}
className="flex items-center gap-2 rounded-md px-1.5 py-1 text-xs hover:bg-accent/50"
>
<span className="shrink-0 font-mono text-muted-foreground" title={comment.path}>
{splitPath(comment.path).base}:{comment.lines}
</span>
<span className="truncate" title={comment.text}>
{comment.text}
</span>
<span className="ml-auto">
<IconAction
label="Remove comment"
onClick={() => removeReviewComment(target, comment.id)}
>
<X className="size-3.5" />
</IconAction>
</span>
</li>
))}
</ul>
<div className="flex items-center gap-2 px-2 pt-1 pb-2">
<span className="text-xs text-muted-foreground tabular-nums">
{comments.length} {comments.length === 1 ? "comment" : "comments"}
</span>
<span className="ml-auto flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => clearReviewComments(target)}>
Clear
</Button>
<Button
variant="ghost"
size="sm"
onClick={send}
className="bg-accent/55 text-foreground hover:bg-accent"
>
<Send />
Send
</Button>
</span>
</div>
</div>
)
}
42 changes: 33 additions & 9 deletions frontend/src/components/diff/FileDiff.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ const MOUNT_MARGIN = "600px 0px"
interface FileDiffProps {
file: DiffFile
onInject: (text: string) => void
/** Hold a comment on these lines for the panel's batch. Absent = no comment
* action, the shape every surface had before review comments existed. */
onComment?: (path: string, lines: string, text: string) => void
/** Ask the panel to confirm and revert this file's changes. Omitted for a
* read-only diff (a PR's changes), where discarding makes no sense. */
onDiscard?: () => void
Expand All @@ -46,7 +49,15 @@ interface FileDiffProps {

// The card must not clip overflow — a clipping ancestor would break the
// sticky header.
export function FileDiff({ file, onInject, onDiscard, bulk, viewed, onViewed }: FileDiffProps) {
export function FileDiff({
file,
onInject,
onComment,
onDiscard,
bulk,
viewed,
onViewed,
}: FileDiffProps) {
const doc = useMemo(() => buildFileDoc(file), [file])
const [expanded, setExpanded] = useState(!file.binary && doc.lineMeta.length <= LARGE_FILE_LINES)
// The nonce guard skips the initial mount so each file keeps its own
Expand Down Expand Up @@ -127,7 +138,7 @@ export function FileDiff({ file, onInject, onDiscard, bulk, viewed, onViewed }:
(file.binary ? (
<p className="px-9 py-2 text-xs text-muted-foreground">Binary file</p>
) : (
<LazyDiffBody doc={doc} path={file.newPath} onInject={onInject} />
<LazyDiffBody doc={doc} path={file.newPath} onInject={onInject} onComment={onComment} />
))}
</section>
)
Expand All @@ -137,14 +148,15 @@ interface DiffBodyProps {
doc: FileDoc
path: string
onInject: (text: string) => void
onComment?: (path: string, lines: string, text: string) => void
}

// A panel holds one CodeMirror view per expanded file, and a wide diff (a PR
// touching 191 files) built them all in a single commit — enough to lock the
// page. LazyDiffBody defers each one until its card comes near the viewport;
// once built, the editor stays, so scrolling back keeps its selection and
// highlighting. Collapsing the file still destroys it.
function LazyDiffBody({ doc, path, onInject }: DiffBodyProps) {
function LazyDiffBody({ doc, path, onInject, onComment }: DiffBodyProps) {
const placeholder = useRef<HTMLDivElement>(null)
const [mounted, setMounted] = useState(false)

Expand All @@ -166,7 +178,7 @@ function LazyDiffBody({ doc, path, onInject }: DiffBodyProps) {
}, [])

if (mounted) {
return <DiffBody doc={doc} path={path} onInject={onInject} />
return <DiffBody doc={doc} path={path} onInject={onInject} onComment={onComment} />
}
// The placeholder has to stand in for the file's height: zero-height cards
// would all sit in the viewport at once and nothing would be deferred.
Expand All @@ -178,25 +190,37 @@ function LazyDiffBody({ doc, path, onInject }: DiffBodyProps) {
//
// A diff's document is not the file: the selection lands on doc lines, which
// newLineRange maps back to the line numbers the agent needs to be told.
function DiffBody({ doc, path, onInject }: DiffBodyProps) {
const { containerRef, getSelectedDocLines } = useDiffEditor(doc, path)
const [lineRef, setLineRef] = useState<string | null>(null)
function DiffBody({ doc, path, onInject, onComment }: DiffBodyProps) {
const { containerRef, getSelectedDocLines, openComment } = useDiffEditor(
doc,
path,
onComment && ((lines, text) => onComment(path, lines, text)),
)
// Both halves of the resolved selection: the file lines an inject or a
// comment is filed against, and the doc line the composer hangs under.
const [selection, setSelection] = useState<{ lines: string; docLine: number } | null>(null)

return (
<InjectMenu
path={path}
containerRef={containerRef}
lineRef={lineRef}
lineRef={selection?.lines ?? null}
// Resolve the selection when the menu opens, not on every change.
onOpenChange={(open) => {
if (!open) {
return
}
const selected = getSelectedDocLines()
const range = selected ? newLineRange(doc.lineMeta, selected.from, selected.to) : null
setLineRef(range && formatLineRef(range))
setSelection(
range && selected ? { lines: formatLineRef(range), docLine: selected.to } : null,
)
}}
onInject={onInject}
onComment={
onComment &&
(() => selection && openComment({ line: selection.docLine, lines: selection.lines }))
}
/>
)
}
16 changes: 15 additions & 1 deletion frontend/src/components/diff/InjectMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@ interface InjectMenuProps {
/** Resolve the selection; fired as the menu opens, not on every change. */
onOpenChange: (open: boolean) => void
onInject: (text: string) => void
/** Start a comment on the selected lines. Absent on a surface that is not a
* review — the dock's file preview — which offers the references only. */
onComment?: (lines: string) => void
}

// The right-click menu over a code view — a file's diff, or the read-only
// preview in the dock — that writes a reference into the session's terminal.
// The whole file, or just the lines under the selection.
// The whole file, or just the lines under the selection. On a review surface it
// also opens a comment on those lines, which is held for the batch rather than
// written out on its own.
//
// The isolate wrapper keeps CodeMirror's high-z-index gutter from painting over
// the sticky header of the card above it.
Expand All @@ -30,6 +35,7 @@ export function InjectMenu({
lineRef,
onOpenChange,
onInject,
onComment,
}: InjectMenuProps) {
return (
<ContextMenu onOpenChange={onOpenChange}>
Expand All @@ -42,6 +48,14 @@ export function InjectMenu({
>
{lineRef === null ? "Inject lines" : `Inject lines ${lineRef}`}
</ContextMenuItem>
{onComment && (
<ContextMenuItem
disabled={lineRef === null}
onClick={() => lineRef && onComment(lineRef)}
>
{lineRef === null ? "Comment on lines…" : `Comment on lines ${lineRef}…`}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
)
Expand Down
29 changes: 19 additions & 10 deletions frontend/src/components/diff/ReviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@ import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { Notice } from "@/components/common/Notice"
import { discardTargets, parseDiff, type DiffFile } from "@/lib/git/diff"
import { addReviewComment } from "@/lib/review-comments"
import { ProjectService } from "@/lib/rpc"
import { useActiveSession } from "@/lib/session/use-active-session"
import { useGitStatus } from "@/lib/git/use-git-status"
import { useInject } from "@/lib/use-inject"
import { errorText } from "@/lib/utils"
import { CommentBatch } from "./CommentBatch"
import type { DiffBulk } from "./diff-bulk"
import { DiscardDialog } from "./DiscardDialog"
import { FileDiff } from "./FileDiff"

// ReviewPanel is the Review tab's body: the active session's uncommitted diff,
// one collapsible file at a time. Context-menu actions write file/line
// references into the session's PTY, mirroring the footer's attach-file button.
// references into the session's PTY, mirroring the footer's attach-file button,
// or hold a comment on the selection for the batch at the panel's foot.
// It follows the active session — a worktree session reviews its checkout, not
// the project root. The dock (RightDock) owns the surrounding chrome: width,
// full screen, the tab bar and the close button.
Expand Down Expand Up @@ -65,14 +68,18 @@ export function ReviewPanel({ bulk }: { bulk: DiffBulk }) {
}

return (
<div className="h-full overflow-y-auto">
<PanelBody
files={files}
failed={failed}
onInject={inject}
onDiscard={setPendingDiscard}
bulk={bulk}
/>
<div className="flex h-full flex-col">
<div className="flex-1 overflow-y-auto">
<PanelBody
files={files}
failed={failed}
onInject={inject}
onComment={(file, lines, text) => addReviewComment(path, file, lines, text)}
onDiscard={setPendingDiscard}
bulk={bulk}
/>
</div>
<CommentBatch target={path} onInject={inject} />
<DiscardDialog
file={pendingDiscard}
onCancel={() => setPendingDiscard(null)}
Expand All @@ -86,11 +93,12 @@ interface PanelBodyProps {
files: DiffFile[] | null
failed: boolean
onInject: (text: string) => void
onComment: (path: string, lines: string, text: string) => void
onDiscard: (file: DiffFile) => void
bulk: DiffBulk
}

function PanelBody({ files, failed, onInject, onDiscard, bulk }: PanelBodyProps) {
function PanelBody({ files, failed, onInject, onComment, onDiscard, bulk }: PanelBodyProps) {
if (failed) {
return <Notice>Not a git repository</Notice>
}
Expand All @@ -107,6 +115,7 @@ function PanelBody({ files, failed, onInject, onDiscard, bulk }: PanelBodyProps)
key={file.newPath}
file={file}
onInject={onInject}
onComment={onComment}
onDiscard={() => onDiscard(file)}
bulk={bulk}
/>
Expand Down
41 changes: 32 additions & 9 deletions frontend/src/components/diff/useDiffEditor.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,51 @@
import { useMemo } from "react"
import { useMemo, useRef } from "react"
import type { RefObject } from "react"
import { buildLineDecorations, diffGutter, type DocLineSelection } from "@/lib/codemirror"
import { commentComposer, setCommentDraft, type CommentDraft } from "@/lib/codemirror-comment"
import { useCodeMirrorView } from "@/lib/use-codemirror-view"
import type { FileDoc } from "@/lib/git/diff"

export interface DiffEditor {
containerRef: RefObject<HTMLDivElement>
/** 1-based doc line span of the current selection, or null when empty. */
getSelectedDocLines: () => DocLineSelection | null
/** Open the inline comment composer under a draft's line. */
openComment: (draft: CommentDraft) => void
}

// useDiffEditor is the diff card's read-only CodeMirror view: the shared editor
// plus the two extensions that make it a diff — the +/- gutter and the per-line
// background. Its selection lands on *doc* lines, which are the diff's own
// interleaving of added and deleted, not the file's numbering; mapping them
// back is newLineRange's job at the call site.
export function useDiffEditor(doc: FileDoc, filename: string): DiffEditor {
// plus the extensions that make it a diff — the +/- gutter, the per-line
// background, and the comment composer that opens between the lines.
//
// Its selection lands on *doc* lines, which are the diff's own interleaving of
// added and deleted, not the file's numbering; mapping them back is
// newLineRange's job at the call site.
export function useDiffEditor(
doc: FileDoc,
filename: string,
onComment?: (lines: string, text: string) => void,
): DiffEditor {
// The extensions are built once per document, so the composer cannot capture
// this render's handler; it reads the current one off the ref instead.
const submit = useRef(onComment)
submit.current = onComment

const source = useMemo(
() => ({
text: doc.text,
extensions: [diffGutter(doc.lineMeta), buildLineDecorations(doc.lineMeta)],
extensions: [
diffGutter(doc.lineMeta),
buildLineDecorations(doc.lineMeta),
commentComposer((lines, text) => submit.current?.(lines, text)),
],
}),
[doc],
)
const { containerRef, getSelectedLines } = useCodeMirrorView(source, filename)
return { containerRef, getSelectedDocLines: getSelectedLines }
const { containerRef, getSelectedLines, dispatchEffects } = useCodeMirrorView(source, filename)

return {
containerRef,
getSelectedDocLines: getSelectedLines,
openComment: (draft) => dispatchEffects([setCommentDraft.of(draft)]),
}
}
3 changes: 2 additions & 1 deletion frontend/src/components/pulls/PullRequestView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ interface PullRequestViewProps {
onRefresh: () => void
/** The merge landed; the screen decides what follows (a toast, the worktree). */
onMerged: () => void
onInject: (text: string) => void
/** Write into the session's terminal; false when no session took it. */
onInject: (text: string) => boolean
}

// PullRequestView is one pull request in full: the header (title, the actions
Expand Down
Loading