diff --git a/README.md b/README.md index 23c3614..07a515e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 15846d6..06f9e87 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/TASKS.md b/TASKS.md index 977a833..9cdc5da 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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`). @@ -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. diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index ec19a2f..06f1770 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -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, diff --git a/crates/strand-tauri/src/pull_requests.rs b/crates/strand-tauri/src/pull_requests.rs index 960b7c1..9cca5f0 100644 --- a/crates/strand-tauri/src/pull_requests.rs +++ b/crates/strand-tauri/src/pull_requests.rs @@ -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 = std::result::Result; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -79,6 +101,18 @@ pub struct PullRequestComment { pub path: Option, } +#[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, +} + #[derive(Debug, Clone, Serialize)] pub struct PullRequest { pub id: u64, @@ -104,6 +138,7 @@ pub struct PullRequest { pub reviewers: Vec, pub checks: Vec, pub comments: Vec, + pub review_threads: Vec, } #[derive(Debug, Clone, Serialize)] @@ -112,7 +147,7 @@ pub struct PullRequestList { pub pull_requests: Vec, } -#[derive(Debug, Clone, Copy, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum PullRequestDiffSide { Deletions, @@ -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 { 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", @@ -310,7 +345,41 @@ fn detail_github(cwd: &str, owner: String, repo: String, id: u64) -> Result Result> { + 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 { @@ -971,9 +1040,78 @@ fn parse_github_pr(value: &Value) -> Option { reviewers, checks, comments, + review_threads: Vec::new(), }) } +fn parse_github_review_threads(value: &Value) -> Vec { + 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::>(); + 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, @@ -1049,6 +1187,7 @@ fn parse_azure_pr( reviewers, checks: Vec::new(), comments: Vec::new(), + review_threads: Vec::new(), }) } @@ -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()); diff --git a/ui/src/components/Diff.tsx b/ui/src/components/Diff.tsx index 144c8d2..32f2b37 100644 --- a/ui/src/components/Diff.tsx +++ b/ui/src/components/Diff.tsx @@ -38,6 +38,8 @@ export interface ParsedDiffProps extends Omit) => 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; } /** @@ -139,6 +141,7 @@ export function ParsedDiff({ lineAnnotations, renderAnnotation, onLineSelected, + onGutterUtilityClick, className, style, }: ParsedDiffProps) { @@ -154,6 +157,8 @@ export function ParsedDiff({ enableLineSelection: Boolean(onLineSelected), controlledSelection: Boolean(onLineSelected), onLineSelected, + enableGutterUtility: Boolean(onGutterUtilityClick), + onGutterUtilityClick, ...diffAppearanceOptions({ diffIndicators, diffLineNumbers, diffWordHighlight }), } as const; return ( diff --git a/ui/src/lib/pullRequests.test.ts b/ui/src/lib/pullRequests.test.ts index 353cffb..de781be 100644 --- a/ui/src/lib/pullRequests.test.ts +++ b/ui/src/lib/pullRequests.test.ts @@ -49,6 +49,7 @@ function pullRequest(overrides: Partial = {}): PullRequest { reviewers: [], checks: [{ name: 'CI', status: 'SUCCESS' }], comments: [], + review_threads: [], ...overrides, }; } diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index ce6291c..011bfa4 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -209,10 +209,21 @@ export interface PullRequestComment { created_at: string; url: string; is_system: boolean; - /** Azure inline threads report their file; top-level comments are null. */ + /** Inline review comments report their file; top-level comments are null. */ path: string | null; } +export interface PullRequestReviewThread { + id: string; + path: string; + start_line: number; + end_line: number; + side: 'deletions' | 'additions'; + is_resolved: boolean; + is_outdated: boolean; + comments: PullRequestComment[]; +} + export interface PullRequest { id: number; title: string; @@ -237,6 +248,7 @@ export interface PullRequest { reviewers: PullRequestReviewer[]; checks: PullRequestCheck[]; comments: PullRequestComment[]; + review_threads: PullRequestReviewThread[]; } export interface PullRequestList { diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index f1bbb26..00b2525 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -5051,7 +5051,14 @@ textarea.clone-input { .pr-comment header code { max-width: min(460px, 55vw); overflow: hidden; color: var(--accent-2); font: 9px var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } .pr-comment header span { padding: 1px 5px; border: 1px solid var(--border); border-radius: 999px; color: var(--text-muted); font-size: 8px; text-transform: uppercase; } .pr-comment header time { color: var(--text-muted); font-size: 9px; white-space: nowrap; } -.pr-comment-time { +.pr-comment-links { + margin-left: auto; + display: flex; + align-items: center; + gap: 4px; +} +.pr-comment-links > time { padding: 3px; } +.pr-comment-links button { padding: 3px; border: 0; border-radius: var(--r-sm); @@ -5063,9 +5070,10 @@ textarea.clone-input { font: inherit; cursor: pointer; } -.pr-comment-time:hover { color: var(--text); background: var(--bg-hover); } -.pr-comment-time:focus-visible { outline: 1px solid var(--accent); } -.pr-comment-time time { color: inherit; } +.pr-comment-links button:first-child:not(:last-child) { padding-inline: 6px; color: var(--accent-2); font-size: 9px; } +.pr-comment-links button:hover { color: var(--text); background: var(--bg-hover); } +.pr-comment-links button:focus-visible { outline: 1px solid var(--accent); } +.pr-comment-links button time { color: inherit; } .pr-comment-body { padding: 12px 13px 13px; } @media (max-width: 760px) { @@ -5120,20 +5128,21 @@ textarea.clone-input { padding: 3px 7px 3px 4px; border-left: 0.5px solid var(--border); } -.pr-diff-tools .icon-btn:disabled { - opacity: .38; - cursor: default; -} .pr-review-diff { --diffs-annotation-min-height: 0; } .pr-inline-composer { display: grid; gap: 8px; - min-width: 320px; + box-sizing: border-box; + width: min(720px, calc(100% - 32px)); + margin: 12px 16px 14px; padding: 10px; background: var(--bg-elev); - border-block: 0.5px solid var(--accent); + border: 0.5px solid var(--border-strong); + border-left: 2px solid var(--accent); + border-radius: 6px; + box-shadow: var(--shadow-1); font-family: var(--font-ui); } .pr-inline-composer-head, @@ -5168,6 +5177,78 @@ textarea.clone-input { .pr-inline-composer textarea:focus { border-color: var(--accent); } .pr-inline-actions { justify-content: flex-end; } .pr-inline-actions span { margin-right: auto; color: var(--text-dim); font-size: 9.5px; } +.pr-inline-thread { + box-sizing: border-box; + width: min(720px, calc(100% - 32px)); + margin: 12px 16px 14px; + overflow: hidden; + border: 0.5px solid var(--border-strong); + border-left: 2px solid var(--accent-2); + border-radius: 6px; + background: var(--bg-elev); + box-shadow: var(--shadow-1); + color: var(--text-2); + font-family: var(--font-ui); +} +.pr-inline-thread.resolved { border-left-color: var(--add); } +.pr-inline-thread.outdated { border-left-color: var(--text-dim); opacity: .82; } +.pr-inline-thread:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; } +.pr-inline-thread > header { + min-height: 30px; + padding: 6px 10px; + border-bottom: 0.5px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + background: color-mix(in oklch, var(--bg-panel), transparent 25%); +} +.pr-inline-thread > header strong { color: var(--text); font: 500 10px var(--font-mono); } +.pr-inline-thread > header > div { display: flex; gap: 5px; } +.pr-inline-thread > header span { + padding: 1px 5px; + border: 0.5px solid var(--border-strong); + border-radius: 999px; + color: var(--text-muted); + font-size: 8px; + text-transform: uppercase; +} +.pr-inline-thread-comment { padding: 10px 11px; } +.pr-inline-thread-comment + .pr-inline-thread-comment { border-top: 0.5px solid var(--border); } +.pr-inline-thread-author { margin-bottom: 7px; display: flex; align-items: center; gap: 7px; } +.pr-inline-thread-author > strong { color: var(--text); font-size: 10px; } +.pr-inline-thread-author > time { margin-left: auto; color: var(--text-muted); font-size: 9px; } +.pr-inline-thread-author > button { + margin-left: auto; + padding: 3px; + border: 0; + border-radius: var(--r-sm); + display: inline-flex; + align-items: center; + gap: 5px; + background: transparent; + color: var(--text-muted); + font: 9px var(--font-ui); + cursor: pointer; +} +.pr-inline-thread-author > button:hover { color: var(--text); background: var(--bg-hover); } +.pr-inline-thread-author > button:focus-visible { outline: 1px solid var(--accent); } +.pr-inline-avatar { + position: relative; + width: 22px; + height: 22px; + flex: none; + border: 0.5px solid var(--border-strong); + border-radius: 50%; + display: grid; + place-items: center; + overflow: hidden; + background: var(--bg-panel); + color: var(--text-2); + font: 650 7px var(--font-ui); +} +.pr-inline-avatar img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.pr-inline-thread-comment > .markdown { font-size: 11px; } .pr-inline-message { padding: 5px 10px; border-bottom: 0.5px solid var(--border); diff --git a/ui/src/views/PullRequests.tsx b/ui/src/views/PullRequests.tsx index 22527b8..e22a2e0 100644 --- a/ui/src/views/PullRequests.tsx +++ b/ui/src/views/PullRequests.tsx @@ -20,7 +20,7 @@ import { } from '../lib/pullRequests'; import { errMessage, tauri } from '../lib/tauri'; import { treeFileOrder } from '../lib/treeOrder'; -import type { PullRequest, PullRequestCheck, PullRequestList } from '../lib/types'; +import type { PullRequest, PullRequestCheck, PullRequestComment, PullRequestList, PullRequestReviewThread } from '../lib/types'; import { useRepo } from '../stores/repo'; import { useSettings } from '../stores/settings'; import { PullRequestMergeControl } from './PullRequestMergeControl'; @@ -173,10 +173,12 @@ function PullRequestConversation({ path, pr, onUpdated, + onViewInChanges, }: { path: string; pr: PullRequest; onUpdated: (next: PullRequest) => void; + onViewInChanges: (comment: PullRequestComment) => void; }) { const platform = useSettings((state) => state.platform); const [draft, setDraft] = useState(''); @@ -369,12 +371,20 @@ function PullRequestConversation({ {comment.is_system && system} {comment.path && {comment.path}} - {commentUrl ? ( - - ) : } +
+ {comment.path && ( + + )} + {commentUrl ? ( + + ) : } +
@@ -395,18 +405,73 @@ function fileGitStatus(file: FileDiffMetadata): GitStatusEntry['status'] { return 'modified'; } -type InlineCommentAnnotation = { range: SelectedLineRange }; +function PullRequestInlineThread({ thread, prUrl }: { thread: PullRequestReviewThread; prUrl: string }) { + const lineLabel = thread.start_line === thread.end_line + ? `${thread.end_line}` + : `${thread.start_line}–${thread.end_line}`; + return ( +
+
+ {thread.side === 'deletions' ? 'Old' : 'New'} line{thread.start_line === thread.end_line ? '' : 's'} {lineLabel} +
+ {thread.is_resolved && Resolved} + {thread.is_outdated && Outdated} +
+
+ {thread.comments.map((comment) => { + const commentUrl = markdownUrl(comment.url, prUrl); + const avatarUrl = markdownUrl(comment.avatar_url ?? undefined); + return ( +
+
+ + {comment.author} + {commentUrl ? ( + + ) : } +
+ +
+ ); + })} +
+ ); +} + +type InlineCommentAnnotation = + | { kind: 'composer'; range: SelectedLineRange } + | { kind: 'thread'; thread: PullRequestReviewThread }; + +type PullRequestChangesTarget = { + path: string; + threadId: string | null; + requestId: number; +}; function PullRequestChanges({ path, provider, pr, onUpdated, + navigationTarget, + onNavigationComplete, }: { path: string; provider: PullRequestList['repository']['provider']; pr: PullRequest; onUpdated: (next: PullRequest) => void; + navigationTarget: PullRequestChangesTarget | null; + onNavigationComplete: (requestId: number) => void; }) { const diffMode = useSettings((state) => state.diffMode); const platform = useSettings((state) => state.platform); @@ -457,10 +522,31 @@ function PullRequestChanges({ ); const selectedFile = selectedPath ? filesByPath.get(selectedPath) ?? null : null; const selectedStats = selectedFile ? diffStats(selectedFile) : null; + const selectedThreads = useMemo( + () => selectedFile ? (pr.review_threads ?? []).filter((thread) => thread.path === selectedFile.name) : [], + [pr.review_threads, selectedFile], + ); useEffect(() => { - setSelectedPath((current) => current && filesByPath.has(current) ? current : treePaths[0] ?? null); - }, [filesByPath, treePaths]); + setSelectedPath((current) => { + if (navigationTarget && filesByPath.has(navigationTarget.path)) return navigationTarget.path; + return current && filesByPath.has(current) ? current : treePaths[0] ?? null; + }); + }, [filesByPath, navigationTarget, treePaths]); + + useEffect(() => { + if (!navigationTarget || navigationTarget.path !== selectedPath) return; + const frame = requestAnimationFrame(() => { + const threadTarget = navigationTarget.threadId + ? document.getElementById(`pr-review-thread-${navigationTarget.threadId}`) + : null; + const target = threadTarget ?? document.getElementById(`pr-diff-file-${pr.id}`); + target?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + if (target instanceof HTMLElement) target.focus({ preventScroll: true }); + onNavigationComplete(navigationTarget.requestId); + }); + return () => cancelAnimationFrame(frame); + }, [navigationTarget, onNavigationComplete, pr.id, selectedPath]); useEffect(() => { setCollapsed(false); @@ -471,12 +557,6 @@ function PullRequestChanges({ const openForReview = pr.state === 'open' || pr.state === 'active'; const inlineCommentsSupported = provider === 'git_hub' && openForReview; - const inlineCommentTitle = provider !== 'git_hub' - ? 'Inline Azure DevOps comments need iteration tracking; open on host for now' - : openForReview - ? 'Comment on lines' - : 'This pull request is read-only'; - const selectLines = useCallback((range: SelectedLineRange | null) => { if (!inlineCommentsSupported || !range) { setSelectedLines(null); @@ -497,21 +577,27 @@ function PullRequestChanges({ setCommentMessage(null); }, [inlineCommentsSupported]); - const beginInlineComment = () => { - if (!selectedFile || !inlineCommentsSupported) return; - const hunk = selectedFile.hunks[0]; - if (!hunk) return; - const side = selectedFile.type === 'deleted' ? 'deletions' : 'additions'; - const line = side === 'deletions' ? hunk.deletionStart : hunk.additionStart; - selectLines({ start: Math.max(1, line), end: Math.max(1, line), side, endSide: side }); + const openInlineComment = useCallback((range: SelectedLineRange) => { + selectLines(range); requestAnimationFrame(() => document.getElementById(`pr-inline-comment-${pr.id}`)?.focus()); - }; + }, [pr.id, selectLines]); const inlineAnnotations = useMemo[]>(() => { - if (!selectedLines) return []; - const side = selectedLines.endSide ?? selectedLines.side ?? 'additions'; - return [{ side, lineNumber: selectedLines.end, metadata: { range: selectedLines } }]; - }, [selectedLines]); + const annotations: DiffLineAnnotation[] = selectedThreads.map((thread) => ({ + side: thread.side, + lineNumber: thread.end_line, + metadata: { kind: 'thread' as const, thread }, + })); + if (selectedLines) { + const side = selectedLines.endSide ?? selectedLines.side ?? 'additions'; + annotations.push({ + side, + lineNumber: selectedLines.end, + metadata: { kind: 'composer' as const, range: selectedLines }, + }); + } + return annotations; + }, [selectedLines, selectedThreads]); const submitInlineComment = async () => { if (!selectedFile || !selectedLines || postingComment) return; @@ -608,6 +694,7 @@ function PullRequestChanges({
-
{commentMessage && ( @@ -665,7 +742,10 @@ function PullRequestChanges({ selectedLines={selectedLines} lineAnnotations={inlineAnnotations} onLineSelected={inlineCommentsSupported ? selectLines : undefined} - renderAnnotation={() => selectedLines ? ( + onGutterUtilityClick={inlineCommentsSupported ? openInlineComment : undefined} + renderAnnotation={(annotation) => annotation.metadata.kind === 'thread' ? ( + + ) : selectedLines ? (
{ @@ -737,6 +817,8 @@ function PullRequestDetails({ onToast: (message: string, kind?: 'success' | 'error') => void; }) { const [tab, setTab] = useState('overview'); + const [changesTarget, setChangesTarget] = useState(null); + const changesRequest = useRef(0); const open = () => { if (pr.url) void shellOpen(pr.url); }; const selectTab = (next: DetailTab) => { setTab(next); @@ -761,6 +843,21 @@ function PullRequestDetails({ : !pr.source_commit ? 'Refresh this pull request before merging' : ''; + const viewCommentInChanges = (comment: PullRequestComment) => { + if (!comment.path) return; + const thread = (pr.review_threads ?? []).find((candidate) => + candidate.comments.some((item) => item.id === comment.id)); + changesRequest.current += 1; + setChangesTarget({ + path: comment.path, + threadId: thread?.id ?? null, + requestId: changesRequest.current, + }); + setTab('changes'); + }; + const completeChangesNavigation = useCallback((requestId: number) => { + setChangesTarget((current) => current?.requestId === requestId ? null : current); + }, []); return (
@@ -853,8 +950,24 @@ function PullRequestDetails({ aria-labelledby={`pr-tab-${pr.id}-${tab}`} > {tab === 'overview' && } - {tab === 'conversation' && } - {tab === 'changes' && } + {tab === 'conversation' && ( + + )} + {tab === 'changes' && ( + + )}
); diff --git a/website/docs/pull-requests.md b/website/docs/pull-requests.md index 1fae91e..56fda17 100644 --- a/website/docs/pull-requests.md +++ b/website/docs/pull-requests.md @@ -62,12 +62,14 @@ does not report a recognized state. ### Conversation -Conversation displays GitHub comments and Azure DevOps thread comments as safe -Markdown in a timeline with author markers, timestamps, and inline file paths -for Azure comments. GitHub and Azure profile images appear when the provider +Conversation displays GitHub issue and review-thread comments plus Azure +DevOps thread comments as safe Markdown in a timeline with author markers, +timestamps, and inline file paths for review comments. GitHub and Azure profile images appear when the provider supplies a usable identity; initials remain visible if an avatar is absent or cannot load. Select a comment timestamp to open that comment directly on the -provider host. +provider host. File-backed comments also show **View in changes**. It switches +to Changes, selects the referenced file, and focuses the inline GitHub thread +when the provider supplied its line coordinates. Use **Write** to compose a top-level comment and **Preview** to inspect the rendered result before sending. The formatting toolbar supports bold, italic, @@ -96,22 +98,28 @@ and split diffs; the choice is saved per repository. Only one file diff is mounted at a time to keep large PRs responsive. Provider patches larger than 16 MB are not rendered. -On an open GitHub pull request, drag across line numbers to select one or more -lines, or choose **Comment on lines** in the file header to start from the -first changed line using the keyboard. Strand highlights the range and opens a -composer directly beneath it. **Add comment** publishes a GitHub review thread +On an open GitHub pull request, hover a line number and choose the `+` in its +gutter. Drag the `+` across adjacent lines to comment on a range, or drag across +line numbers and then use the `+` at the end of the selection. Strand +highlights the range and opens a compact composer directly beneath that code, +inside the diff. **Add comment** publishes a GitHub review thread on that exact old- or new-file range; `Mod+Enter` sends from the composer. Before publishing, Strand verifies that the pull request head is still the commit used by the displayed patch; if it changed, the draft stays in place and Changes asks you to refresh and reselect. Closed and merged pull requests stay read-only. +Fetched GitHub review threads remain visible directly beneath their anchored +line or range. Replies stay grouped in the same card, and resolved or outdated +threads are labeled. The same review comments also appear in Conversation, so +comments added on GitHub are visible after refreshing the pull request. + Azure does not expose every check or policy field through the same provider command, so absent data is shown honestly rather than inferred. Azure inline comments also require provider iteration/change-tracking coordinates that the current patch fetch does not include, so Strand disables that action and directs you to the host instead of creating a wrongly anchored thread. -Replies, suggestions, approve/request-changes actions, Azure policy details, +Creating replies, resolving threads, suggestions, approve/request-changes actions, Azure policy details, branch updates, and close/reopen controls are planned but are not presented as available yet. GitLab and Bitbucket adapters will use the same workspace in a later slice.