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: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,14 @@ keyboard alone, and the mouse stays first-class.
opening automatically. Each PR gets a full-width workspace: read rendered
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 and
diff workspace, see color-coded provider checks, and merge with merge-commit,
squash, or rebase through a GitHub-style split merge control.
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,
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
remain enforced, while submit-review and other lifecycle actions are still
in progress.
remain enforced. Azure inline comments need iteration tracking and
submit-review and other lifecycle actions are still in progress.
- **Worktrees (⌘5)** — an AI-agent dashboard for every worktree with stable
repo naming, branch/session labels, dirty count, ±lines, "touched 3m ago"
activity, disk size, ahead/behind, and one-click Review pinned where the
Expand Down
26 changes: 26 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1580,6 +1580,32 @@ common Markdown blocks, a hosted screenshot/image action, live character
count, and clearer submission feedback. Remote images remain inert until the
user explicitly reveals them, preserving the webview privacy boundary.

**Hosted PR review-workspace research (2026-07-13):** A current comparison of
Codex, Cursor, Conductor, GitHub, Graphite, and GitKraken is captured in
`docs/pull-request-improvements.md`. The recommended next phase keeps Strand's
lazy provider queries and one-mounted-diff boundary, but adds the missing review
state: a readiness/viewed/thread ledger, inline thread lifecycle and batched
review submission, attention-oriented list filters, then commit/version and
safe worktree actions. Concrete unchecked slices were added to `TASKS.md`; no
user-visible behavior changed in this research pass.

