diff --git a/package-lock.json b/package-lock.json index cc7ddd87..f7754069 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,8 @@ "react-dom": "^19.2.5", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "ssh-config": "^5.1.0", "ws": "^8.20.0" @@ -5361,6 +5363,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -6027,6 +6041,26 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-is-element": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", @@ -6040,6 +6074,59 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -6067,6 +6154,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-text": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", @@ -6096,6 +6202,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/highlight.js": { "version": "11.11.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", @@ -6148,6 +6271,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -8769,6 +8902,18 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -9127,6 +9272,35 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -10375,6 +10549,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vfile-message": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", @@ -10567,6 +10755,16 @@ "defaults": "^1.0.3" } }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index d455df8d..f431afb8 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,8 @@ "react-dom": "^19.2.5", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "ssh-config": "^5.1.0", "ws": "^8.20.0" diff --git a/src/main/build-initial-state.ts b/src/main/build-initial-state.ts index edff98f7..341d29c6 100644 --- a/src/main/build-initial-state.ts +++ b/src/main/build-initial-state.ts @@ -80,6 +80,10 @@ export function buildInitialAppState( : 'system', themeLight: config.themeLight || DEFAULT_LIGHT_THEME, themeDark: config.themeDark || DEFAULT_DARK_THEME, + reviewDiffMode: + config.reviewDiffMode === 'light' || config.reviewDiffMode === 'dark' + ? config.reviewDiffMode + : 'match', hotkeys: config.hotkeys || null, defaultAgent: config.defaultAgent || 'claude', claudeCommand: config.claudeCommand || DEFAULT_CLAUDE_COMMAND, diff --git a/src/main/github.ts b/src/main/github.ts index 1f6ce15f..61a139b1 100644 --- a/src/main/github.ts +++ b/src/main/github.ts @@ -4,7 +4,13 @@ import { log, formatErr } from './debug' import { getCachedToken, invalidateTokenCache, resolveGitHubToken } from './github-auth' import { trackedFetch } from './github-recorder' import type { CheckStatus, PRReview, PRStatus } from '../shared/state/prs' -import type { PRSummary, PRMetadata } from '../shared/github-types' +import type { + PRSummary, + PRMetadata, + ReviewSyncComment, + ReviewSyncInput, + ReviewSyncResult +} from '../shared/github-types' export type { CheckStatus, PRReview, PRStatus, PRSummary, PRMetadata } @@ -193,6 +199,7 @@ interface GraphQLActor { interface GraphQLPR { number: number title: string + body: string state: 'OPEN' | 'CLOSED' | 'MERGED' isDraft: boolean url: string @@ -322,7 +329,7 @@ function gqlStatusState(s: Extract { const ctx = await getRepoContext(repoRoot) if (!ctx) return { ok: false, error: 'No GitHub origin remote detected' } const token = getCachedToken() if (!token) return { ok: false, error: 'No GitHub token configured' } const { owner, repo } = ctx.upstream + const payload: { event: string; body?: string } = { event } + if (body.trim()) payload.body = body try { const res = await trackedFetch( `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/reviews`, @@ -740,7 +753,7 @@ export async function approvePR( Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ event: 'APPROVE' }) + body: JSON.stringify(payload) } ) if (res.status === 200 || res.status === 201) return { ok: true } @@ -755,18 +768,26 @@ export async function approvePR( return { ok: false, error: 'Unauthorized — check that your token has repo scope' } } if (res.status === 422) { - // Most common 422 here is "Can not approve your own pull request", - // which the UI should filter out before calling. Surface GitHub's - // message so unexpected cases aren't silent. - return { ok: false, error: apiMessage || 'PR cannot be approved' } + // Common 422s: "Can not approve your own pull request", or a missing + // body for REQUEST_CHANGES/COMMENT. Surface GitHub's message verbatim. + return { ok: false, error: apiMessage || 'Review cannot be submitted' } } return { ok: false, error: apiMessage || `${res.status} ${res.statusText}` } } catch (err) { - log('github', `approvePR fetch failed for ${owner}/${repo}#${prNumber}`, formatErr(err)) + log('github', `submitPRReview fetch failed for ${owner}/${repo}#${prNumber}`, formatErr(err)) return { ok: false, error: err instanceof Error ? err.message : String(err) } } } +/** Submit an APPROVE review with no comment body — GitHub's "Approve without + * comment". Thin wrapper over {@link submitPRReview}. */ +export async function approvePR( + repoRoot: string, + prNumber: number +): Promise<{ ok: true } | { ok: false; error: string }> { + return submitPRReview(repoRoot, prNumber, 'APPROVE', '') +} + /** Check whether the authenticated user has starred the repo. */ export async function isRepoStarred(token: string, owner: string, repo: string): Promise { try { @@ -1173,6 +1194,431 @@ export async function getPRMetadata( } } +// --- Review sync ----------------------------------------------------------- + +async function ghRequest( + token: string, + url: string, + method: string, + body?: unknown +): Promise<{ ok: boolean; status: number; json: unknown }> { + const headers: Record = { + Accept: 'application/vnd.github+json', + 'User-Agent': 'Harness', + 'X-GitHub-Api-Version': '2022-11-28', + Authorization: `Bearer ${token}` + } + if (body !== undefined) headers['Content-Type'] = 'application/json' + const res = await trackedFetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined + }) + let json: unknown = null + try { + json = await res.json() + } catch { + /* empty body */ + } + return { ok: res.ok, status: res.status, json } +} + +async function ghGraphQL( + token: string, + query: string, + variables: Record +): Promise<{ ok: boolean; data?: unknown; error?: string }> { + const res = await trackedFetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'Harness', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ query, variables }) + }) + if (!res.ok) return { ok: false, error: `HTTP ${res.status}` } + const json = (await res.json()) as { data?: unknown; errors?: { message: string }[] } + if (json.errors && json.errors.length > 0) { + return { ok: false, data: json.data, error: json.errors.map((e) => e.message).join('; ') } + } + return { ok: true, data: json.data } +} + +/** Push local review comments (as a DRAFT/pending review) + per-file viewed + * state to a GitHub PR, then pull the canonical comment set back. + * + * Comments are added to the viewer's pending review so they stay unpublished + * until the user submits the review on GitHub. A user can only have one + * pending review per PR, so we reuse an existing one (adding threads to it) + * or create a fresh one. Line comments use the RIGHT side; file-level + * (line 0) comments aren't supported as drafts and are left local. + * + * Best-effort: a comment GitHub rejects (e.g. a line outside the diff) is + * counted as failed and kept locally rather than aborting the sync. + * Comments already carrying a remoteId are assumed pushed and not re-sent. */ +export async function syncPRReview( + token: string, + owner: string, + repo: string, + prNumber: number, + input: ReviewSyncInput +): Promise { + const base = `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}` + const passthrough = { + comments: input.comments, + reviewedFiles: input.reviewedFiles, + pushed: 0, + failed: 0 + } + + // 1. PR node id (for the draft-review + viewed-state GraphQL APIs). + let nodeId = '' + try { + const pr = await ghRequest(token, base, 'GET') + if (!pr.ok) { + return { ok: false, error: `Could not load PR #${prNumber} (HTTP ${pr.status})`, ...passthrough } + } + nodeId = (pr.json as { node_id?: string }).node_id ?? '' + } catch (err) { + return { ok: false, error: formatErr(err), ...passthrough } + } + if (!nodeId) return { ok: false, error: 'Could not resolve PR node id', ...passthrough } + + // 2. Find the viewer's existing pending review, if any. Pending reviews are + // only returned to their author, so any PENDING entry here is ours. + let pendingReviewNodeId = '' + try { + const res = await ghRequest(token, `${base}/reviews?per_page=100`, 'GET') + if (res.ok && Array.isArray(res.json)) { + const pending = (res.json as Array<{ id: number; node_id: string; state: string }>).find( + (r) => r.state === 'PENDING' + ) + if (pending) { + pendingReviewNodeId = pending.node_id + } + } + } catch (err) { + log('github', 'list reviews failed', formatErr(err)) + } + + // 3. Push un-synced line comments as drafts on the pending review. + const toPush = input.pullOnly + ? [] + : input.comments.filter( + (c) => c.remoteId === undefined && c.lineNumber > 0 && c.inReplyToId === undefined + ) + let pushed = 0 + let failed = 0 + if (toPush.length > 0) { + if (pendingReviewNodeId) { + // Existing pending review — add each comment as a thread. + for (const c of toPush) { + const range = c.startLine !== undefined && c.startLine !== c.lineNumber + const r = await ghGraphQL( + token, + `mutation($rid:ID!,$path:String!,$line:Int!,$startLine:Int,$startSide:DiffSide,$body:String!){ + addPullRequestReviewThread(input:{pullRequestReviewId:$rid,path:$path,line:$line,startLine:$startLine,side:RIGHT,startSide:$startSide,body:$body}){ thread { id } } + }`, + { + rid: pendingReviewNodeId, + path: c.filePath, + line: c.lineNumber, + startLine: range ? c.startLine : null, + startSide: range ? 'RIGHT' : null, + body: c.body + } + ) + if (r.ok) pushed++ + else { + failed++ + log('github', `draft thread rejected ${c.filePath}:${c.lineNumber}: ${r.error}`) + } + } + } else { + // No pending review yet — create one carrying all the threads. + const threads = toPush.map((c) => { + const range = c.startLine !== undefined && c.startLine !== c.lineNumber + return { + path: c.filePath, + line: c.lineNumber, + side: 'RIGHT', + ...(range ? { startLine: c.startLine, startSide: 'RIGHT' } : {}), + body: c.body + } + }) + const r = await ghGraphQL( + token, + `mutation($prId:ID!,$threads:[DraftPullRequestReviewThread!]){ + addPullRequestReview(input:{pullRequestId:$prId,threads:$threads}){ pullRequestReview { databaseId } } + }`, + { prId: nodeId, threads } + ) + if (r.ok) { + pushed += toPush.length + } else { + failed += toPush.length + log('github', `create draft review failed: ${r.error}`) + } + } + } + + // 3b. Push replies. A reply targets an existing comment (its inReplyToId), + // so it posts directly via REST in_reply_to (published immediately). + if (!input.pullOnly) { + const replies = input.comments.filter( + (c) => c.remoteId === undefined && c.inReplyToId !== undefined + ) + for (const r of replies) { + try { + const res = await ghRequest(token, `${base}/comments`, 'POST', { + body: r.body, + in_reply_to: r.inReplyToId + }) + if (res.ok) pushed++ + else { + failed++ + const msg = (res.json as { message?: string } | null)?.message ?? '' + log('github', `reply rejected (in_reply_to ${r.inReplyToId}) HTTP ${res.status} ${msg}`) + } + } catch (err) { + failed++ + log('github', 'reply post error', formatErr(err)) + } + } + } + + // 3c. Resolve threads the user asked to resolve. Never unresolve. + if (!input.pullOnly && input.resolveThreadIds && input.resolveThreadIds.length > 0) { + for (const threadId of input.resolveThreadIds) { + const r = await ghGraphQL( + token, + 'mutation($id:ID!){resolveReviewThread(input:{threadId:$id}){thread{id}}}', + { id: threadId } + ) + if (!r.ok) log('github', `resolve thread failed ${threadId}: ${r.error}`) + } + } + + // 4. Push viewed state — only MARK locally-reviewed files. Never unmark: + // a file viewed on GitHub (but not locally) must not get cleared. + // Skipped on a pull-only sync. + const reviewedSet = new Set(input.reviewedFiles) + if (!input.pullOnly) { + for (const path of input.files) { + if (!reviewedSet.has(path)) continue + const r = await ghGraphQL( + token, + 'mutation($id:ID!,$p:String!){markFileAsViewed(input:{pullRequestId:$id,path:$p}){clientMutationId}}', + { id: nodeId, p: path } + ) + if (!r.ok) log('github', `mark viewed failed for ${path}: ${r.error}`) + } + } + + // 4c. Pull review threads → thread node id + resolved state per comment. + const threadInfo = new Map() + const threadsQuery = await ghGraphQL( + token, + 'query($o:String!,$n:String!,$num:Int!){repository(owner:$o,name:$n){pullRequest(number:$num){reviewThreads(first:100){nodes{id isResolved comments(first:100){nodes{databaseId}}}}}}}', + { o: owner, n: repo, num: prNumber } + ) + if (threadsQuery.ok) { + const nodes = + ( + threadsQuery.data as { + repository?: { + pullRequest?: { + reviewThreads?: { + nodes?: Array<{ + id: string + isResolved: boolean + comments?: { nodes?: Array<{ databaseId?: number }> } + }> + } + } + } + } + )?.repository?.pullRequest?.reviewThreads?.nodes ?? [] + for (const t of nodes) { + for (const c of t.comments?.nodes ?? []) { + if (typeof c.databaseId === 'number') { + threadInfo.set(c.databaseId, { threadId: t.id, resolved: t.isResolved }) + } + } + } + } else { + log('github', `review threads query failed: ${threadsQuery.error}`) + } + + // 4b. Pull GitHub's viewed state and union it with the local set, so files + // viewed on GitHub stay viewed (and vice-versa). Viewed state is + // additive — un-viewing isn't propagated. + const ghViewed = new Set() + const viewedQuery = await ghGraphQL( + token, + 'query($o:String!,$n:String!,$num:Int!){repository(owner:$o,name:$n){pullRequest(number:$num){files(first:100){nodes{path viewerViewedState}}}}}', + { o: owner, n: repo, num: prNumber } + ) + if (viewedQuery.ok) { + const nodes = + ( + viewedQuery.data as { + repository?: { + pullRequest?: { files?: { nodes?: Array<{ path: string; viewerViewedState?: string }> } } + } + } + )?.repository?.pullRequest?.files?.nodes ?? [] + for (const f of nodes) if (f.viewerViewedState === 'VIEWED') ghViewed.add(f.path) + } else { + log('github', `viewed-state query failed: ${viewedQuery.error}`) + } + const mergedReviewed = [...new Set([...input.reviewedFiles, ...ghViewed])] + + // 5. Pull the canonical comment set: published comments + our pending + // review's drafts (so freshly-pushed drafts get an id and don't re-post). + const pulled: ReviewSyncComment[] = [] + type ApiComment = { + id: number + path: string + line?: number | null + original_line?: number | null + start_line?: number | null + original_start_line?: number | null + body?: string + created_at?: string + html_url?: string + in_reply_to_id?: number + user?: { login?: string; avatar_url?: string } + } + const collect = (arr: ApiComment[]): void => { + for (const rc of arr) { + const start = rc.start_line ?? rc.original_start_line ?? undefined + pulled.push({ + filePath: rc.path, + lineNumber: rc.line ?? rc.original_line ?? 0, + startLine: start ?? undefined, + body: rc.body ?? '', + remoteId: rc.id, + author: rc.user?.login, + authorAvatarUrl: rc.user?.avatar_url, + createdAt: rc.created_at, + htmlUrl: rc.html_url, + draft: false, + inReplyToId: rc.in_reply_to_id + }) + } + } + try { + const published = await ghRequest(token, `${base}/comments?per_page=100`, 'GET') + if (published.ok && Array.isArray(published.json)) collect(published.json as ApiComment[]) + } catch (err) { + log('github', 'list review comments error', formatErr(err)) + } + // Pending (draft) review comments come back from REST with a null line, so + // pull them via GraphQL where originalLine is populated. + const draftQuery = await ghGraphQL( + token, + 'query($o:String!,$n:String!,$num:Int!){repository(owner:$o,name:$n){pullRequest(number:$num){reviews(first:20,states:[PENDING]){nodes{comments(first:100){nodes{databaseId path line originalLine startLine originalStartLine body createdAt url replyTo{databaseId} author{login avatarUrl}}}}}}}}', + { o: owner, n: repo, num: prNumber } + ) + if (draftQuery.ok) { + type GqlComment = { + databaseId?: number + path?: string + line?: number | null + originalLine?: number | null + startLine?: number | null + originalStartLine?: number | null + body?: string + createdAt?: string + url?: string + replyTo?: { databaseId?: number } + author?: { login?: string; avatarUrl?: string } + } + const reviews = + ( + draftQuery.data as { + repository?: { + pullRequest?: { reviews?: { nodes?: Array<{ comments?: { nodes?: GqlComment[] } }> } } + } + } + )?.repository?.pullRequest?.reviews?.nodes ?? [] + for (const rev of reviews) { + for (const c of rev?.comments?.nodes ?? []) { + if (typeof c.databaseId !== 'number' || !c.path) continue + pulled.push({ + filePath: c.path, + lineNumber: c.line ?? c.originalLine ?? 0, + startLine: c.startLine ?? c.originalStartLine ?? undefined, + body: c.body ?? '', + remoteId: c.databaseId, + author: c.author?.login, + authorAvatarUrl: c.author?.avatarUrl, + createdAt: c.createdAt, + htmlUrl: c.url, + draft: true, + inReplyToId: c.replyTo?.databaseId + }) + } + } + } else { + log('github', `pending review comments query failed: ${draftQuery.error}`) + } + + // Drafts pulled from a pending review can come back with a null/0 line. + // Recover it from the matching local comment (same file + body) so the + // pulled copy renders at the right line AND dedupes against the local one + // — otherwise it lands at the top and the local copy is kept too. + for (const p of pulled) { + if (p.lineNumber === 0) { + const local = input.comments.find( + (c) => c.filePath === p.filePath && c.body === p.body && c.lineNumber > 0 + ) + if (local) p.lineNumber = local.lineNumber + } + } + + // Attach thread node id + resolved state to each pulled comment. + for (const p of pulled) { + if (p.remoteId !== undefined) { + const ti = threadInfo.get(p.remoteId) + if (ti) { + p.threadId = ti.threadId + p.resolved = ti.resolved + } + } + } + + // Collapse identical duplicates (e.g. drafts accidentally pushed twice by + // earlier syncs) so they don't pile up. Keyed on file+line+body+author so + // distinct reviewers' identical text isn't merged. + const seen = new Set() + const dedupedPulled = pulled.filter((c) => { + const k = `${c.filePath}:${c.lineNumber}:${c.body}:${c.author ?? ''}` + if (seen.has(k)) return false + seen.add(k) + return true + }) + + // Keep local comments not represented in the pulled set — file-level + // comments (no draft support) and any that failed to push — so they're + // not lost and can retry next sync. + const pulledKey = new Set(dedupedPulled.map((c) => `${c.filePath}:${c.lineNumber}:${c.body}`)) + const keptLocal = input.comments.filter( + (c) => c.remoteId === undefined && !pulledKey.has(`${c.filePath}:${c.lineNumber}:${c.body}`) + ) + + return { + ok: true, + comments: [...dedupedPulled, ...keptLocal], + reviewedFiles: mergedReviewed, + pushed, + failed + } +} + /** Test a token by making an authenticated request to /user. Returns the username if valid. */ export async function testToken(token: string): Promise<{ ok: boolean; username?: string; error?: string }> { try { diff --git a/src/main/index.ts b/src/main/index.ts index 32718428..21c22c7a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -38,7 +38,8 @@ import { getWeeklyStats } from './weekly-stats' import type { TerminalTab, PaneNode, PaneLeaf } from '../shared/state/terminals' import { getLeaves, mapLeaves } from '../shared/state/terminals' import { listWorktrees, listBranches, continueWorktree, isWorktreeDirty, defaultWorktreeDir, getChangedFiles, getFileDiff, getBranchCommits, getCommitDiff, getCommitMeta, getCommitChangedFiles, getCommitFileDiffSides, getCommitRangeChangedFiles, getCommitRangeFileDiffSides, getMainWorktreeStatus, prepareMainForMerge, mergeWorktreeLocally, getBranchSha, previewMergeConflicts, getBranchDiffStats, listAllFiles, listRecentCommitShas, readWorktreeFile, readWorktreeFileBinary, writeWorktreeFile, getFileDiffSides, getCurrentBranch, symlinkClaudeSettings, type MergeStrategy } from './worktree' -import { listOpenPRs, testToken, starRepo, unstarRepo, isRepoStarred, mergePR, approvePR, getRepoInfo, type GitHubMergeMethod, type MergePRResult } from './github' +import { listOpenPRs, testToken, starRepo, unstarRepo, isRepoStarred, mergePR, approvePR, submitPRReview, getRepoInfo, getRepoContext, syncPRReview, type GitHubMergeMethod, type MergePRResult } from './github' +import type { ReviewSyncInput, ReviewSyncResult } from '../shared/github-types' import { AVAILABLE_EDITORS, DEFAULT_EDITOR_ID, openInEditor } from './editor' import { setSecret, getSecret, hasSecret, deleteSecret } from './secrets' import { resolveGitHubToken, getTokenSource, invalidateTokenCache, getCachedToken } from './github-auth' @@ -1589,6 +1590,62 @@ function registerIpcHandlers(): void { } ) + transport.onRequest( + 'pr:submitReview', + async ( + _ctx, + worktreePath: string, + event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT', + body: string + ): Promise<{ ok: true } | { ok: false; error: string }> => { + const cached = store.getSnapshot().state.prs.byPath[worktreePath] + let prNumber = cached?.number + if (typeof prNumber !== 'number') { + await prPoller.refreshOne(worktreePath) + prNumber = store.getSnapshot().state.prs.byPath[worktreePath]?.number + } + if (typeof prNumber !== 'number') { + return { ok: false, error: 'No pull request found for this worktree' } + } + const result = await submitPRReview(worktreePath, prNumber, event, body) + if (result.ok) { + void prPoller.refreshOne(worktreePath) + } + return result + } + ) + + transport.onRequest( + 'review:sync', + async (_ctx, worktreePath: string, input: ReviewSyncInput): Promise => { + const passthrough = { + comments: input.comments, + reviewedFiles: input.reviewedFiles, + pushed: 0, + failed: 0 + } + const token = getCachedToken() + if (!token) { + return { ok: false, error: 'Connect a GitHub token in Settings to sync', ...passthrough } + } + // PRs (incl. fork PRs) live on the upstream repo — that's where the + // PR number the poller cached came from. + const ctx = await getRepoContext(worktreePath) + if (!ctx) { + return { ok: false, error: 'Could not resolve GitHub repo from worktree origin', ...passthrough } + } + let prNumber = store.getSnapshot().state.prs.byPath[worktreePath]?.number + if (typeof prNumber !== 'number') { + await prPoller.refreshOne(worktreePath) + prNumber = store.getSnapshot().state.prs.byPath[worktreePath]?.number + } + if (typeof prNumber !== 'number') { + return { ok: false, error: 'No pull request found for this worktree', ...passthrough } + } + return syncPRReview(token, ctx.upstream.owner, ctx.upstream.repo, prNumber, input) + } + ) + transport.onRequest('stats:getWeekly', async (_ctx) => { const snap = store.getSnapshot().state return getWeeklyStats(snap.prs, snap.worktrees) @@ -2083,6 +2140,18 @@ function registerIpcHandlers(): void { return true }) + transport.onRequest('config:setReviewDiffMode', (_ctx, mode: string) => { + if (mode !== 'match' && mode !== 'light' && mode !== 'dark') return false + if (mode === 'match') { + delete config.reviewDiffMode + } else { + config.reviewDiffMode = mode + } + saveConfig(config) + store.dispatch({ type: 'settings/reviewDiffModeChanged', payload: mode }) + return true + }) + // Accept built-in IDs (validated against AVAILABLE_THEMES) and any // currently-loaded custom theme id of the matching mode. Custom IDs // are filename-derived and not statically known, so the live slice is diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 58489380..36a4cfbd 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -103,6 +103,9 @@ export interface Config { themeLight?: string // Theme id used when `themeMode` resolves to 'dark'. Default 'dark'. themeDark?: string + // Appearance override for the review tab's code editors. Default 'match' + // (follow the app theme); 'light'/'dark' pin the diffs to that palette. + reviewDiffMode?: 'match' | 'light' | 'dark' // App-background hex the renderer last applied. Used at next boot to // pick the BrowserWindow background color so the first paint doesn't // flash. Written from the renderer via a fire-and-forget IPC after each @@ -278,6 +281,7 @@ export const AVAILABLE_THEMES = [ 'one-dark', 'solarized-dark', 'solarized-light', + 'hub-delight', 'cyberfunk' ] as const @@ -293,6 +297,7 @@ export const THEME_APP_BG: Record = { 'one-dark': '#282c34', 'solarized-dark': '#002b36', 'solarized-light': '#fdf6e3', + 'hub-delight': '#ffffff', 'cyberfunk': '#000000' } diff --git a/src/main/pr-poller.test.ts b/src/main/pr-poller.test.ts index 8aba0e12..6bf13715 100644 --- a/src/main/pr-poller.test.ts +++ b/src/main/pr-poller.test.ts @@ -25,6 +25,7 @@ function fakePRStatus(number: number): PRStatus { return { number, title: `PR ${number}`, + body: '', state: 'open', url: '', branch: '', diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d36f64f7..83c29df9 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -13,6 +13,7 @@ import { SCALES, scaleSpec } from '../shared/state/settings' import { useActiveTheme } from './hooks/useActiveTheme' import { useHoldToQuit } from './hooks/useHoldToQuit' import { applyTheme, effectiveAppBg } from './theme-apply' +import { setReviewDiffMode } from './monaco-setup' import { getBackend } from './backend' import { HotkeysProvider, Tooltip } from './components/Tooltip' import { Sidebar } from './components/Sidebar' @@ -25,6 +26,7 @@ import { QuestCard } from './components/QuestCard' import { WorkspaceView } from './components/WorkspaceView' import { QuakeTerminal } from './components/QuakeTerminal' import { RightColumn } from './components/RightColumn' +import { requestReviewFile } from './review-open-file' import { CollapsedSidebar } from './components/CollapsedSidebar' import { CollapsedRightPanel } from './components/CollapsedRightPanel' import { Settings } from './components/Settings' @@ -74,10 +76,16 @@ const TITLE_LEADING_PX = 80 export default function App(): JSX.Element { const { isMobile } = useViewport() const active = useActiveTheme() + const { reviewDiffMode, themeLight, themeDark } = useSettings() useEffect(() => { applyTheme(active) getBackend().setLastEffectiveAppBg(effectiveAppBg(active)) }, [active]) + // Push the review-diff appearance override into Monaco. Runs after + // applyTheme so the 'match' path reads the freshly-applied :root palette. + useEffect(() => { + setReviewDiffMode(reviewDiffMode, themeLight, themeDark) + }, [reviewDiffMode, themeLight, themeDark, active]) if (isMobile) return return } @@ -1569,6 +1577,7 @@ const setQuestStep = useCallback((next: QuestStep) => { onReorderTabs={handleReorderTabs} onMoveTabToPane={handleMoveTabToPane} onSendToAgent={handleSendToAgent} + onOpenFile={(_wtPath, filePath) => handleOpenFile(filePath)} topBarLeadingPx={TITLE_LEADING_PX} hideAppTitle={singleScreenMode} onTitleBlockEdge={isVisible ? handleTitleBlockEdge : undefined} @@ -1725,6 +1734,11 @@ const setQuestStep = useCallback((next: QuestStep) => { onOpenReview={() => { if (activeWorktreeId) void backend.panesOpenReview(activeWorktreeId) }} + onOpenReviewFile={(filePath) => { + if (!activeWorktreeId) return + requestReviewFile(activeWorktreeId, filePath) + void backend.panesOpenReview(activeWorktreeId) + }} onCollapse={() => setRightColumnHidden(true)} /> )} diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts index c2ccab45..5573750b 100644 --- a/src/renderer/build-backend.ts +++ b/src/renderer/build-backend.ts @@ -39,6 +39,7 @@ import type { StateEventListener } from '../shared/transport/transport' import type { ElectronAPI } from './types' +import type { ReviewSyncInput } from '../shared/github-types' export type { ElectronOnlyHelpers } @@ -222,7 +223,14 @@ export function buildBackend( listRepoPRs: (repoRoot: string) => req('prs:listOpen', repoRoot), mergePR: (worktreePath: string, method: 'merge' | 'squash' | 'rebase') => req('pr:merge', worktreePath, method), + reviewSync: (worktreePath: string, input: ReviewSyncInput) => + req('review:sync', worktreePath, input), approvePR: (worktreePath: string) => req('pr:approve', worktreePath), + submitPRReview: ( + worktreePath: string, + event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT', + body: string + ) => req('pr:submitReview', worktreePath, event, body), getWeeklyStats: () => req('stats:getWeekly'), @@ -290,6 +298,8 @@ export function buildBackend( setThemeMode: (mode: 'light' | 'dark' | 'system') => req('config:setThemeMode', mode), setThemeLight: (theme: string) => req('config:setThemeLight', theme), setThemeDark: (theme: string) => req('config:setThemeDark', theme), + setReviewDiffMode: (mode: 'match' | 'light' | 'dark') => + req('config:setReviewDiffMode', mode), setLastEffectiveAppBg: (hex: string) => sig('config:setLastEffectiveAppBg', hex), reloadCustomThemes: () => req('config:reloadCustomThemes'), openThemesFolder: () => reqLocal('config:openThemesFolder'), diff --git a/src/renderer/components/ChangedFilesPanel.tsx b/src/renderer/components/ChangedFilesPanel.tsx index 16798073..17595357 100644 --- a/src/renderer/components/ChangedFilesPanel.tsx +++ b/src/renderer/components/ChangedFilesPanel.tsx @@ -13,6 +13,10 @@ interface ChangedFilesPanelProps { onOpenDiff: (filePath: string, staged: boolean, mode: Mode) => void onSendToAgent?: (text: string) => void onOpenReview?: () => void + /** Open the worktree's Review tab focused on this committed file. */ + onOpenReviewFile?: (filePath: string) => void + /** Open a file in the in-Harness editor tab (⌘-click on the editor icon). */ + onOpenFile?: (filePath: string) => void } const STATUS_LABEL: Record = { @@ -36,7 +40,7 @@ interface ChangedFilesData { branch: ChangedFile[] } -export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onOpenReview }: ChangedFilesPanelProps): JSX.Element { +export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onOpenReview, onOpenReviewFile, onOpenFile }: ChangedFilesPanelProps): JSX.Element { const backend = useBackend() const fetcher = useCallback(async (path: string): Promise => { const [working, branch] = await Promise.all([ @@ -58,7 +62,6 @@ export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onO const stagedFiles = workingFiles.filter((f) => f.staged) const unstagedFiles = workingFiles.filter((f) => !f.staged) - const totalCount = workingFiles.length + branchFiles.length const actions = ( <> @@ -100,8 +103,11 @@ export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onO {worktreePath && hasLoaded && ( <> {/* Uncommitted section */} -
- Uncommitted +
+ Uncommitted + {workingFiles.length > 0 && ( + {workingFiles.length} + )}
{workingFiles.length === 0 ? (
No changes
@@ -119,6 +125,7 @@ export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onO worktreePath={worktreePath} onClick={() => onOpenDiff(file.path, true, 'working')} onSendToAgent={onSendToAgent} + onOpenFile={onOpenFile} /> ))} {stagedFiles.length > 0 && unstagedFiles.length > 0 && ( @@ -133,14 +140,18 @@ export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onO worktreePath={worktreePath} onClick={() => onOpenDiff(file.path, false, 'working')} onSendToAgent={onSendToAgent} + onOpenFile={onOpenFile} /> ))} )} {/* Branch diff section */} -
- Committed +
+ Committed + {branchFiles.length > 0 && ( + {branchFiles.length} + )}
{branchFiles.length === 0 ? (
No commits on this branch yet
@@ -150,8 +161,13 @@ export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onO key={`branch-${file.path}`} file={file} worktreePath={worktreePath} - onClick={() => onOpenDiff(file.path, false, 'branch')} + onClick={() => + onOpenReviewFile + ? onOpenReviewFile(file.path) + : onOpenDiff(file.path, false, 'branch') + } onSendToAgent={onSendToAgent} + onOpenFile={onOpenFile} /> )) )} @@ -159,13 +175,6 @@ export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onO )}
- {totalCount > 0 && ( -
- {workingFiles.length > 0 && {workingFiles.length} uncommitted} - {workingFiles.length > 0 && branchFiles.length > 0 && · } - {branchFiles.length > 0 && {branchFiles.length} committed} -
- )} ) } @@ -174,12 +183,14 @@ function FileRow({ file, worktreePath, onClick, - onSendToAgent + onSendToAgent, + onOpenFile }: { file: ChangedFile worktreePath: string | null onClick: () => void onSendToAgent?: (text: string) => void + onOpenFile?: (filePath: string) => void }): JSX.Element { const backend = useBackend() const lastSlash = file.path.lastIndexOf('/') @@ -231,11 +242,12 @@ function FileRow({ )} {worktreePath && ( - + + + )} + {filePath} - {dirty && } - {saveError && ( - - {saveError} - - )} + {!branchDiff && Uncommitted} {staged && !branchDiff && staged} {branchDiff && branch} {!sides.originalExists && new file} @@ -167,14 +178,14 @@ function FileDiffView({ {wordWrap ? : } - {editable && ( - + + {onOpenEditor && filePath && sides.modifiedExists && ( + )} @@ -188,34 +199,72 @@ function FileDiffView({ )} + {filePath && ( + + + + )}
- {readOnlyBanner && ( -
- {readOnlyBanner} -
- )}
- onSendToAgent(`@${filePath}:${ln} `) - : undefined - } - /> + {viewMode === 'full' ? ( + + ) : ( + + )}
) } +export function ModeButton({ + active, + label, + hint, + onClick +}: { + active: boolean + label: string + hint?: string + onClick: () => void +}): JSX.Element { + return ( + + + + ) +} + // Commit diffs are whole-commit, multi-file text. Monaco's inline diff is // per-file, so commit view keeps the legacy parsed rendering for now. // Fold into Monaco by rendering one editor per changed file in a follow-up. diff --git a/src/renderer/components/FileView.tsx b/src/renderer/components/FileView.tsx index 00be14e4..51e9e09e 100644 --- a/src/renderer/components/FileView.tsx +++ b/src/renderer/components/FileView.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ArrowRightFromLine, AtSign, Code2, Eye, Save, WrapText } from 'lucide-react' +import { ArrowRightFromLine, AtSign, Code2, FileInput, FileOutput, Save, WrapText } from 'lucide-react' import ReactMarkdown from 'react-markdown' import rehypeHighlight from 'rehype-highlight' import remarkGfm from 'remark-gfm' @@ -9,9 +9,11 @@ import { MonacoEditor } from './MonacoEditor' import { useSettings } from '../store' import { useBackend } from '../backend' import { scaledEditorFontSize } from '../../shared/state/settings' +import { setTabDirty, clearTabDirty } from '../dirty-tabs' import 'highlight.js/styles/github-dark.css' interface FileViewProps { + tabId: string worktreePath: string filePath?: string onSendToAgent?: (text: string) => void @@ -48,7 +50,7 @@ function formatBytes(n: number): string { return `${(n / (1024 * 1024)).toFixed(1)} MB` } -export function FileView({ worktreePath, filePath, onSendToAgent }: FileViewProps): JSX.Element { +export function FileView({ tabId, worktreePath, filePath, onSendToAgent }: FileViewProps): JSX.Element { const backend = useBackend() const settings = useSettings() const [result, setResult] = useState(null) @@ -68,6 +70,15 @@ export function FileView({ worktreePath, filePath, onSendToAgent }: FileViewProp const dirty = value !== savedValue + // Publish unsaved state so handleCloseTab can warn before closing this + // tab; clear the entry when the tab unmounts. + useEffect(() => { + setTabDirty(tabId, dirty) + }, [tabId, dirty]) + useEffect(() => { + return () => clearTabDirty(tabId) + }, [tabId]) + const mode: ViewerMode = useMemo( () => (filePath ? detectViewerMode(filePath) : 'text'), [filePath] @@ -221,7 +232,7 @@ export function FileView({ worktreePath, filePath, onSendToAgent }: FileViewProp onClick={() => setMarkdownAsCode((v) => !v)} className="shrink-0 text-faint hover:text-fg cursor-pointer" > - {markdownAsCode ? : } + {markdownAsCode ? : } ) : null @@ -322,13 +333,29 @@ function FileHeader({ const backend = useBackend() return (
+ {showSave && ( + + + + )} {filePath} - {dirty && } {saveError && ( @@ -339,17 +366,6 @@ function FileHeader({ {truncated && truncated} {wrapToggleControl} {toggleControl} - {showSave && ( - - - - )} {onSendToAgent && ( + )} +
+
+
+ + {comment.body} + +
+ {clamped && ( +
+ )} +
+ {collapsible && ( + + )} +
+ ) +} + +/** A comment thread: the root comment followed by its replies, each inset + * and touching the one above so the reply relationship reads visually. A + * footer holds the reply field and resolve control. */ +function CommentThread({ + thread, + onDelete, + forceExpanded, + onAddReply, + onResolveThread, + pendingResolve +}: { + thread: ReviewComment[] + onDelete: (id: string) => void + forceExpanded?: boolean + onAddReply: ( + root: { filePath: string; lineNumber: number; remoteId: number }, + body: string + ) => void + onResolveThread: (threadId: string) => void + pendingResolve: ReadonlySet +}): JSX.Element { + const root = thread[0] + const [replying, setReplying] = useState(false) + const [replyBody, setReplyBody] = useState('') + const replyRef = useRef(null) + useEffect(() => { + if (replying) replyRef.current?.focus() + }, [replying]) + + const canReply = root.remoteId !== undefined + const resolved = thread.some((c) => c.resolved) + const resolving = !!root.threadId && pendingResolve.has(root.threadId) + const canResolve = !!root.threadId && !resolved && !root.draft + + const submitReply = (): void => { + const body = replyBody.trim() + if (!body || root.remoteId === undefined) return + onAddReply({ filePath: root.filePath, lineNumber: root.lineNumber, remoteId: root.remoteId }, body) + setReplyBody('') + setReplying(false) + } + + return ( +
+ {thread.map((c, i) => ( +
+ onDelete(c.id)} forceExpanded={forceExpanded} /> +
+ ))} + +
1 ? 16 : 0, + fontSize: '11px' }} - onMouseOver={(e) => (e.currentTarget.style.color = 'var(--color-danger, #f85149)')} - onMouseOut={(e) => (e.currentTarget.style.color = 'var(--color-faint)')} > - ✕ - + {canReply && !replying && ( + + )} + {resolved ? ( + + Resolved + + ) : resolving ? ( + Resolving on next sync… + ) : canResolve ? ( + + ) : null} +
+ + {replying && ( +
1 ? 16 : 0 }}> +