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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,10 @@ keyboard alone, and the mouse stays first-class.
Markdown descriptions and avatar-led timeline conversations, compose
top-level comments with formatting, preview, and hosted screenshot links, inspect
lazily loaded code changes in the Local Changes-style Pierre file tree,
switch stacked/split layout in place, and add stale-head-guarded GitHub
comments to selected line ranges. A compact readiness ledger combines review,
switch stacked/split layout in place, jump from timeline comments to their
file/thread, read GitHub review threads with replies directly beneath their code,
and add stale-head-guarded comments to selected
line ranges. A compact readiness ledger combines review,
checks, conflicts, merge state, and provider freshness before merging with
merge-commit, squash, or rebase through a GitHub-style split control.
Authentication stays in the signed-in `gh` / `az` CLI; provider policies
Expand Down
13 changes: 11 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1600,12 +1600,21 @@ false green. Focused Vitest coverage locks the provider-state normalization.

**Hosted PR inline review kick (2026-07-13):** Changes now carries its own
persisted stacked/split controls and exposes Pierre's controlled line-range
selection through Strand's diff boundary. Open GitHub PRs get an annotation-row
composer that publishes a real review comment against old/new blob coordinates;
selection and native hover-gutter `+` through Strand's diff boundary. Open
GitHub PRs get a compact annotation-row composer immediately beneath the target
code that publishes a real review comment against old/new blob coordinates;
`repo_pull_request_inline_comment` rechecks the exact head before the write so a
stale diff cannot silently misplace feedback. Azure stays explicitly disabled
until its required iteration/change-tracking coordinates join the patch model.

**Hosted PR thread reading (2026-07-13):** GitHub detail now loads its review
thread connection separately from issue comments, preserving file/range,
old/new side, reply grouping, and resolved/outdated state. Those comments join
Conversation and render as persistent Pierre annotation cards beneath their
code in Changes, so a refresh reflects comments created in Strand or on GitHub.
Conversation now links file-backed comments straight to that file/thread in
Changes and transfers keyboard focus to the destination.

---

