Commit8b905f4unknown_key
Merge pull request #129 from ccantynz-alt/claude/architecture-audit-design-wxpren
Merge pull request #129 from ccantynz-alt/claude/architecture-audit-design-wxpren Claude/architecture audit design wxpren
10 files changed+731−728b905f4536f6a1a1afc8beb426d86f594ae4ae4d
10 changed files+731−74
Modifieddrizzle/0107_auto_repair_mode.sql+2−3View fileUnifiedSplit
@@ -1,5 +1,4 @@
11-- Add auto_repair_mode to per-repo automation settings.
2-- Default 'off' — bot never commits to branches unless the owner enables it.
23ALTER TABLE repo_automation_settings
3 ADD COLUMN IF NOT EXISTS auto_repair_mode TEXT NOT NULL DEFAULT 'suggest';
4 ADD COLUMN IF NOT EXISTS auto_repair_mode TEXT NOT NULL DEFAULT 'off';
Addeddrizzle/0108_push_automation_modes.sql+6−0View fileUnifiedSplit
@@ -0,0 +1,6 @@
1-- Add per-repo toggles for push-time AI automations that open issues or PRs.
2-- Default 'off' — both features were previously gated by server-level env vars
3-- (AI_AUTO_ISSUES=1, no per-repo opt-in). 'suggest' = enabled.
4ALTER TABLE repo_automation_settings
5 ADD COLUMN IF NOT EXISTS auto_issues_mode TEXT NOT NULL DEFAULT 'off',
6 ADD COLUMN IF NOT EXISTS doc_drift_mode TEXT NOT NULL DEFAULT 'off';
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/__tests__/automation-settings.test.ts+10−2View fileUnifiedSplit
@@ -69,8 +69,16 @@ describe("AUTOMATION_DEFAULTS — match pre-0106 behavior", () => {
6969 expect(AUTOMATION_DEFAULTS.autoMergeMode).toBe("off");
7070 });
7171
72 it("auto-repair defaults to 'suggest' (runs but preserves pre-0107 behavior)", () => {
73 expect(AUTOMATION_DEFAULTS.autoRepairMode).toBe("suggest");
72 it("auto-repair defaults to 'off' — bot never commits to branches unless owner enables it", () => {
73 expect(AUTOMATION_DEFAULTS.autoRepairMode).toBe("off");
74 });
75
76 it("auto-issues defaults to 'off' — off by default, owner opts in", () => {
77 expect(AUTOMATION_DEFAULTS.autoIssuesMode).toBe("off");
78 });
79
80 it("doc-drift defaults to 'off' — off by default, owner opts in", () => {
81 expect(AUTOMATION_DEFAULTS.docDriftMode).toBe("off");
7482 });
7583});
7684
Modifiedsrc/db/schema.ts+40−1View fileUnifiedSplit
@@ -4691,7 +4691,9 @@ export const repoAutomationSettings = pgTable(
46914691 issueTriageMode: text("issue_triage_mode").notNull().default("suggest"),
46924692 autoMergeMode: text("auto_merge_mode").notNull().default("auto"),
46934693 ciAutofixMode: text("ci_autofix_mode").notNull().default("suggest"),
4694 autoRepairMode: text("auto_repair_mode").notNull().default("suggest"),
4694 autoRepairMode: text("auto_repair_mode").notNull().default("off"),
4695 autoIssuesMode: text("auto_issues_mode").notNull().default("off"),
4696 docDriftMode: text("doc_drift_mode").notNull().default("off"),
46954697 createdAt: timestamp("created_at").defaultNow(),
46964698 updatedAt: timestamp("updated_at").defaultNow(),
46974699 },
@@ -4702,3 +4704,40 @@ export const repoAutomationSettings = pgTable(
47024704
47034705export type RepoAutomationSettings = typeof repoAutomationSettings.$inferSelect;
47044706export 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/hooks/post-receive.ts+19−12View fileUnifiedSplit
@@ -52,7 +52,9 @@ export async function onPostReceive(
5252 refs: PushRef[],
5353 pusherUserId: string = ""
5454): Promise<void> {
55 // Resolve repo ID once for automation settings lookups.
55 // Resolve per-repo automation settings once for this push. Fails open —
56 // any DB error leaves automationSettings null and all per-repo gates pass
57 // (unchanged behavior for repos without a settings row).
5658 let repoId = "";
5759 try {
5860 const [repoRow] = await db
@@ -151,13 +153,15 @@ export async function onPostReceive(
151153 // 4e. AI Auto-Issue Opener. Scans the diff for each pushed ref for
152154 // TODO/FIXME/HACK comments, hardcoded secrets, SQL injection patterns,
153155 // and debug console.log calls. Opens one issue per finding type per
154 // file (capped at MAX_ISSUES_PER_PUSH). Gated on AI_AUTO_ISSUES=1.
155 // Fire-and-forget; never blocks the push path.
156 // file (capped at MAX_ISSUES_PER_PUSH). Gated on AI_AUTO_ISSUES=1 and
157 // per-repo autoIssuesMode. Fire-and-forget; never blocks the push path.
156158 for (const ref of refs) {
157159 if (ref.newSha.startsWith("0000")) continue;
158 scanDiffForIssues(owner, repo, ref.oldSha, ref.newSha, pusherUserId).catch(
159 (err) => console.warn("[ai-auto-issues] dispatch error:", err)
160 );
160 if (automationSettings && isAutomationOn(automationSettings.autoIssuesMode)) {
161 scanDiffForIssues(owner, repo, ref.oldSha, ref.newSha, pusherUserId).catch(
162 (err) => console.warn("[ai-auto-issues] dispatch error:", err)
163 );
164 }
161165 }
162166
163167 // 4f. Dependency CVE scanner — when DEPENDENCY_SCAN_ENABLED=1, scan
@@ -174,12 +178,15 @@ export async function onPostReceive(
174178 // 4d. AI-tracked documentation drift check (migration 0068). Walks the
175179 // repo's markdown files for `<!-- gluecron:doc-track ... -->`
176180 // regions, hashes the referenced source, and opens a PR labelled
177 // `ai:doc-update` when the prose drifts. Fire-and-forget; failures
178 // are swallowed inside ai-doc-updater.ts so a missing anthropic key
179 // or empty doc_tracking table never breaks the push.
180 void fireDocDriftCheck(owner, repo).catch((err) =>
181 console.warn("[ai-doc-updater] dispatch error:", err)
182 );
181 // `ai:doc-update` when the prose drifts. Gated on per-repo
182 // docDriftMode. Fire-and-forget; failures are swallowed inside
183 // ai-doc-updater.ts so a missing anthropic key or empty
184 // doc_tracking table never breaks the push.
185 if (automationSettings && isAutomationOn(automationSettings.docDriftMode)) {
186 void fireDocDriftCheck(owner, repo).catch((err) =>
187 console.warn("[ai-doc-updater] dispatch error:", err)
188 );
189 }
183190
184191 // 4g. Smart empty states — repo onboarding. On the very first push to a
185192 // repo's default branch (oldSha all-zeros), generate a README draft,
Modifiedsrc/lib/automation-settings.ts+12−2View fileUnifiedSplit
@@ -24,7 +24,9 @@
2424 * issueTriageMode 'off' | 'suggest' (default 'suggest')
2525 * autoMergeMode 'off' | 'suggest' | 'auto' (default 'off')
2626 * ciAutofixMode 'off' | 'suggest' | 'auto' (default 'suggest')
27 * autoRepairMode 'off' | 'suggest' (default 'suggest')
27 * autoRepairMode 'off' | 'suggest' (default 'off')
28 * autoIssuesMode 'off' | 'suggest' (default 'off')
29 * docDriftMode 'off' | 'suggest' (default 'off')
2830 *
2931 * Where only on/off is meaningful, 'auto' is treated the same as 'suggest'
3032 * (i.e. "on") — `isAutomationOn` encodes that rule.
@@ -47,6 +49,8 @@ export interface AutomationSettings {
4749 autoMergeMode: AutomationMode;
4850 ciAutofixMode: AutomationMode;
4951 autoRepairMode: AutomationMode;
52 autoIssuesMode: AutomationMode;
53 docDriftMode: AutomationMode;
5054}
5155
5256/** Loader signature — the DI seam dispatch sites accept for tests. */
@@ -67,7 +71,9 @@ export const AUTOMATION_DEFAULTS: Readonly<AutomationSettings> = Object.freeze({
6771 issueTriageMode: "suggest",
6872 autoMergeMode: "off",
6973 ciAutofixMode: "suggest",
70 autoRepairMode: "suggest",
74 autoRepairMode: "off",
75 autoIssuesMode: "off",
76 docDriftMode: "off",
7177});
7278
7379// ---------------------------------------------------------------------------
@@ -120,6 +126,8 @@ export function settingsFromRow(
120126 autoMergeMode: normalizeMode(row.autoMergeMode, AUTOMATION_DEFAULTS.autoMergeMode),
121127 ciAutofixMode: normalizeMode(row.ciAutofixMode, AUTOMATION_DEFAULTS.ciAutofixMode),
122128 autoRepairMode: normalizeMode(row.autoRepairMode, AUTOMATION_DEFAULTS.autoRepairMode),
129 autoIssuesMode: normalizeMode(row.autoIssuesMode, AUTOMATION_DEFAULTS.autoIssuesMode),
130 docDriftMode: normalizeMode(row.docDriftMode, AUTOMATION_DEFAULTS.docDriftMode),
123131 };
124132}
125133
@@ -169,6 +177,8 @@ export async function upsertAutomationSettings(
169177 autoMergeMode: normalizeMode(patch.autoMergeMode, current.autoMergeMode),
170178 ciAutofixMode: normalizeMode(patch.ciAutofixMode, current.ciAutofixMode),
171179 autoRepairMode: normalizeMode(patch.autoRepairMode, current.autoRepairMode),
180 autoIssuesMode: normalizeMode(patch.autoIssuesMode, current.autoIssuesMode),
181 docDriftMode: normalizeMode(patch.docDriftMode, current.docDriftMode),
172182 };
173183
174184 await db
Modifiedsrc/routes/automation-settings.tsx+75−1View fileUnifiedSplit
@@ -275,6 +275,44 @@ automationSettings.get(
275275 )}
276276
277277 <form method="post" action={`/${ownerName}/${repoName}/settings/automation`}>
278 <div class="automation-card" style="margin-bottom: var(--space-4);">
279 <div style="padding: 14px var(--space-5); border-bottom: 1px solid var(--border);">
280 <div style="font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px;">Direct actions — these automations modify your repository</div>
281 <div style="font-size: 12.5px; color: var(--text-muted);">These three automations commit code or open PRs/issues on your behalf. Off by default.</div>
282 </div>
283 <div style="display: flex; gap: var(--space-4); padding: var(--space-4) var(--space-5); flex-wrap: wrap;">
284 <label style="display: flex; align-items: center; gap: 10px; cursor: pointer; min-width: 200px;">
285 <input type="checkbox" name="auto_repair_quick" value="suggest"
286 checked={settings.autoRepairMode !== "off"}
287 onChange="this.form.querySelector('[name=auto_repair_mode]').value = this.checked ? 'suggest' : 'off'"
288 style="width: 16px; height: 16px; cursor: pointer;" />
289 <div>
290 <div style="font-size: 13px; font-weight: 600; color: var(--text-strong);">Auto-repair</div>
291 <div style="font-size: 11.5px; color: var(--text-muted);">Commits fixes to your branch</div>
292 </div>
293 </label>
294 <label style="display: flex; align-items: center; gap: 10px; cursor: pointer; min-width: 200px;">
295 <input type="checkbox" name="auto_issues_quick" value="suggest"
296 checked={settings.autoIssuesMode !== "off"}
297 onChange="this.form.querySelector('[name=auto_issues_mode]').value = this.checked ? 'suggest' : 'off'"
298 style="width: 16px; height: 16px; cursor: pointer;" />
299 <div>
300 <div style="font-size: 13px; font-weight: 600; color: var(--text-strong);">Auto-issues</div>
301 <div style="font-size: 11.5px; color: var(--text-muted);">Opens issues for TODOs and security patterns</div>
302 </div>
303 </label>
304 <label style="display: flex; align-items: center; gap: 10px; cursor: pointer; min-width: 200px;">
305 <input type="checkbox" name="doc_drift_quick" value="suggest"
306 checked={settings.docDriftMode !== "off"}
307 onChange="this.form.querySelector('[name=doc_drift_mode]').value = this.checked ? 'suggest' : 'off'"
308 style="width: 16px; height: 16px; cursor: pointer;" />
309 <div>
310 <div style="font-size: 13px; font-weight: 600; color: var(--text-strong);">Doc-drift PRs</div>
311 <div style="font-size: 11.5px; color: var(--text-muted);">Opens PRs when docs drift from source</div>
312 </div>
313 </label>
314 </div>
315 </div>
278316 <div class="automation-card">
279317 <table class="automation-table">
280318 <thead>
@@ -424,6 +462,40 @@ automationSettings.get(
424462 />
425463 </td>
426464 </tr>
465 <tr>
466 <td class="automation-feature-name">Auto-issues</td>
467 <td class="automation-feature-desc">
468 Scans the diff on each push for TODO/FIXME comments,
469 hardcoded secrets, and SQL injection patterns. Opens one
470 issue per finding type per file (capped at 5 per push).
471 Requires <code>AI_AUTO_ISSUES=1</code> server-side.
472 </td>
473 <td>
474 <ModeSelect
475 name="auto_issues_mode"
476 value={settings.autoIssuesMode}
477 modes={["off", "suggest"]}
478 labels={{ suggest: "On" }}
479 />
480 </td>
481 </tr>
482 <tr>
483 <td class="automation-feature-name">Doc-drift PRs</td>
484 <td class="automation-feature-desc">
485 After each push, checks whether docs marked with{" "}
486 <code>{"<!-- gluecron:doc-track -->"}</code> have drifted
487 from their referenced source. Opens a PR labelled{" "}
488 <code>ai:doc-update</code> when they have.
489 </td>
490 <td>
491 <ModeSelect
492 name="doc_drift_mode"
493 value={settings.docDriftMode}
494 modes={["off", "suggest"]}
495 labels={{ suggest: "On" }}
496 />
497 </td>
498 </tr>
427499 <tr>
428500 <td class="automation-feature-name">AI loop (issue → PR → merge)</td>
429501 <td class="automation-feature-desc">
@@ -471,7 +543,9 @@ automationSettings.post(
471543 issueTriageMode: normalizeMode(body["issue_triage_mode"], "suggest"),
472544 autoMergeMode: normalizeMode(body["auto_merge_mode"], "off"),
473545 ciAutofixMode: normalizeMode(body["ci_autofix_mode"], "suggest"),
474 autoRepairMode: normalizeMode(body["auto_repair_mode"], "suggest"),
546 autoRepairMode: normalizeMode(body["auto_repair_mode"], "off"),
547 autoIssuesMode: normalizeMode(body["auto_issues_mode"], "off"),
548 docDriftMode: normalizeMode(body["doc_drift_mode"], "off"),
475549 });
476550
477551 // The two automations that already lived on the repositories row keep
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