Commita2c10c5unknown_key
feat(review): batched PR review workflow + split diff view
feat(review): batched PR review workflow + split diff view Batched review: reviewers can now draft multiple inline comments then submit them as a formal review (Approve / Request Changes / Comment). Pending comments stored in pending_reviews + pending_review_comments tables (migration 0109). New routes: GET /review/pending, POST /review/pending/add, POST /review/pending/:id/delete, POST /review/submit. Sticky submit bar shows pending count. Split diff: side-by-side view toggle on every file header. Reads ?diffview=split query param. toSplitRows() pairs del/add lines. SplitDiffBody renders two-column table. Toggle links persist across navigation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WZuWnHv9RwxE87W6KESB9V
4 files changed+604−53a2c10c582b4d3bd12cacfd22358d986183c40517
4 changed files+604−53
Addeddrizzle/0109_pending_reviews.sql+22−0View fileUnifiedSplit
@@ -0,0 +1,22 @@
1-- Pending review drafts — inline comments staged before a reviewer submits.
2-- One pending_review row per (pr, author). Many pending_review_comments per review.
3CREATE TABLE IF NOT EXISTS pending_reviews (
4 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
5 pr_id UUID NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
6 author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
7 created_at TIMESTAMPTZ DEFAULT NOW(),
8 UNIQUE (pr_id, author_id)
9);
10
11CREATE TABLE IF NOT EXISTS pending_review_comments (
12 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
13 review_id UUID NOT NULL REFERENCES pending_reviews(id) ON DELETE CASCADE,
14 file_path TEXT NOT NULL,
15 line_number INTEGER NOT NULL,
16 body TEXT NOT NULL,
17 created_at TIMESTAMPTZ DEFAULT NOW()
18);
19
20CREATE INDEX IF NOT EXISTS idx_pending_reviews_pr ON pending_reviews(pr_id);
21CREATE INDEX IF NOT EXISTS idx_pending_reviews_author ON pending_reviews(pr_id, author_id);
22CREATE INDEX IF NOT EXISTS idx_pending_review_comments_review ON pending_review_comments(review_id);
Modifiedsrc/db/schema.ts+37−0View fileUnifiedSplit
@@ -4704,3 +4704,40 @@ export const repoAutomationSettings = pgTable(
47044704
47054705export type RepoAutomationSettings = typeof repoAutomationSettings.$inferSelect;
47064706export type NewRepoAutomationSettings = typeof repoAutomationSettings.$inferInsert;
4707
4708// ---------------------------------------------------------------------------
4709// Migration 0109 — Batched PR review workflow
4710// pending_reviews: one row per (pr, author) staging session.
4711// pending_review_comments: individual inline comments attached to a session.
4712// ---------------------------------------------------------------------------
4713export const pendingReviews = pgTable(
4714 "pending_reviews",
4715 {
4716 id: uuid("id").primaryKey().defaultRandom(),
4717 prId: uuid("pr_id").notNull().references(() => pullRequests.id, { onDelete: "cascade" }),
4718 authorId: uuid("author_id").notNull().references(() => users.id, { onDelete: "cascade" }),
4719 createdAt: timestamp("created_at").defaultNow(),
4720 },
4721 (table) => [
4722 uniqueIndex("pending_reviews_pr_author").on(table.prId, table.authorId),
4723 index("idx_pending_reviews_pr").on(table.prId),
4724 ]
4725);
4726
4727export const pendingReviewComments = pgTable(
4728 "pending_review_comments",
4729 {
4730 id: uuid("id").primaryKey().defaultRandom(),
4731 reviewId: uuid("review_id").notNull().references(() => pendingReviews.id, { onDelete: "cascade" }),
4732 filePath: text("file_path").notNull(),
4733 lineNumber: integer("line_number").notNull(),
4734 body: text("body").notNull(),
4735 createdAt: timestamp("created_at").defaultNow(),
4736 },
4737 (table) => [
4738 index("idx_pending_review_comments_review").on(table.reviewId),
4739 ]
4740);
4741
4742export type PendingReview = typeof pendingReviews.$inferSelect;
4743export type PendingReviewComment = typeof pendingReviewComments.$inferSelect;
Modifiedsrc/routes/pulls.tsx+240−0View fileUnifiedSplit
@@ -26,6 +26,8 @@ import {
2626 issues,
2727 issueComments,
2828 repoCollaborators,
29 pendingReviews,
30 pendingReviewComments,
2931} from "../db/schema";
3032import { Layout } from "../views/layout";
3133import { RepoHeader } from "../views/components";
@@ -3956,6 +3958,8 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
39563958 const prNum = parseInt(c.req.param("number"), 10);
39573959 const user = c.get("user");
39583960 const tab = c.req.query("tab") || "conversation";
3961 const isSplit = c.req.query("diffview") === "split";
3962 const pendingCount = parseInt(c.req.query("pending") || "0", 10) || 0;
39593963
39603964 const resolved = await resolveRepo(ownerName, repoName);
39613965 if (!resolved) return c.notFound();
@@ -4794,6 +4798,13 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
47944798 inlineComments={diffInlineComments}
47954799 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
47964800 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
4801 pendingReviewUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/review/pending/add` : undefined}
4802 submitReviewUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/review/submit` : undefined}
4803 isSplit={isSplit}
4804 pendingCount={pendingCount}
4805 owner={ownerName}
4806 repo={repoName}
4807 prNumber={pr.number}
47974808 />
47984809 </>
47994810 ) : (
@@ -5687,6 +5698,235 @@ pulls.post(
56875698 }
56885699);
56895700
5701// ─── Batched PR review workflow ─────────────────────────────────────────────
5702// Reviewers can stage multiple inline comments in a pending_reviews session,
5703// then submit them all at once as an Approve / Request Changes / Comment review.
5704
5705// GET /:owner/:repo/pulls/:number/review/pending
5706// Returns {count, comments} for the current user's pending review session.
5707pulls.get(
5708 "/:owner/:repo/pulls/:number/review/pending",
5709 softAuth,
5710 requireAuth,
5711 requireRepoAccess("read"),
5712 async (c) => {
5713 const { owner: ownerName, repo: repoName } = c.req.param();
5714 const prNum = parseInt(c.req.param("number"), 10);
5715 const user = c.get("user")!;
5716
5717 const resolved = await resolveRepo(ownerName, repoName);
5718 if (!resolved) return c.json({ count: 0, comments: [] });
5719
5720 const [pr] = await db
5721 .select()
5722 .from(pullRequests)
5723 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5724 .limit(1);
5725 if (!pr) return c.json({ count: 0, comments: [] });
5726
5727 const [review] = await db
5728 .select()
5729 .from(pendingReviews)
5730 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5731 .limit(1);
5732
5733 if (!review) return c.json({ count: 0, comments: [] });
5734
5735 const comments = await db
5736 .select({ id: pendingReviewComments.id, filePath: pendingReviewComments.filePath, lineNumber: pendingReviewComments.lineNumber, body: pendingReviewComments.body })
5737 .from(pendingReviewComments)
5738 .where(eq(pendingReviewComments.reviewId, review.id))
5739 .orderBy(asc(pendingReviewComments.createdAt));
5740
5741 return c.json({ count: comments.length, comments });
5742 }
5743);
5744
5745// POST /:owner/:repo/pulls/:number/review/pending/add
5746// Adds a comment to the current user's pending review (creates session if needed).
5747pulls.post(
5748 "/:owner/:repo/pulls/:number/review/pending/add",
5749 softAuth,
5750 requireAuth,
5751 requireRepoAccess("read"),
5752 async (c) => {
5753 const { owner: ownerName, repo: repoName } = c.req.param();
5754 const prNum = parseInt(c.req.param("number"), 10);
5755 const user = c.get("user")!;
5756 const body = await c.req.parseBody();
5757 const filePath = String(body.filePath || "").trim();
5758 const lineNumber = parseInt(String(body.lineNumber || ""), 10);
5759 const commentBody = String(body.body || "").trim();
5760
5761 if (!filePath || !Number.isFinite(lineNumber) || lineNumber <= 0 || !commentBody) {
5762 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5763 }
5764
5765 const resolved = await resolveRepo(ownerName, repoName);
5766 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5767
5768 const [pr] = await db
5769 .select()
5770 .from(pullRequests)
5771 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5772 .limit(1);
5773 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5774
5775 // Upsert the pending_reviews session row
5776 let [review] = await db
5777 .select()
5778 .from(pendingReviews)
5779 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5780 .limit(1);
5781
5782 if (!review) {
5783 [review] = await db
5784 .insert(pendingReviews)
5785 .values({ prId: pr.id, authorId: user.id })
5786 .onConflictDoNothing()
5787 .returning();
5788 // If onConflictDoNothing returned nothing (race), fetch again
5789 if (!review) {
5790 [review] = await db
5791 .select()
5792 .from(pendingReviews)
5793 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5794 .limit(1);
5795 }
5796 }
5797
5798 if (!review) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5799
5800 await db.insert(pendingReviewComments).values({
5801 reviewId: review.id,
5802 filePath,
5803 lineNumber,
5804 body: commentBody,
5805 });
5806
5807 // Count total pending comments for this review
5808 const countRows = await db
5809 .select({ id: pendingReviewComments.id })
5810 .from(pendingReviewComments)
5811 .where(eq(pendingReviewComments.reviewId, review.id));
5812
5813 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files&pending=${countRows.length}`);
5814 }
5815);
5816
5817// POST /:owner/:repo/pulls/:number/review/pending/:commentId/delete
5818// Removes a single comment from the pending review session.
5819pulls.post(
5820 "/:owner/:repo/pulls/:number/review/pending/:commentId/delete",
5821 softAuth,
5822 requireAuth,
5823 requireRepoAccess("read"),
5824 async (c) => {
5825 const { owner: ownerName, repo: repoName, commentId } = c.req.param();
5826 const prNum = parseInt(c.req.param("number"), 10);
5827 const user = c.get("user")!;
5828
5829 const resolved = await resolveRepo(ownerName, repoName);
5830 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5831
5832 const [pr] = await db
5833 .select()
5834 .from(pullRequests)
5835 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5836 .limit(1);
5837 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5838
5839 // Verify ownership via the review session
5840 const [review] = await db
5841 .select()
5842 .from(pendingReviews)
5843 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5844 .limit(1);
5845
5846 if (review) {
5847 await db
5848 .delete(pendingReviewComments)
5849 .where(and(eq(pendingReviewComments.id, commentId), eq(pendingReviewComments.reviewId, review.id)));
5850 }
5851
5852 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5853 }
5854);
5855
5856// POST /:owner/:repo/pulls/:number/review/submit
5857// Submits the pending review: posts all staged comments + a formal review state.
5858pulls.post(
5859 "/:owner/:repo/pulls/:number/review/submit",
5860 softAuth,
5861 requireAuth,
5862 requireRepoAccess("read"),
5863 async (c) => {
5864 const { owner: ownerName, repo: repoName } = c.req.param();
5865 const prNum = parseInt(c.req.param("number"), 10);
5866 const user = c.get("user")!;
5867 const body = await c.req.parseBody();
5868 const reviewBody = String(body.reviewBody || "").trim();
5869 const reviewStateRaw = String(body.reviewState || "comment");
5870
5871 const reviewState =
5872 reviewStateRaw === "approve"
5873 ? "approved"
5874 : reviewStateRaw === "request_changes"
5875 ? "changes_requested"
5876 : "commented";
5877
5878 const resolved = await resolveRepo(ownerName, repoName);
5879 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5880
5881 const [pr] = await db
5882 .select()
5883 .from(pullRequests)
5884 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5885 .limit(1);
5886 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5887
5888 const [review] = await db
5889 .select()
5890 .from(pendingReviews)
5891 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5892 .limit(1);
5893
5894 if (review) {
5895 const pendingComments = await db
5896 .select()
5897 .from(pendingReviewComments)
5898 .where(eq(pendingReviewComments.reviewId, review.id))
5899 .orderBy(asc(pendingReviewComments.createdAt));
5900
5901 // Post each pending comment as a real pr_comment
5902 for (const pc of pendingComments) {
5903 await db.insert(prComments).values({
5904 pullRequestId: pr.id,
5905 authorId: user.id,
5906 body: pc.body,
5907 filePath: pc.filePath,
5908 lineNumber: pc.lineNumber,
5909 isAiReview: false,
5910 });
5911 }
5912
5913 // Delete the pending review session (cascades to comments)
5914 await db.delete(pendingReviews).where(eq(pendingReviews.id, review.id));
5915 }
5916
5917 // Insert the formal review record
5918 await db.insert(prReviews).values({
5919 pullRequestId: pr.id,
5920 reviewerId: user.id,
5921 state: reviewState,
5922 body: reviewBody || null,
5923 isAi: false,
5924 });
5925
5926 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=conversation`);
5927 }
5928);
5929
56905930// Apply a suggestion from a PR comment — commits the suggested code to the
56915931// head branch on behalf of the logged-in user.
56925932pulls.post(
Modifiedsrc/views/diff-view.tsx+305−53View fileUnifiedSplit
@@ -283,6 +283,40 @@ function escapeHtml(str: string): string {
283283 .replace(/"/g, """);
284284}
285285
286// ─── Split-view helpers ─────────────────────────────────────────────────
287
288interface SplitRow {
289 left: { kind: "ctx" | "add" | "del"; text: string; oldNum: number | null; newNum: number | null } | null;
290 right: { kind: "ctx" | "add" | "del"; text: string; oldNum: number | null; newNum: number | null } | null;
291 isHunk?: boolean;
292 hunkHeader?: string;
293}
294
295type ParsedLine = ParsedHunk["lines"][number];
296
297function toSplitRows(lines: ParsedLine[]): SplitRow[] {
298 const rows: SplitRow[] = [];
299 let i = 0;
300 while (i < lines.length) {
301 const ln = lines[i];
302 if (ln.kind === "ctx") {
303 rows.push({ left: ln, right: ln });
304 i++;
305 continue;
306 }
307 // Collect a run of del then add lines
308 const dels: ParsedLine[] = [];
309 const adds: ParsedLine[] = [];
310 while (i < lines.length && lines[i].kind === "del") { dels.push(lines[i++]); }
311 while (i < lines.length && lines[i].kind === "add") { adds.push(lines[i++]); }
312 const max = Math.max(dels.length, adds.length);
313 for (let j = 0; j < max; j++) {
314 rows.push({ left: dels[j] ?? null, right: adds[j] ?? null });
315 }
316 }
317 return rows;
318}
319
286320// ─── File header pieces ─────────────────────────────────────────────────
287321
288322const StatusPill: FC<{ status: ParsedFile["status"] }> = ({ status }) => {
@@ -329,9 +363,23 @@ export interface DiffViewProps {
329363 commentActionUrl?: string;
330364 /** If set, shows "Apply suggestion" button on suggestion blocks; POSTs to `${applySuggestionUrl}/${commentId}` */
331365 applySuggestionUrl?: string;
366 /** URL to POST pending review comment additions to */
367 pendingReviewUrl?: string;
368 /** URL to POST final review submission to */
369 submitReviewUrl?: string;
370 /** When true, render split (side-by-side) diff instead of unified */
371 isSplit?: boolean;
372 /** Number of pending review comments (shows sticky submit bar when > 0) */
373 pendingCount?: number;
374 /** Repo owner for building review URLs */
375 owner?: string;
376 /** Repo name for building review URLs */
377 repo?: string;
378 /** PR number for building review URLs */
379 prNumber?: number;
332380}
333381
334export const DiffView: FC<DiffViewProps> = ({ raw, files, viewFileBase, inlineComments, commentActionUrl, applySuggestionUrl }) => {
382export const DiffView: FC<DiffViewProps> = ({ raw, files, viewFileBase, inlineComments, commentActionUrl, applySuggestionUrl, pendingReviewUrl, submitReviewUrl, isSplit, pendingCount, owner, repo, prNumber }) => {
335383 const parsed = parseUnifiedDiff(raw);
336384
337385 // Build a lookup map: "filePath:lineNumber" → inline comments
@@ -460,6 +508,10 @@ export const DiffView: FC<DiffViewProps> = ({ raw, files, viewFileBase, inlineCo
460508 View file
461509 </a>
462510 )}
511 <span style="margin-left:4px;display:flex;gap:2px;background:var(--bg-tertiary);border-radius:6px;padding:2px;">
512 <a href="?diffview=unified" class={`diff-view-tab${isSplit ? "" : " diff-view-tab-active"}`}>Unified</a>
513 <a href="?diffview=split" class={`diff-view-tab${isSplit ? " diff-view-tab-active" : ""}`}>Split</a>
514 </span>
463515 </summary>
464516
465517 {file.binary ? (
@@ -475,6 +527,53 @@ export const DiffView: FC<DiffViewProps> = ({ raw, files, viewFileBase, inlineCo
475527 </div>
476528 ) : file.hunks.length === 0 ? (
477529 <div class="diff-empty">No textual changes.</div>
530 ) : isSplit ? (
531 <div class={`diff-body diff-body-split${language ? " has-hljs" : ""}`}>
532 <table class="diff-table-split" style="width:100%;border-collapse:collapse;">
533 <colgroup>
534 <col style="width:40px;" /><col style="width:50%;" />
535 <col style="width:40px;" /><col style="width:50%;" />
536 </colgroup>
537 <tbody>
538 {file.hunks.map((hunk) => {
539 const splitRows = toSplitRows(hunk.lines);
540 return (
541 <>
542 <tr class="diff-split-hunk-row">
543 <td colspan={4} class="diff-hunk-header" style="padding:4px 8px;font-size:11px;font-family:var(--font-mono);color:var(--text-muted);background:rgba(91,110,232,0.06);">
544 <span class="diff-hunk-header-text">{hunk.header}</span>
545 </td>
546 </tr>
547 {splitRows.map((row) => {
548 const leftBg = row.left?.kind === "del" ? "rgba(248,113,113,0.08)" : "transparent";
549 const rightBg = row.right?.kind === "add" ? "rgba(52,211,153,0.08)" : "transparent";
550 return (
551 <tr class="diff-split-row">
552 <td class="diff-ln" style={`background:${leftBg};color:var(--text-muted);padding:0 6px;font-size:11px;font-family:var(--font-mono);text-align:right;user-select:none;border-right:1px solid var(--border);`}>
553 {row.left?.oldNum ?? ""}
554 </td>
555 <td class="diff-split-cell" style={`background:${leftBg};padding:0 var(--space-2,8px);font-family:var(--font-mono);font-size:12.5px;white-space:pre;overflow:hidden;border-right:1px solid var(--border);`}>
556 {row.left ? (
557 <span>{(row.left.kind === "del" ? "- " : " ") + row.left.text}</span>
558 ) : null}
559 </td>
560 <td class="diff-ln" style={`background:${rightBg};color:var(--text-muted);padding:0 6px;font-size:11px;font-family:var(--font-mono);text-align:right;user-select:none;border-right:1px solid var(--border);`}>
561 {row.right?.newNum ?? ""}
562 </td>
563 <td class="diff-split-cell" style={`background:${rightBg};padding:0 var(--space-2,8px);font-family:var(--font-mono);font-size:12.5px;white-space:pre;overflow:hidden;`}>
564 {row.right ? (
565 <span>{(row.right.kind === "add" ? "+ " : " ") + row.right.text}</span>
566 ) : null}
567 </td>
568 </tr>
569 );
570 })}
571 </>
572 );
573 })}
574 </tbody>
575 </table>
576 </div>
478577 ) : (
479578 <div class={`diff-body${language ? " has-hljs" : ""}`}>
480579 {file.hunks.map((hunk, hIdx) => (
@@ -513,7 +612,7 @@ export const DiffView: FC<DiffViewProps> = ({ raw, files, viewFileBase, inlineCo
513612 // Inline comments anchor to the new-file line number
514613 const commentKey = ln.newNum != null ? `${file.path}:${ln.newNum}` : null;
515614 const lineComments = commentKey ? (commentsByLine.get(commentKey) ?? []) : [];
516 const canComment = commentActionUrl && ln.kind !== "del" && ln.newNum != null;
615 const canComment = (commentActionUrl || pendingReviewUrl) && ln.kind !== "del" && ln.newNum != null;
517616 return (
518617 <>
519618 <div
@@ -600,6 +699,69 @@ export const DiffView: FC<DiffViewProps> = ({ raw, files, viewFileBase, inlineCo
600699 {applySuggestionUrl && (
601700 <meta name="diff-apply-suggestion-url" content={applySuggestionUrl} />
602701 )}
702 {pendingReviewUrl && (
703 <meta name="diff-pending-review-url" content={pendingReviewUrl} />
704 )}
705
706 {pendingCount != null && pendingCount > 0 && submitReviewUrl && (
707 <>
708 <div class="diff-pending-bar">
709 <span>{pendingCount} pending comment{pendingCount !== 1 ? "s" : ""}</span>
710 <button
711 type="button"
712 onclick="document.getElementById('submit-review-dialog').style.display='flex'"
713 class="btn"
714 style="background:var(--accent,#5b6ee8);color:#fff;border:none;border-radius:5px;padding:6px 14px;font-size:13px;cursor:pointer;font-family:var(--font-sans,inherit);"
715 >
716 Submit review
717 </button>
718 </div>
719 <div
720 id="submit-review-dialog"
721 style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;align-items:center;justify-content:center;"
722 >
723 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:var(--space-5,20px);width:min(520px,90vw);max-height:80vh;overflow-y:auto;">
724 <h3 style="margin:0 0 var(--space-3,12px);font-size:16px;color:var(--text);">Submit review</h3>
725 <form method="post" action={submitReviewUrl}>
726 <textarea
727 name="reviewBody"
728 placeholder="Leave a review summary (optional)..."
729 class="diff-inline-textarea"
730 rows={4}
731 style="width:100%;margin-bottom:var(--space-3,12px);"
732 />
733 <div style="display:flex;gap:var(--space-2,8px);margin-bottom:var(--space-3,12px);flex-wrap:wrap;">
734 <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-family:var(--font-sans,inherit);font-size:13px;color:var(--text);">
735 <input type="radio" name="reviewState" value="comment" checked /> Comment
736 </label>
737 <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-family:var(--font-sans,inherit);font-size:13px;color:var(--text);">
738 <input type="radio" name="reviewState" value="approve" /> Approve
739 </label>
740 <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-family:var(--font-sans,inherit);font-size:13px;color:var(--text);">
741 <input type="radio" name="reviewState" value="request_changes" /> Request changes
742 </label>
743 </div>
744 <div style="display:flex;gap:var(--space-2,8px);justify-content:flex-end;">
745 <button
746 type="button"
747 onclick="document.getElementById('submit-review-dialog').style.display='none'"
748 style="background:transparent;color:var(--text-muted);border:1px solid var(--border);border-radius:5px;padding:6px 14px;font-size:13px;cursor:pointer;font-family:var(--font-sans,inherit);"
749 >
750 Cancel
751 </button>
752 <button
753 type="submit"
754 style="background:var(--accent,#5b6ee8);color:#fff;border:none;border-radius:5px;padding:6px 14px;font-size:13px;cursor:pointer;font-family:var(--font-sans,inherit);"
755 >
756 Submit {pendingCount} comment{pendingCount !== 1 ? "s" : ""}
757 </button>
758 </div>
759 </form>
760 </div>
761 </div>
762 </>
763 )}
764
603765 <script dangerouslySetInnerHTML={{ __html: DIFF_VIEW_JS }} />
604766 </div>
605767 );
@@ -658,64 +820,107 @@ const DIFF_VIEW_JS = `
658820 var filePath = row.getAttribute('data-file');
659821 var lineNum = row.getAttribute('data-newline');
660822 var lineText = row.getAttribute('data-linetext') || '';
661 var actionUrl = document.querySelector('meta[name="diff-comment-url"]');
662 if (!filePath || !lineNum || !actionUrl) return;
823 var actionMeta = document.querySelector('meta[name="diff-comment-url"]');
824 var pendingMeta = document.querySelector('meta[name="diff-pending-review-url"]');
825 if (!filePath || !lineNum) return;
826 if (!actionMeta && !pendingMeta) return;
827 var directUrl = actionMeta ? (actionMeta.getAttribute('content') || '') : '';
828 var pendingUrl = pendingMeta ? (pendingMeta.getAttribute('content') || '') : '';
663829 // Remove any existing open form
664830 var existing = document.querySelector('.diff-inline-form-row');
665831 if (existing) {
666832 if (existing.previousSibling === row) { existing.remove(); return; }
667833 existing.remove();
668834 }
669 // Build a form row
670 var form = document.createElement('form');
671 form.method = 'POST';
672 form.action = actionUrl.getAttribute('content') || '';
673 form.className = 'diff-inline-form-row';
674 form.innerHTML =
675 '<input type="hidden" name="file_path" value="' + filePath.replace(/"/g,'"') + '">' +
676 '<input type="hidden" name="line_number" value="' + lineNum + '">' +
677 '<textarea name="body" rows="3" placeholder="Leave a comment…" class="diff-inline-textarea" required></textarea>' +
678 '<button type="button" class="diff-suggestion-toggle" title="Toggle suggestion mode">Suggest a change</button>' +
679 '<textarea class="diff-suggestion-textarea" rows="3" placeholder="Enter suggested replacement…" aria-label="Suggested replacement code"></textarea>' +
680 '<div class="diff-inline-form-actions">' +
681 '<button type="submit" class="diff-inline-submit">Comment</button>' +
682 '<button type="button" class="diff-inline-cancel">Cancel</button>' +
683 '</div>';
684 var toggleBtn = form.querySelector('.diff-suggestion-toggle');
685 var suggTA = form.querySelector('.diff-suggestion-textarea');
686 var submitBtn = form.querySelector('.diff-inline-submit');
687 var bodyTA = form.querySelector('textarea[name="body"]');
688 var suggestionActive = false;
689 toggleBtn.addEventListener('click', function () {
690 suggestionActive = !suggestionActive;
691 if (suggestionActive) {
692 toggleBtn.classList.add('is-active');
693 suggTA.classList.add('is-visible');
694 suggTA.value = lineText;
695 submitBtn.textContent = 'Add suggestion & comment';
696 } else {
697 toggleBtn.classList.remove('is-active');
698 suggTA.classList.remove('is-visible');
699 submitBtn.textContent = 'Comment';
835 // Build a form row — if pendingUrl exists, show two-option UI
836 var wrapper = document.createElement('div');
837 wrapper.className = 'diff-inline-form-row';
838 if (pendingUrl) {
839 // Two-option UI: "Add to review" or "Comment directly"
840 wrapper.innerHTML =
841 '<div class="diff-comment-choice">' +
842 '<form method="POST" action="' + pendingUrl.replace(/"/g,'"') + '" class="diff-pending-form">' +
843 '<input type="hidden" name="filePath" value="' + filePath.replace(/"/g,'"') + '">' +
844 '<input type="hidden" name="lineNumber" value="' + lineNum + '">' +
845 '<textarea name="body" class="diff-inline-textarea" rows="3" placeholder="Leave a comment..." required></textarea>' +
846 '<div class="diff-comment-actions">' +
847 '<button type="submit" class="diff-inline-submit">Add to review</button>' +
848 '<span style="font-size:11px;color:var(--text-muted)">or</span>' +
849 (directUrl ? '<button type="button" class="diff-comment-direct-btn diff-inline-cancel">Comment directly</button>' : '') +
850 '<button type="button" class="diff-inline-cancel" style="margin-left:auto;">Cancel</button>' +
851 '</div>' +
852 '</form>' +
853 '</div>';
854 // "Comment directly" switches action to direct URL and hides pending-only fields
855 var directBtn = wrapper.querySelector('.diff-comment-direct-btn');
856 if (directBtn && directUrl) {
857 directBtn.addEventListener('click', function () {
858 var pForm = wrapper.querySelector('.diff-pending-form');
859 pForm.action = directUrl;
860 // swap hidden field names to match direct endpoint
861 var fpInput = pForm.querySelector('input[name="filePath"]');
862 var lnInput = pForm.querySelector('input[name="lineNumber"]');
863 if (fpInput) fpInput.name = 'file_path';
864 if (lnInput) lnInput.name = 'line_number';
865 directBtn.style.display = 'none';
866 pForm.querySelector('.diff-inline-submit').textContent = 'Comment';
867 });
700868 }
701 });
702 form.addEventListener('submit', function (ev) {
703 if (!suggestionActive) return;
704 ev.preventDefault();
705 var suggVal = suggTA.value;
706 var commentText = bodyTA.value;
707 var wrapped = "\`\`\`suggestion\n" + suggVal + "\n\`\`\`";
708 var fullBody = commentText ? (wrapped + '\n' + commentText) : wrapped;
709 // Replace the body textarea value with the combined body
710 bodyTA.value = fullBody;
711 bodyTA.removeAttribute('required');
712 // Re-submit without the suggestion logic
713 suggestionActive = false;
714 form.submit();
715 });
716 form.querySelector('.diff-inline-cancel').addEventListener('click', function () { form.remove(); });
717 row.insertAdjacentElement('afterend', form);
718 bodyTA.focus();
869 var cancelBtn = wrapper.querySelector('.diff-inline-cancel:last-child');
870 if (cancelBtn) cancelBtn.addEventListener('click', function () { wrapper.remove(); });
871 var bodyTA = wrapper.querySelector('textarea[name="body"]');
872 row.insertAdjacentElement('afterend', wrapper);
873 if (bodyTA) bodyTA.focus();
874 } else if (directUrl) {
875 // Original single-option form
876 var form = document.createElement('form');
877 form.method = 'POST';
878 form.action = directUrl;
879 form.innerHTML =
880 '<input type="hidden" name="file_path" value="' + filePath.replace(/"/g,'"') + '">' +
881 '<input type="hidden" name="line_number" value="' + lineNum + '">' +
882 '<textarea name="body" rows="3" placeholder="Leave a comment…" class="diff-inline-textarea" required></textarea>' +
883 '<button type="button" class="diff-suggestion-toggle" title="Toggle suggestion mode">Suggest a change</button>' +
884 '<textarea class="diff-suggestion-textarea" rows="3" placeholder="Enter suggested replacement…" aria-label="Suggested replacement code"></textarea>' +
885 '<div class="diff-inline-form-actions">' +
886 '<button type="submit" class="diff-inline-submit">Comment</button>' +
887 '<button type="button" class="diff-inline-cancel">Cancel</button>' +
888 '</div>';
889 var toggleBtn = form.querySelector('.diff-suggestion-toggle');
890 var suggTA = form.querySelector('.diff-suggestion-textarea');
891 var submitBtn = form.querySelector('.diff-inline-submit');
892 var bodyTA2 = form.querySelector('textarea[name="body"]');
893 var suggestionActive = false;
894 toggleBtn.addEventListener('click', function () {
895 suggestionActive = !suggestionActive;
896 if (suggestionActive) {
897 toggleBtn.classList.add('is-active');
898 suggTA.classList.add('is-visible');
899 suggTA.value = lineText;
900 submitBtn.textContent = 'Add suggestion & comment';
901 } else {
902 toggleBtn.classList.remove('is-active');
903 suggTA.classList.remove('is-visible');
904 submitBtn.textContent = 'Comment';
905 }
906 });
907 form.addEventListener('submit', function (ev) {
908 if (!suggestionActive) return;
909 ev.preventDefault();
910 var suggVal = suggTA.value;
911 var commentText = bodyTA2.value;
912 var wrapped = '\`\`\`suggestion\n' + suggVal + '\n\`\`\`';
913 var fullBody = commentText ? (wrapped + '\n' + commentText) : wrapped;
914 bodyTA2.value = fullBody;
915 bodyTA2.removeAttribute('required');
916 suggestionActive = false;
917 form.submit();
918 });
919 form.querySelector('.diff-inline-cancel').addEventListener('click', function () { form.remove(); });
920 wrapper.appendChild(form);
921 row.insertAdjacentElement('afterend', wrapper);
922 if (bodyTA2) bodyTA2.focus();
923 }
719924 }
720925 });
721926})();
@@ -1262,4 +1467,51 @@ const DIFF_VIEW_CSS = `
12621467 .diff-jump-pills { display: flex; gap: 4px; flex-shrink: 0; }
12631468 .diff-jump-add { color: #6ee7b7; font-size: 11px; }
12641469 .diff-jump-del { color: #fca5a5; font-size: 11px; }
1470
1471 /* ─── Unified/Split view toggle ─── */
1472 .diff-view-tab {
1473 padding: 3px 10px;
1474 font-size: 11.5px;
1475 border-radius: 4px;
1476 color: var(--text-muted);
1477 text-decoration: none;
1478 font-family: var(--font-sans, inherit);
1479 transition: all 80ms ease;
1480 }
1481 .diff-view-tab:hover { color: var(--text); }
1482 .diff-view-tab-active {
1483 background: var(--bg-elevated);
1484 color: var(--text-strong, var(--text));
1485 box-shadow: 0 1px 3px rgba(0,0,0,0.15);
1486 }
1487
1488 /* ─── Pending review sticky bar ─── */
1489 .diff-pending-bar {
1490 position: fixed;
1491 bottom: var(--space-4, 16px);
1492 right: var(--space-4, 16px);
1493 background: var(--bg-elevated);
1494 border: 1px solid var(--accent, #5b6ee8);
1495 border-radius: 10px;
1496 padding: 10px 16px;
1497 display: flex;
1498 align-items: center;
1499 gap: var(--space-3, 12px);
1500 box-shadow: 0 4px 16px rgba(0,0,0,0.3);
1501 z-index: 100;
1502 font-size: 13px;
1503 font-family: var(--font-sans, inherit);
1504 color: var(--text);
1505 }
1506
1507 /* ─── Two-option comment choice ─── */
1508 .diff-comment-choice { display: flex; flex-direction: column; gap: 8px; }
1509 .diff-comment-actions { display: flex; align-items: center; gap: 8px; margin-top: 6px; flex-wrap: wrap; }
1510
1511 /* ─── Split diff table ─── */
1512 .diff-body-split { overflow-x: auto; }
1513 .diff-table-split { table-layout: fixed; }
1514 .diff-split-row:hover { filter: brightness(1.05); }
1515 .diff-split-hunk-row td { border-top: 1px solid rgba(91,110,232,0.18); border-bottom: 1px solid rgba(91,110,232,0.18); }
12651516`;
1517
12661518