## 1.1+ — Post-1.0
Expand Down
28 changes: 18 additions & 10 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1288,17 +1288,22 @@ tree: watch the agent work, review fast, accept or reject safely.
persisted stacked/split controls in context. Azure comparisons
fetch source/target objects without updating repository refs or FETCH_HEAD.
- ◐ Discussion threads and comment creation: Conversation reads GitHub
comments and Azure thread comments (including inline file context) as safe
issue comments, GitHub review-thread comments, and Azure thread comments
(including inline file context) as safe
Markdown and creates top-level Markdown comments through the signed-in
provider CLI. The `PullRequestConversation` timeline now includes a
keyboard-operable Write/Preview composer, Markdown formatting toolbar,
hosted screenshot/image insertion, explicit click-to-load image previews,
character count, provider avatars with initials fallback, and comment
permalinks. GitHub Changes now supports Pierre line-range selection and an
anchored inline composer through `repo_pull_request_inline_comment`, with
exact-head validation before publishing. Replies, Azure iteration-tracked
inline comments, direct binary attachment uploads, suggestions, and thread
resolution remain.
permalinks. File-backed timeline comments expose a keyboard-operable
**View in changes** action that selects the file and focuses its fetched
GitHub thread when coordinates exist. GitHub Changes now uses Pierre's native hover-gutter `+`,
line-range selection, persistent fetched thread cards with replies and
resolved/outdated state, and an annotation-row composer through
`repo_pull_request_inline_comment`, with
exact-head validation before publishing. Reply creation, Azure
iteration-tracked inline comments, direct binary attachment uploads,
suggestions, and thread resolution remain.
- ☐ Submit reviews: approve, request changes, dismiss/update a review where supported.
- ◐ PR review ledger + merge-readiness model (see
`docs/pull-request-improvements.md`).
Expand All @@ -1308,13 +1313,16 @@ tree: watch the agent work, review fast, accept or reject safely.
explicitly incomplete instead of appearing ready.
- ☐ Add viewed-file progress and unresolved-thread counts when those review
state models land.
- ◐ Inline review workspace: GitHub line-range selection and immediate
publishing are present (`ParsedDiff` controlled selection + inline
annotation composer); provider-neutral line/file thread reading, Azure
iteration coordinates, replies, resolved/outdated state, local
- ◐ Inline review workspace: GitHub hover-gutter line/range selection,
immediate publishing, and fetched review-thread annotations are present
(`ParsedDiff` controlled selection + native gutter utility + inline
composer/thread cards); Azure iteration coordinates, reply/resolution
writes, local
content-hash-keyed viewed files, unviewed/thread filters, batched drafts,
and keyboard next-thread navigation remain while retaining one mounted
Pierre diff.
- ☐ Paginate GitHub review threads and replies beyond the current bounded
100-thread / 100-comment detail query.
- ☐ Batched review submission: pending comments plus Comment / Approve /
Request changes, summary preview, exact-head stale guard, and draft
preservation when a provider write fails.
Expand Down
1 change: 1 addition & 0 deletions crates/strand-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ pub async fn repo_pull_request_comment(path: String, id: u64, body: String) -> C
/// Add a provider review thread anchored to an exact file line range.
/// `expected_head` prevents a delayed editor from commenting on a newer diff.
#[tauri::command(async)]
#[allow(clippy::too_many_arguments)]
pub async fn repo_pull_request_inline_comment(
path: String,
id: u64,
Expand Down
176 changes: 172 additions & 4 deletions crates/strand-tauri/src/pull_requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,28 @@ const GITHUB_DETAIL_FIELDS: &str = concat!(
"url,body,mergeStateStatus,reviewDecision,comments,commits,additions,deletions,",
"changedFiles,reviewRequests,latestReviews,labels,statusCheckRollup,headRefOid"
);
const GITHUB_REVIEW_THREADS_QUERY: &str = r#"query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
reviewThreads(first: 100) {
nodes {
id
isResolved
isOutdated
path
line
startLine
originalLine
originalStartLine
diffSide
comments(first: 100) {
nodes { id body createdAt url author { login avatarUrl } }
}
}
}
}
}
}"#;
type Result<T> = std::result::Result<T, String>;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
Expand Down Expand Up @@ -79,6 +101,18 @@ pub struct PullRequestComment {
pub path: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct PullRequestReviewThread {
pub id: String,
pub path: String,
pub start_line: u32,
pub end_line: u32,
pub side: PullRequestDiffSide,
pub is_resolved: bool,
pub is_outdated: bool,
pub comments: Vec<PullRequestComment>,
}

#[derive(Debug, Clone, Serialize)]
pub struct PullRequest {
pub id: u64,
Expand All @@ -104,6 +138,7 @@ pub struct PullRequest {
pub reviewers: Vec<PullRequestReviewer>,
pub checks: Vec<PullRequestCheck>,
pub comments: Vec<PullRequestComment>,
pub review_threads: Vec<PullRequestReviewThread>,
}

#[derive(Debug, Clone, Serialize)]
Expand All @@ -112,7 +147,7 @@ pub struct PullRequestList {
pub pull_requests: Vec<PullRequest>,
}

#[derive(Debug, Clone, Copy, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PullRequestDiffSide {
Deletions,
Expand Down Expand Up @@ -293,14 +328,14 @@ fn list_github(cwd: &str, remote: String, owner: String, repo: String) -> Result

fn detail_github(cwd: &str, owner: String, repo: String, id: u64) -> Result<PullRequest> {
let slug = format!("{owner}/{repo}");
let id = id.to_string();
let id_string = id.to_string();
let output = run_command(
cwd,
"gh",
&[
"pr",
"view",
&id,
&id_string,
"--repo",
&slug,
"--json",
Expand All @@ -310,7 +345,41 @@ fn detail_github(cwd: &str, owner: String, repo: String, id: u64) -> Result<Pull
)?;
let value: Value = serde_json::from_slice(&output)
.map_err(|error| format!("GitHub CLI returned invalid JSON: {error}"))?;
parse_github_pr(&value).ok_or_else(|| format!("GitHub CLI returned no data for PR #{id}"))
let mut pull_request = parse_github_pr(&value)
.ok_or_else(|| format!("GitHub CLI returned no data for PR #{id}"))?;
let review_threads = github_review_threads(cwd, &owner, &repo, id)?;
pull_request.comments.extend(
review_threads
.iter()
.flat_map(|thread| thread.comments.iter().cloned()),
);
pull_request
.comments
.sort_by(|left, right| left.created_at.cmp(&right.created_at));
pull_request.comment_count = pull_request.comments.len();
pull_request.review_threads = review_threads;
Ok(pull_request)
}

fn github_review_threads(
cwd: &str,
owner: &str,
repo: &str,
id: u64,
) -> Result<Vec<PullRequestReviewThread>> {
let query = format!("query={GITHUB_REVIEW_THREADS_QUERY}");
let owner = format!("owner={owner}");
let repo = format!("repo={repo}");
let number = format!("number={id}");
let output = run_command(
cwd,
"gh",
&["api", "graphql", "-f", &query, "-F", &owner, "-F", &repo, "-F", &number],
&[("GH_PROMPT_DISABLED", "1")],
)?;
let value: Value = serde_json::from_slice(&output)
.map_err(|error| format!("GitHub CLI returned invalid review-thread JSON: {error}"))?;
Ok(parse_github_review_threads(&value))
}

fn diff_github(cwd: &str, owner: String, repo: String, id: u64) -> Result<String> {
Expand Down Expand Up @@ -971,9 +1040,78 @@ fn parse_github_pr(value: &Value) -> Option<PullRequest> {
reviewers,
checks,
comments,
review_threads: Vec::new(),
})
}

fn parse_github_review_threads(value: &Value) -> Vec<PullRequestReviewThread> {
value
.pointer("/data/repository/pullRequest/reviewThreads/nodes")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[])
.iter()
.filter_map(|thread| {
let path = text(thread.get("path"))?;
let end_line = thread
.get("line")
.and_then(Value::as_u64)
.or_else(|| thread.get("originalLine").and_then(Value::as_u64))? as u32;
let start_line = thread
.get("startLine")
.and_then(Value::as_u64)
.or_else(|| thread.get("originalStartLine").and_then(Value::as_u64))
.unwrap_or(u64::from(end_line)) as u32;
let side = match text(thread.get("diffSide"))?.as_str() {
"LEFT" => PullRequestDiffSide::Deletions,
"RIGHT" => PullRequestDiffSide::Additions,
_ => return None,
};
let comments = thread
.pointer("/comments/nodes")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[])
.iter()
.filter_map(|comment| {
let author = text(comment.pointer("/author/login"))
.unwrap_or_else(|| "unknown".into());
Some(PullRequestComment {
id: text(comment.get("id"))?,
avatar_url: text(comment.pointer("/author/avatarUrl"))
.or_else(|| github_avatar_url(&author)),
author,
body: text(comment.get("body")).unwrap_or_default(),
created_at: text(comment.get("createdAt")).unwrap_or_default(),
url: text(comment.get("url")).unwrap_or_default(),
is_system: false,
path: Some(path.clone()),
})
})
.collect::<Vec<_>>();
if comments.is_empty() {
return None;
}
Some(PullRequestReviewThread {
id: text(thread.get("id"))?,
path,
start_line,
end_line,
side,
is_resolved: thread
.get("isResolved")
.and_then(Value::as_bool)
.unwrap_or(false),
is_outdated: thread
.get("isOutdated")
.and_then(Value::as_bool)
.unwrap_or(false),
comments,
})
})
.collect()
}