**Hosted PR readiness ledger (2026-07-13):** The first review-workspace slice
landed in the PR header. `pullRequestReadiness` conservatively combines active /
draft state, exact-head availability, required-review state, CI results, merge
conflicts, and provider merge state; the compact `.pr-readiness` strip keeps the
result, checks summary, review decision, and update age visible across every PR
tab, with keyboard-accessible blocker details. Unrecognized signals and Azure's
currently unavailable policy/check detail remain **Status incomplete**, never a
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;
`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.

---

## 1.1+ — Post-1.0
Expand Down
33 changes: 31 additions & 2 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1284,7 +1284,8 @@ tree: watch the agent work, review fast, accept or reject safely.
keyboard-operable 22% Pierre folder tree and compact Local Changes-style
file header mount one selected, edge-to-edge diff at a time, leaving the
rest of the full-width detail workspace for code and following the app's
split/stacked appearance settings. Azure comparisons
split/stacked appearance settings. The file header now exposes the same
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
Expand All @@ -1293,10 +1294,38 @@ tree: watch the agent work, review fast, accept or reject safely.
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. Replies, new
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.
- ☐ Submit reviews: approve, request changes, dismiss/update a review where supported.
- ◐ PR review ledger + merge-readiness model (see
`docs/pull-request-improvements.md`).
- ☑ Header readiness strip (`pullRequestReadiness`, `.pr-readiness`):
combines state, required reviews, checks, conflicts, provider freshness,
and expandable blocker evidence; missing Azure policy/check fields remain
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
content-hash-keyed viewed files, unviewed/thread filters, batched drafts,
and keyboard next-thread navigation remain while retaining one mounted
Pierre diff.
- ☐ Batched review submission: pending comments plus Comment / Approve /
Request changes, summary preview, exact-head stale guard, and draft
preservation when a provider write fails.
- ☐ PR inbox controls over the shallow list: Open / Needs review / Mine /
Draft / Closed filters, fuzzy search, attention-first sort, and row-level
review/check state without expanding provider queries per row.
- ☐ Lazy Commits + independent Checks tabs; keep readiness persistent in the
PR header and deep-link unsupported provider detail to the host.
- ☐ Review evolution + local action: reliable “since my last review” compare
where the provider exposes a boundary, safe Open in worktree / Update branch,
suggestions, and unresolved-feedback export for external agents.
- ◐ Checks render provider states as green success, yellow running, red
failure, or neutral. Azure policies, merge queue/auto-complete, and
required-review detail remain.
Expand Down
29 changes: 29 additions & 0 deletions crates/strand-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,35 @@ pub async fn repo_pull_request_comment(path: String, id: u64, body: String) -> C
.await
}

/// 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)]
pub async fn repo_pull_request_inline_comment(
path: String,
id: u64,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a test in a comment

body: String,
file_path: String,
start_line: u32,
end_line: u32,
side: pull_requests::PullRequestDiffSide,
expected_head: String,
) -> CmdResult<()> {
run_blocking("pull request inline comment", move || {
pull_requests::add_inline_comment(
&path,
id,
&body,
&file_path,
start_line,
end_line,
side,
&expected_head,
)
.map_err(|message| CmdError { message })
})
.await
}

/// Merge a hosted pull request through its provider. The expected source
/// commit prevents merging unseen updates; provider policies remain enforced.
#[tauri::command(async)]
Expand Down
1 change: 1 addition & 0 deletions crates/strand-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ fn main() {
commands::repo_pull_request,
commands::repo_pull_request_diff,
commands::repo_pull_request_comment,
commands::repo_pull_request_inline_comment,
commands::repo_pull_request_merge,
commands::repo_diff_unstaged,
commands::repo_diff_staged,
Expand Down
130 changes: 130 additions & 0 deletions crates/strand-tauri/src/pull_requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ pub struct PullRequestList {
pub pull_requests: Vec<PullRequest>,
}

#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PullRequestDiffSide {
Deletions,
Additions,
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum HostRepo {
GitHub {
Expand Down Expand Up @@ -174,6 +181,44 @@ pub fn add_comment(path: &str, id: u64, body: &str) -> Result<()> {
}
}

#[allow(clippy::too_many_arguments)]
pub fn add_inline_comment(
path: &str,
id: u64,
body: &str,
file_path: &str,
start_line: u32,
end_line: u32,
side: PullRequestDiffSide,
expected_head: &str,
) -> Result<()> {
validate_comment(body)?;
validate_commit(expected_head)?;
if file_path.trim().is_empty() || file_path.contains(['\r', '\n', '\0']) {
return Err("The inline comment file path is invalid".to_string());
}
if start_line == 0 || end_line < start_line {
return Err("The inline comment line range is invalid".to_string());
}
let (_, host) = host_for_path(path)?;
match host {
HostRepo::GitHub { owner, repo } => {
let current = detail_github(path, owner.clone(), repo.clone(), id)?;
if current.source_commit != expected_head {
return Err("The pull request changed while this comment was being written. Refresh Changes and select the lines again.".to_string());
}
add_inline_comment_github(
path, owner, repo, id, body, file_path, start_line, end_line, side,
expected_head,
)
}
HostRepo::Azure { .. } => Err(
"Inline Azure DevOps comments need iteration tracking metadata that Strand does not load yet. Open this pull request on Azure DevOps to comment on these lines."
.to_string(),
),
}
}

pub fn merge(
path: &str,
id: u64,
Expand Down Expand Up @@ -297,6 +342,61 @@ fn add_comment_github(cwd: &str, owner: String, repo: String, id: u64, body: &st
Ok(())
}

#[allow(clippy::too_many_arguments)]
fn add_inline_comment_github(
cwd: &str,
owner: String,
repo: String,
id: u64,
body: &str,
file_path: &str,
start_line: u32,
end_line: u32,
side: PullRequestDiffSide,
expected_head: &str,
) -> Result<()> {
let endpoint = format!("repos/{owner}/{repo}/pulls/{id}/comments");
let payload = github_inline_comment_payload(
body, file_path, start_line, end_line, side, expected_head,
);
let input = serde_json::to_vec(&payload)
.map_err(|error| format!("Could not encode GitHub inline comment: {error}"))?;
run_command_input(
cwd,
"gh",
&["api", "--method", "POST", &endpoint, "--input", "-"],
&[("GH_PROMPT_DISABLED", "1")],
Some(&input),
)?;
Ok(())
}

fn github_inline_comment_payload(
body: &str,
file_path: &str,
start_line: u32,
end_line: u32,
side: PullRequestDiffSide,
expected_head: &str,
) -> Value {
let side = match side {
PullRequestDiffSide::Deletions => "LEFT",
PullRequestDiffSide::Additions => "RIGHT",
};
let mut payload = serde_json::json!({
"body": body,
"commit_id": expected_head,
"path": file_path,
"line": end_line,
"side": side,
});
if start_line != end_line {
payload["start_line"] = start_line.into();
payload["start_side"] = side.into();
}
payload
}

fn merge_github(
cwd: &str,
owner: &str,
Expand Down Expand Up @@ -1293,4 +1393,34 @@ mod tests {
assert_eq!(azure_merge_strategy(PullRequestMergeStrategy::MergeCommit), "noFastForward");
assert_eq!(azure_merge_strategy(PullRequestMergeStrategy::Rebase), "rebase");
}

#[test]
fn builds_github_inline_comment_ranges_with_blob_sides() {
let multi = github_inline_comment_payload(
"Please simplify this.",
"src/lib.rs",
10,
12,
PullRequestDiffSide::Additions,
"0123456789abcdef0123456789abcdef01234567",
);
assert_eq!(multi["path"], "src/lib.rs");
assert_eq!(multi["start_line"], 10);
assert_eq!(multi["line"], 12);
assert_eq!(multi["start_side"], "RIGHT");
assert_eq!(multi["side"], "RIGHT");

let single = github_inline_comment_payload(
"Why remove this?",
"src/lib.rs",
7,
7,
PullRequestDiffSide::Deletions,
"0123456789abcdef0123456789abcdef01234567",
);
assert_eq!(single["line"], 7);
assert_eq!(single["side"], "LEFT");
assert!(single.get("start_line").is_none());
assert!(single.get("start_side").is_none());
}
}
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ Project documentation.
- [`learnings.md`](./learnings.md) — durable rules learned while building
Strand. Read before touching the UI; append when you discover something
future work has to respect.
- [`pull-request-improvements.md`](./pull-request-improvements.md) — competitive
research and the prioritized UX/UI proposal for the hosted PR workspace.
- [`changelog/`](./changelog) — append-only log of substantive changes per
working session. One file per session, dated.
- See also: [`../ROADMAP.md`](../ROADMAP.md) (milestones) and
Expand Down
Loading
Loading