Commit47a7a0aunknown_key
feat(pr-review): user inline diff comments — click any line to comment
feat(pr-review): user inline diff comments — click any line to comment Implements the "Files changed" tab click-to-comment workflow: - Hover a diff line → "+" button appears in the new-file gutter - Click "+" → inline textarea form drops below the line - Submit POSTs to the existing comment endpoint with file_path + line_number - POST endpoint now persists filePath + lineNumber on prComments rows - After posting, redirects back to ?tab=files so the comment is visible DiffView now accepts: - inlineComments: InlineDiffComment[] — rendered anchored to their lines - commentActionUrl — shown to authenticated users only Inline comments from AI review are also shown in the diff (AI badge, purple accent border). Non-file comments continue to work unchanged. https://claude.ai/code/session_01ACsT2Pc8GRoZwZRF8SK68Y
2 files changed+254−2847a7a0a7d39d80dace8f76ab1054395209c1941a
2 changed files+254−28
Modifiedsrc/routes/pulls.tsx+47−3View fileUnifiedSplit
@@ -27,7 +27,7 @@ import {
2727import { Layout } from "../views/layout";
2828import { RepoHeader } from "../views/components";
2929import { PendingCommentsBanner } from "../views/pending-comments-banner";
30import { DiffView } from "../views/diff-view";
30import { DiffView, type InlineDiffComment } from "../views/diff-view";
3131import { ReactionsBar } from "../views/reactions";
3232import { summariseReactions } from "../lib/reactions";
3333import { loadPrTemplate } from "../lib/templates";
@@ -2272,9 +2272,10 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
22722272 pr.headBranch
22732273 );
22742274
2275 // Get diff for "Files changed" tab
2275 // Get diff for "Files changed" tab + load inline comments for that tab
22762276 let diffRaw = "";
22772277 let diffFiles: GitDiffFile[] = [];
2278 let diffInlineComments: InlineDiffComment[] = [];
22782279 if (tab === "files") {
22792280 const repoDir = getRepoPath(ownerName, repoName);
22802281 // Run the two git diffs in parallel — they're independent reads of
@@ -2319,6 +2320,39 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
23192320 patch: "",
23202321 };
23212322 });
2323
2324 // Fetch inline comments (file+line anchored) for the files tab
2325 const inlineRows = await db
2326 .select({
2327 id: prComments.id,
2328 filePath: prComments.filePath,
2329 lineNumber: prComments.lineNumber,
2330 body: prComments.body,
2331 isAiReview: prComments.isAiReview,
2332 createdAt: prComments.createdAt,
2333 authorUsername: users.username,
2334 })
2335 .from(prComments)
2336 .innerJoin(users, eq(prComments.authorId, users.id))
2337 .where(
2338 and(
2339 eq(prComments.pullRequestId, pr.id),
2340 eq(prComments.moderationStatus, "approved"),
2341 )
2342 )
2343 .orderBy(asc(prComments.createdAt));
2344
2345 diffInlineComments = inlineRows
2346 .filter(r => r.filePath != null && r.lineNumber != null)
2347 .map(r => ({
2348 id: r.id,
2349 filePath: r.filePath!,
2350 lineNumber: r.lineNumber!,
2351 authorUsername: r.authorUsername,
2352 body: renderMarkdown(r.body),
2353 isAiReview: r.isAiReview,
2354 createdAt: r.createdAt.toISOString(),
2355 }));
23222356 }
23232357
23242358 // ─── Derived visual state ───
@@ -2475,6 +2509,8 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
24752509 raw={diffRaw}
24762510 files={diffFiles}
24772511 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
2512 inlineComments={diffInlineComments}
2513 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
24782514 />
24792515 ) : (
24802516 <>
@@ -2835,6 +2871,10 @@ pulls.post(
28352871 const user = c.get("user")!;
28362872 const body = await c.req.parseBody();
28372873 const commentBody = String(body.body || "").trim();
2874 const filePathRaw = String(body.file_path || "").trim();
2875 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
2876 const inlineFilePath = filePathRaw || undefined;
2877 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
28382878
28392879 if (!commentBody) {
28402880 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
@@ -2870,6 +2910,8 @@ pulls.post(
28702910 authorId: user.id,
28712911 body: commentBody,
28722912 moderationStatus: decision.status,
2913 filePath: inlineFilePath,
2914 lineNumber: inlineLineNumber,
28732915 })
28742916 .returning();
28752917
@@ -2944,7 +2986,9 @@ pulls.post(
29442986 }
29452987 }
29462988
2947 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2989 // Inline comments go back to the files tab; conversation comments to the conversation tab
2990 const redirectTab = inlineFilePath ? "?tab=files" : "";
2991 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
29482992 }
29492993);
29502994
Modifiedsrc/views/diff-view.tsx+207−25View fileUnifiedSplit
@@ -308,16 +308,39 @@ const StatPills: FC<{ add: number; del: number }> = ({ add, del }) => (
308308
309309// ─── Public component ──────────────────────────────────────────────────
310310
311export interface InlineDiffComment {
312 id: string;
313 filePath: string;
314 lineNumber: number;
315 authorUsername: string;
316 body: string;
317 createdAt: string;
318 isAiReview?: boolean;
319}
320
311321export interface DiffViewProps {
312322 raw: string;
313323 files: GitDiffFile[];
314324 /** When set, file headers gain "View file" links to `${viewFileBase}/${path}`. */
315325 viewFileBase?: string;
326 /** Existing inline comments to render anchored to their file+line */
327 inlineComments?: InlineDiffComment[];
328 /** URL to POST a new inline comment to (shows gutter "+" buttons when set) */
329 commentActionUrl?: string;
316330}
317331
318export const DiffView: FC<DiffViewProps> = ({ raw, files, viewFileBase }) => {
332export const DiffView: FC<DiffViewProps> = ({ raw, files, viewFileBase, inlineComments, commentActionUrl }) => {
319333 const parsed = parseUnifiedDiff(raw);
320334
335 // Build a lookup map: "filePath:lineNumber" → inline comments
336 const commentsByLine = new Map<string, InlineDiffComment[]>();
337 for (const c of inlineComments ?? []) {
338 const key = `${c.filePath}:${c.lineNumber}`;
339 const arr = commentsByLine.get(key) ?? [];
340 arr.push(c);
341 commentsByLine.set(key, arr);
342 }
343
321344 // Stat fallback: if --numstat gave us per-file counts they trump our
322345 // hunk-based count (only --numstat sees binary deltas accurately).
323346 const statByPath = new Map<string, { add: number; del: number }>();
@@ -455,22 +478,45 @@ export const DiffView: FC<DiffViewProps> = ({ raw, files, viewFileBase }) => {
455478 }
456479 const marker =
457480 ln.kind === "add" ? "+" : ln.kind === "del" ? "−" : " ";
481 // Inline comments anchor to the new-file line number
482 const commentKey = ln.newNum != null ? `${file.path}:${ln.newNum}` : null;
483 const lineComments = commentKey ? (commentsByLine.get(commentKey) ?? []) : [];
484 const canComment = commentActionUrl && ln.kind !== "del" && ln.newNum != null;
458485 return (
459 <div
460 class={`diff-row diff-row-${ln.kind}`}
461 data-line={key}
462 >
463 <span class="diff-gutter diff-gutter-old">
464 {ln.oldNum ?? ""}
465 </span>
466 <span class="diff-gutter diff-gutter-new">
467 {ln.newNum ?? ""}
468 </span>
469 <span class="diff-marker" aria-hidden="true">
470 {marker}
471 </span>
472 <CodeSpan html={highlighted} text={ln.text} />
473 </div>
486 <>
487 <div
488 class={`diff-row diff-row-${ln.kind}`}
489 data-line={key}
490 data-file={canComment ? file.path : undefined}
491 data-newline={canComment ? ln.newNum : undefined}
492 >
493 <span class="diff-gutter diff-gutter-old">
494 {ln.oldNum ?? ""}
495 </span>
496 <span class="diff-gutter diff-gutter-new">
497 {ln.newNum ?? ""}
498 {canComment && (
499 <button class="diff-comment-btn" title="Add comment" aria-label="Add inline comment">+</button>
500 )}
501 </span>
502 <span class="diff-marker" aria-hidden="true">
503 {marker}
504 </span>
505 <CodeSpan html={highlighted} text={ln.text} />
506 </div>
507 {lineComments.map(c => (
508 <div class={`diff-inline-comment${c.isAiReview ? " diff-inline-comment-ai" : ""}`} data-comment-id={c.id}>
509 <div class="diff-inline-comment-head">
510 <strong>{c.authorUsername}</strong>
511 <span class="diff-inline-comment-meta">
512 {c.isAiReview && <span class="diff-inline-ai-badge">AI</span>}
513 {new Date(c.createdAt).toLocaleDateString()}
514 </span>
515 </div>
516 <div class="diff-inline-comment-body" dangerouslySetInnerHTML={{ __html: c.body }} />
517 </div>
518 ))}
519 </>
474520 );
475521 })}
476522 </>
@@ -481,6 +527,9 @@ export const DiffView: FC<DiffViewProps> = ({ raw, files, viewFileBase }) => {
481527 );
482528 })}
483529
530 {commentActionUrl && (
531 <meta name="diff-comment-url" content={commentActionUrl} />
532 )}
484533 <script dangerouslySetInnerHTML={{ __html: DIFF_VIEW_JS }} />
485534 </div>
486535 );
@@ -490,18 +539,55 @@ export const DiffView: FC<DiffViewProps> = ({ raw, files, viewFileBase }) => {
490539
491540const DIFF_VIEW_JS = `
492541(function () {
542 // Copy-path button
493543 document.addEventListener('click', function (e) {
494544 var t = e.target;
495545 if (!t) return;
496 var btn = t.closest && t.closest('.diff-file-copy');
497 if (!btn) return;
498 e.preventDefault();
499 var path = btn.getAttribute('data-copy') || '';
500 if (!navigator.clipboard) return;
501 navigator.clipboard.writeText(path).then(function () {
502 btn.classList.add('is-copied');
503 setTimeout(function () { btn.classList.remove('is-copied'); }, 1200);
504 }).catch(function () {});
546 // Copy path button
547 var copyBtn = t.closest && t.closest('.diff-file-copy');
548 if (copyBtn) {
549 e.preventDefault();
550 var path = copyBtn.getAttribute('data-copy') || '';
551 if (!navigator.clipboard) return;
552 navigator.clipboard.writeText(path).then(function () {
553 copyBtn.classList.add('is-copied');
554 setTimeout(function () { copyBtn.classList.remove('is-copied'); }, 1200);
555 }).catch(function () {});
556 return;
557 }
558 // Inline comment "+" button
559 var commentBtn = t.closest && t.closest('.diff-comment-btn');
560 if (commentBtn) {
561 e.preventDefault();
562 var row = commentBtn.closest('.diff-row');
563 if (!row) return;
564 var filePath = row.getAttribute('data-file');
565 var lineNum = row.getAttribute('data-newline');
566 var actionUrl = document.querySelector('meta[name="diff-comment-url"]');
567 if (!filePath || !lineNum || !actionUrl) return;
568 // Remove any existing open form
569 var existing = document.querySelector('.diff-inline-form-row');
570 if (existing) {
571 if (existing.previousSibling === row) { existing.remove(); return; }
572 existing.remove();
573 }
574 // Build a form row
575 var form = document.createElement('form');
576 form.method = 'POST';
577 form.action = actionUrl.getAttribute('content') || '';
578 form.className = 'diff-inline-form-row';
579 form.innerHTML =
580 '<input type="hidden" name="file_path" value="' + filePath.replace(/"/g,'"') + '">' +
581 '<input type="hidden" name="line_number" value="' + lineNum + '">' +
582 '<textarea name="body" rows="3" placeholder="Leave a comment…" class="diff-inline-textarea" required></textarea>' +
583 '<div class="diff-inline-form-actions">' +
584 '<button type="submit" class="diff-inline-submit">Comment</button>' +
585 '<button type="button" class="diff-inline-cancel">Cancel</button>' +
586 '</div>';
587 form.querySelector('.diff-inline-cancel').addEventListener('click', function () { form.remove(); });
588 row.insertAdjacentElement('afterend', form);
589 form.querySelector('textarea').focus();
590 }
505591 });
506592})();
507593`;
@@ -792,6 +878,102 @@ const DIFF_VIEW_CSS = `
792878
793879 .diff-row:hover .diff-gutter { opacity: 1; }
794880
881 /* ─── Inline comment "+" button ─── */
882 .diff-comment-btn {
883 display: none;
884 position: absolute;
885 right: 2px;
886 top: 50%;
887 transform: translateY(-50%);
888 width: 16px; height: 16px;
889 padding: 0;
890 background: var(--accent, #8c6dff);
891 color: #fff;
892 border: none;
893 border-radius: 3px;
894 font-size: 12px;
895 line-height: 1;
896 cursor: pointer;
897 z-index: 2;
898 }
899 .diff-gutter-new { position: relative; }
900 .diff-row:hover .diff-comment-btn { display: flex; align-items: center; justify-content: center; }
901
902 /* ─── Inline comments anchored to diff lines ─── */
903 .diff-inline-comment {
904 grid-column: 1 / -1;
905 display: block;
906 margin: 4px 0;
907 padding: 10px 14px;
908 background: var(--bg-elevated);
909 border-left: 3px solid var(--border);
910 border-radius: 0 4px 4px 0;
911 font-family: var(--font-sans, inherit);
912 font-size: 13px;
913 }
914 .diff-inline-comment-ai { border-left-color: #8c6dff; }
915 .diff-inline-comment-head {
916 display: flex; gap: 8px; align-items: center;
917 margin-bottom: 6px;
918 font-size: 12px;
919 color: var(--text-muted);
920 }
921 .diff-inline-comment-head strong { color: var(--text-strong); }
922 .diff-inline-ai-badge {
923 background: rgba(140,109,255,0.2);
924 color: #a78bfa;
925 border-radius: 3px;
926 padding: 1px 5px;
927 font-size: 10px;
928 font-weight: 600;
929 }
930 .diff-inline-comment-body { color: var(--text); line-height: 1.6; }
931
932 /* ─── Inline comment form ─── */
933 .diff-inline-form-row {
934 grid-column: 1 / -1;
935 display: block;
936 padding: 10px 14px;
937 background: var(--bg-elevated);
938 border-top: 1px solid var(--border);
939 border-bottom: 1px solid var(--border);
940 }
941 .diff-inline-textarea {
942 width: 100%;
943 min-height: 72px;
944 background: var(--bg);
945 color: var(--text);
946 border: 1px solid var(--border);
947 border-radius: 4px;
948 padding: 8px;
949 font-size: 13px;
950 font-family: var(--font-sans, inherit);
951 resize: vertical;
952 box-sizing: border-box;
953 }
954 .diff-inline-textarea:focus { outline: none; border-color: var(--accent, #8c6dff); }
955 .diff-inline-form-actions {
956 display: flex; gap: 8px; margin-top: 8px;
957 }
958 .diff-inline-submit {
959 background: var(--accent, #8c6dff);
960 color: #fff;
961 border: none;
962 border-radius: 5px;
963 padding: 6px 14px;
964 font-size: 13px;
965 cursor: pointer;
966 }
967 .diff-inline-cancel {
968 background: transparent;
969 color: var(--text-muted);
970 border: 1px solid var(--border);
971 border-radius: 5px;
972 padding: 6px 14px;
973 font-size: 13px;
974 cursor: pointer;
975 }
976
795977 /* Highlight.js theme overrides so colors layer correctly on tints */
796978 .diff-body.has-hljs .hljs-keyword { color: #ff7b72; }
797979 .diff-body.has-hljs .hljs-built_in,
798980