fn parse_azure_pr(
value: &Value,
organization: &str,
Expand Down Expand Up @@ -1049,6 +1187,7 @@ fn parse_azure_pr(
reviewers,
checks: Vec::new(),
comments: Vec::new(),
review_threads: Vec::new(),
})
}

Expand Down Expand Up @@ -1376,6 +1515,35 @@ mod tests {
assert!(comments[1].is_system);
}

#[test]
fn normalizes_github_review_threads_with_replies_and_ranges() {
let value = serde_json::json!({
"data": { "repository": { "pullRequest": { "reviewThreads": { "nodes": [{
"id": "PRRT_1", "isResolved": false, "isOutdated": false,
"path": "src/lib.rs", "line": 29, "startLine": 27,
"originalLine": 29, "originalStartLine": 27, "diffSide": "RIGHT",
"comments": { "nodes": [
{ "id": "PRRC_1", "body": "Please validate this.",
"createdAt": "2026-07-13T12:00:00Z", "url": "https://github.com/acme/repo/pull/42#discussion_r1",
"author": { "login": "octo", "avatarUrl": "https://avatars.example/octo" } },
{ "id": "PRRC_2", "body": "Fixed.",
"createdAt": "2026-07-13T12:05:00Z", "url": "https://github.com/acme/repo/pull/42#discussion_r2",
"author": { "login": "ada", "avatarUrl": "https://avatars.example/ada" } }
] }
}] } } } }
});

let threads = parse_github_review_threads(&value);
assert_eq!(threads.len(), 1);
assert_eq!(threads[0].path, "src/lib.rs");
assert_eq!((threads[0].start_line, threads[0].end_line), (27, 29));
assert_eq!(threads[0].side, PullRequestDiffSide::Additions);
assert!(!threads[0].is_resolved);
assert_eq!(threads[0].comments.len(), 2);
assert_eq!(threads[0].comments[1].author, "ada");
assert_eq!(threads[0].comments[0].path.as_deref(), Some("src/lib.rs"));
}

