Skip to content
28 changes: 21 additions & 7 deletions .demo/fake-sidecar/fixtures.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,11 @@ var headBlobs = map[string]string{
type fixture struct {
mu sync.Mutex
threads []github.Thread
reviewID string // the active pending-review node id; "" = none
viewed map[string]string // path -> VIEWED/UNVIEWED
seq int // monotonic id source
now time.Time // process-start clock: timestamps are stamped relative to it
comments []github.TimelineComment // the pr-level conversation, appended to by PostIssueComment
reviewID string // the active pending-review node id; "" = none
viewed map[string]string // path -> VIEWED/UNVIEWED
seq int // monotonic id source
now time.Time // process-start clock: timestamps are stamped relative to it
}

func newFixture() *fixture {
Expand All @@ -69,6 +70,11 @@ func newFixture() *fixture {
CreatedAt: f.ago(3 * time.Hour),
}},
}}
f.comments = []github.TimelineComment{{
Author: other,
Body: "thanks for tackling this, dracula's been on the wishlist for a while.",
CreatedAt: f.ago(5 * time.Hour),
}}
return f
}

Expand Down Expand Up @@ -161,10 +167,10 @@ func (f *fixture) GetThreads(_ context.Context, _, _ string, _ int) ([]github.Th
}

func (f *fixture) GetTimeline(_ context.Context, _, _ string, _ int) (*github.Timeline, error) {
f.mu.Lock()
defer f.mu.Unlock()
return &github.Timeline{
Comments: []github.TimelineComment{
{Author: other, Body: "thanks for tackling this, dracula's been on the wishlist for a while.", CreatedAt: f.ago(5 * time.Hour)},
},
Comments: append([]github.TimelineComment(nil), f.comments...),
Reviews: []github.ReviewSummary{
{Author: other, State: "COMMENTED", Body: "looks close. one inline question on the accent.", CreatedAt: f.ago(3 * time.Hour)},
},
Expand Down Expand Up @@ -268,6 +274,14 @@ func (f *fixture) PostComment(_ context.Context, _, _ string, _ int, in github.P
return &github.PostComment{ID: commentID, ThreadID: threadID, ReviewID: f.reviewID}, nil
}

func (f *fixture) PostIssueComment(_ context.Context, _, _ string, _ int, body string) (*github.PostIssueComment, error) {
f.mu.Lock()
defer f.mu.Unlock()
id := int64(9000 + f.nextID())
f.comments = append(f.comments, github.TimelineComment{Author: viewer, Body: body, CreatedAt: f.ago(0)})
return &github.PostIssueComment{ID: id}, nil
}

func (f *fixture) DeleteComment(_ context.Context, commentID string) error {
f.mu.Lock()
defer f.mu.Unlock()
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- The PR overview can now be commented on without leaving it. `ga` posts a comment on the pull request, and `gp`/`gq` answer what's under the cursor: into that code thread, or as a new PR comment where GitHub offers no threading. `gq` opens with the comment quoted, and in visual mode quotes just the selection
- `gc` on the PR overview shows or hides a thread's replies, which were previously only reachable by entering the review

### Fixed

- Long lines on the PR overview broke mid-word. They now wrap at a word, and a wrapped comment body stays inside its thread box
- A thread's footer counted replies that were already on screen, reading as though more were hidden. The count now appears only when the replies are collapsed
- `gc` in the PR diff opted a thread out of peeking for the rest of the session, with no way back to the cursor-driven default. It now dismisses or shows the thread for as long as the cursor stays on it, in the split layout too; `comments.display = "expanded"` keeps its toggle, having no peek to return to

## [0.1.42] — 2026-09-07

### Changed

- The PR overview's `g?` cheatsheet now correctly says that `r` starts a draft review, whilst `e` only enters the review
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ Set `command_alias` in `setup()` to register a shorter name for the same command

### PR review

`:Differ pr [<n>]` opens a pull request and lands on the overview, its home page; no argument lists the repo's PRs and picks from them. `owner/repo#<n>` targets another repo, which is how you reach a fork. From the overview, `e` enters the files and `r` enters and starts a review; `<CR>` on a thread jumps straight to that comment's file and line. The review keymaps (`ga` comment, `gp` reply, `gr` resolve, `<Tab>` viewed) are live once you're in the diff.
`:Differ pr [<n>]` opens a pull request and lands on the overview, its home page; no argument lists the repo's PRs and picks from them. `owner/repo#<n>` targets another repo, which is how you reach a fork. From the overview, `e` enters the files and `r` enters and starts a review; `<CR>` on a thread jumps straight to that comment's file and line. The page writes back too, without leaving it: `ga` comments on the PR, `gc` shows or hides a thread's replies, and `gp`/`gq` answer what's under the cursor. The cursor decides where the answer goes — into the code thread it sits in, or a new PR comment anywhere else, which is all GitHub offers off a thread — and the key decides whether it opens with a quote: `gp` doesn't, `gq` quotes the comment, and visual-mode `gq` quotes just the selection. The review keymaps (`ga` comment, `gp` reply, `gr` resolve, `<Tab>` viewed) are live once you're in the diff.

| Command | Effect |
|---|---|
Expand Down
10 changes: 8 additions & 2 deletions doc/differ.txt
Original file line number Diff line number Diff line change
Expand Up @@ -324,8 +324,14 @@ PR REVIEW *differ-usage-pr-review*
page; no argument lists the repo’s PRs and picks from them. `owner/repo#<n>`
targets another repo, which is how you reach a fork. From the overview, `e`
enters the files and `r` enters and starts a review; `<CR>` on a thread jumps
straight to that comment’s file and line. The review keymaps (`ga` comment,
`gp` reply, `gr` resolve, `<Tab>` viewed) are live once you’re in the diff.
straight to that comment’s file and line. The page writes back too, without
leaving it: `ga` comments on the PR, `gc` shows or hides a thread’s replies,
and `gp`/`gq` answer what’s under the cursor. The cursor decides where the
answer goes — into the code thread it sits in, or a new PR comment anywhere
else, which is all GitHub offers off a thread — and the key decides whether
it opens with a quote: `gp` doesn’t, `gq` quotes the comment, and visual-mode
`gq` quotes just the selection. The review keymaps (`ga` comment, `gp` reply,
`gr` resolve, `<Tab>` viewed) are live once you’re in the diff.

-----------------------------------------------------------------------
Command Effect
Expand Down
15 changes: 15 additions & 0 deletions internal/github/comments.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,21 @@ func (c *Client) postNewThread(ctx context.Context, owner, repo string, number i
return pc, nil
}

// PostIssueComment posts a PR-level conversation comment. github does not thread
// these, so a reply to one is just another comment; the caller supplies the quoting.
func (c *Client) PostIssueComment(ctx context.Context, owner, repo string, number int, body string) (*PostIssueComment, error) {
prID, err := c.prNodeID(ctx, owner, repo, number)
if err != nil {
return nil, err
}
var out addIssueCommentGQL
vars := map[string]any{"subjectId": prID, "body": body}
if err := c.graphql(ctx, addIssueCommentMutation, vars, &out); err != nil {
return nil, err
}
return &PostIssueComment{ID: parseID(out.AddComment.CommentEdge.Node.FullDatabaseID)}, nil
}

// DeleteComment removes a single review comment by its node id (draft or published).
func (c *Client) DeleteComment(ctx context.Context, commentID string) error {
if err := c.graphql(ctx, deleteCommentMutation, map[string]any{"id": commentID}, nil); err != nil {
Expand Down
12 changes: 12 additions & 0 deletions internal/github/dtos.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,18 @@ type addReplyGQL struct {
} `json:"addPullRequestReviewThreadReply"`
}

// addIssueCommentGQL is the addComment response: the new conversation comment's
// numeric id.
type addIssueCommentGQL struct {
AddComment struct {
CommentEdge struct {
Node struct {
FullDatabaseID string `json:"fullDatabaseId"`
} `json:"node"`
} `json:"commentEdge"`
} `json:"addComment"`
}

// resolveThreadGQL is the (un)resolveReviewThread response; the mutation field is
// aliased to result so resolve and unresolve share one shape.
type resolveThreadGQL struct {
Expand Down
10 changes: 10 additions & 0 deletions internal/github/mutations.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ mutation AddThreadReply($threadId: ID!, $reviewId: ID, $body: String!) {
}
}`

// addIssueCommentMutation posts a PR-level conversation comment. the subject is the PR
// node id; github has no threading on these, so a "reply" is another comment like any
// other.
const addIssueCommentMutation = `
mutation AddIssueComment($subjectId: ID!, $body: String!) {
addComment(input: {subjectId: $subjectId, body: $body}) {
commentEdge { node { fullDatabaseId } }
}
}`

// resolveThreadMutation / unresolveThreadMutation toggle a thread's resolved state.
// the mutation field is aliased to result so both decode into one shape.
const resolveThreadMutation = `
Expand Down
6 changes: 6 additions & 0 deletions internal/github/results.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@ type PostComment struct {
ReviewID string `json:"review_id,omitempty"`
}

// PostIssueComment is the post_issue_comment result: the new conversation comment's
// numeric id. github does not thread these, so there is no thread to report.
type PostIssueComment struct {
ID int64 `json:"id"`
}

// ResolveThread is the resolve_thread result: the thread's resolved state after the
// toggle, echoed from GitHub.
type ResolveThread struct {
Expand Down
1 change: 1 addition & 0 deletions internal/github/schema_validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ var mutationConstants = []struct {
{"addThreadMutation", addThreadMutation},
{"deleteCommentMutation", deleteCommentMutation},
{"addThreadReplyMutation", addThreadReplyMutation},
{"addIssueCommentMutation", addIssueCommentMutation},
{"resolveThreadMutation", resolveThreadMutation},
{"unresolveThreadMutation", unresolveThreadMutation},
{"markFileViewedMutation", markFileViewedMutation},
Expand Down
2 changes: 2 additions & 0 deletions internal/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type API interface {
SubmitReview(ctx context.Context, reviewID, event, body string) (*github.SubmitReview, error)
DiscardReview(ctx context.Context, reviewID string) error
PostComment(ctx context.Context, owner, repo string, number int, in github.PostCommentInput) (*github.PostComment, error)
PostIssueComment(ctx context.Context, owner, repo string, number int, body string) (*github.PostIssueComment, error)
DeleteComment(ctx context.Context, commentID string) error
ResolveThread(ctx context.Context, threadID string, resolved bool) (*github.ResolveThread, error)
SetFileViewed(ctx context.Context, owner, repo string, number int, path string, viewed bool) (*github.SetFileViewed, error)
Expand Down Expand Up @@ -64,6 +65,7 @@ func NewRegistry(d Deps) Registry {
"submit_review": d.submitReview,
"discard_review": d.discardReview,
"post_comment": d.postComment,
"post_issue_comment": d.postIssueComment,
"delete_comment": d.deleteComment,
"resolve_thread": d.resolveThread,
"set_file_viewed": d.setFileViewed,
Expand Down
50 changes: 50 additions & 0 deletions internal/handlers/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type mockAPI struct {
gotReviewID string
gotEvent string
gotComment github.PostCommentInput
gotBody string
gotThreadID string
gotCommentID string
gotResolved bool
Expand Down Expand Up @@ -121,6 +122,13 @@ func (m *mockAPI) PostComment(_ context.Context, _, _ string, number int, in git
return &github.PostComment{ID: 555, ThreadID: "PRT_1"}, nil
}

func (m *mockAPI) PostIssueComment(_ context.Context, _, _ string, number int, body string) (*github.PostIssueComment, error) {
m.called = true
m.gotNumber = number
m.gotBody = body
return &github.PostIssueComment{ID: 777}, nil
}

func (m *mockAPI) DeleteComment(_ context.Context, commentID string) error {
m.called = true
m.gotCommentID = commentID
Expand Down Expand Up @@ -387,6 +395,48 @@ func TestPostCommentReplyRoutes(t *testing.T) {
}
}

func TestPostIssueCommentRoutes(t *testing.T) {
m := &mockAPI{headSHA: "abc123"}
res, err := deps(m).postIssueComment(context.Background(), json.RawMessage(
`{"owner":"o","repo":"r","number":3,"body":"ship it"}`,
))
if err != nil {
t.Fatal(err)
}
if m.gotNumber != 3 || m.gotBody != "ship it" {
t.Errorf("params not forwarded: number=%d body=%q", m.gotNumber, m.gotBody)
}
if pc := res.(*github.PostIssueComment); pc.ID != 777 {
t.Errorf("result not forwarded: %+v", pc)
}
// no diff anchor, so nothing for a moved head to invalidate
if m.headCalled {
t.Error("a conversation comment must not pay for the head round-trip")
}
}

func TestPostIssueCommentRequiresBody(t *testing.T) {
m := &mockAPI{}
_, err := deps(m).postIssueComment(context.Background(), json.RawMessage(
`{"owner":"o","repo":"r","number":3}`,
))
wantBadRequest(t, err)
if m.called {
t.Error("GH must not be called without a body")
}
}

func TestPostIssueCommentRequiresPR(t *testing.T) {
m := &mockAPI{}
_, err := deps(m).postIssueComment(context.Background(), json.RawMessage(
`{"owner":"o","repo":"r","body":"ship it"}`,
))
wantBadRequest(t, err)
if m.called {
t.Error("GH must not be called without pr coords")
}
}

// ── the TOCTOU guard ────────────────────────────────────────────────────────────
// an anchored mutation pins expected_head; the handler resolves the live head and
// rejects with conflict when it moved. an unpinned mutation skips the round-trip.
Expand Down
21 changes: 21 additions & 0 deletions internal/handlers/mutate_comments.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,27 @@ func (d Deps) postComment(ctx context.Context, params json.RawMessage) (any, err
})
}

type postIssueCommentParams struct {
prParams
Body string `json:"body"`
}

// postIssueComment posts a PR-level conversation comment. it carries no diff anchor, so
// neither the anchor validation nor the head guard applies.
func (d Deps) postIssueComment(ctx context.Context, params json.RawMessage) (any, error) {
var p postIssueCommentParams
if err := decode(params, &p); err != nil {
return nil, err
}
if err := requirePR(p.Owner, p.Repo, p.Number); err != nil {
return nil, err
}
if p.Body == "" {
return nil, protocol.NewError(protocol.CodeBadRequest, "body is required")
}
return d.GH.PostIssueComment(ctx, p.Owner, p.Repo, p.Number, p.Body)
}

type deleteCommentParams struct {
prParams
CommentID string `json:"comment_id"` // the comment's graphql node id
Expand Down
10 changes: 10 additions & 0 deletions lua/differ/pr/client.lua
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,16 @@ function M.post_comment(pr, args, cb)
sidecar.request("post_comment", with_pr(pr, args), cb)
end

-- post_issue_comment result: {id}. a PR-level conversation comment, posted
-- immediately: it carries no diff anchor, so it never joins a review draft and there is
-- no head to guard against
---@param pr { owner: string, repo: string, number: integer }
---@param body string
---@param cb fun(err: table|nil, result: any)
function M.post_issue_comment(pr, body, cb)
sidecar.request("post_issue_comment", with_pr(pr, { body = body }), cb)
end

-- submit_review result: {id}. finalises the draft as one batch with an event. args:
-- review_id/event (APPROVE|REQUEST_CHANGES|COMMENT)/body, expected_head?
---@param pr { owner: string, repo: string, number: integer }
Expand Down
7 changes: 4 additions & 3 deletions lua/differ/pr/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -367,9 +367,10 @@ local function cursor_anchor()
return require("differ.pr.threads").anchor_at(session, buf, row)
end

-- gc: collapse/expand the thread group under the cursor. an explicit toggle
-- overrides the cursor-peek default until toggled back; re-apply swaps the boxes in
-- place (stacked) or shows/hides the float (split), and a no-op off a thread row
-- gc: collapse/expand the thread group under the cursor. the toggle overrides the
-- default until toggled back, or until the cursor leaves the anchor where the default
-- is the cursor's to make; re-apply swaps the boxes in place (stacked) or shows/hides
-- the float (split), and a no-op off a thread row
function M.toggle_thread()
local s = session
local anchor = cursor_anchor()
Expand Down
Loading
Loading