#[test]
fn rejects_empty_and_oversized_comments() {
assert!(validate_comment(" \n ").is_err());
Expand Down
5 changes: 5 additions & 0 deletions ui/src/components/Diff.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export interface ParsedDiffProps<LAnnotation = undefined> extends Omit<DiffProps
renderAnnotation?: (annotation: DiffLineAnnotation<LAnnotation>) => ReactNode;
/** Pierre emits a complete range after pointer or keyboard selection. */
onLineSelected?: (range: SelectedLineRange | null) => void;
/** Opens a line/range action from Pierre's built-in hover-gutter `+`. */
onGutterUtilityClick?: (range: SelectedLineRange) => void;
}

/**
Expand Down Expand Up @@ -139,6 +141,7 @@ export function ParsedDiff<LAnnotation = undefined>({
lineAnnotations,
renderAnnotation,
onLineSelected,
onGutterUtilityClick,
className,
style,
}: ParsedDiffProps<LAnnotation>) {
Expand All @@ -154,6 +157,8 @@ export function ParsedDiff<LAnnotation = undefined>({
enableLineSelection: Boolean(onLineSelected),
controlledSelection: Boolean(onLineSelected),
onLineSelected,
enableGutterUtility: Boolean(onGutterUtilityClick),
onGutterUtilityClick,
...diffAppearanceOptions({ diffIndicators, diffLineNumbers, diffWordHighlight }),
} as const;
return (
Expand Down
1 change: 1 addition & 0 deletions ui/src/lib/pullRequests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ function pullRequest(overrides: Partial<PullRequest> = {}): PullRequest {
reviewers: [],
checks: [{ name: 'CI', status: 'SUCCESS' }],
comments: [],
review_threads: [],
...overrides,
};
}
Expand Down
Loading
Loading