@fullstackhouse/cerber 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/artifact.js +3 -0
- package/dist/core/export.js +3 -6
- package/dist/core/gh.js +14 -6
- package/dist/core/send.js +5 -9
- package/dist/core/severity.js +26 -0
- package/dist/runner/prompt.js +23 -3
- package/dist/server/daemon.js +34 -5
- package/dist/server/index.js +109 -3
- package/package.json +1 -1
- package/web/dist/assets/{index-s4kXKj8C.css → index-DjQ8jays.css} +1 -1
- package/web/dist/assets/{index-akC55Qya.js → index-DnAK-pqr.js} +34 -34
- package/web/dist/index.html +2 -2
package/dist/core/artifact.js
CHANGED
|
@@ -52,6 +52,9 @@ export const PrInfoSchema = z.object({
|
|
|
52
52
|
headSha: z.string().default(""),
|
|
53
53
|
/** OPEN | CLOSED | MERGED, as of the last fetch. */
|
|
54
54
|
state: z.enum(["OPEN", "CLOSED", "MERGED"]).default("OPEN"),
|
|
55
|
+
/** GitHub reports a draft's `state` as OPEN, so draftness needs its own field.
|
|
56
|
+
* Defaulted: artifacts written before this existed still load. */
|
|
57
|
+
isDraft: z.boolean().default(false),
|
|
55
58
|
additions: z.number().int().default(0),
|
|
56
59
|
deletions: z.number().int().default(0),
|
|
57
60
|
changedFiles: z.number().int().default(0),
|
package/dist/core/export.js
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
function grade(c) {
|
|
3
|
-
return c.severity ? `**${c.severity}:** ` : "";
|
|
4
|
-
}
|
|
1
|
+
import { withGrade } from "./severity.js";
|
|
5
2
|
/** Render an artifact as a standalone markdown review document. */
|
|
6
3
|
export function toMarkdown(artifact) {
|
|
7
4
|
const lines = [];
|
|
@@ -33,7 +30,7 @@ export function toMarkdown(artifact) {
|
|
|
33
30
|
lines.push("");
|
|
34
31
|
const comments = artifact.comments.filter((c) => c.chapterId === ch.id && c.status !== "dropped");
|
|
35
32
|
for (const c of comments) {
|
|
36
|
-
lines.push(`> **${c.path}${c.line != null ? `:${c.line}` : ""}** — ${
|
|
33
|
+
lines.push(`> **${c.path}${c.line != null ? `:${c.line}` : ""}** — ${withGrade(c.body, c.severity).replace(/\n/g, "\n> ")}`);
|
|
37
34
|
lines.push("");
|
|
38
35
|
}
|
|
39
36
|
}
|
|
@@ -44,7 +41,7 @@ export function toMarkdown(artifact) {
|
|
|
44
41
|
lines.push("## Other comments");
|
|
45
42
|
lines.push("");
|
|
46
43
|
for (const c of orphans) {
|
|
47
|
-
lines.push(`> **${c.path}${c.line != null ? `:${c.line}` : ""}** — ${c.body.replace(/\n/g, "\n> ")}`);
|
|
44
|
+
lines.push(`> **${c.path}${c.line != null ? `:${c.line}` : ""}** — ${withGrade(c.body, c.severity).replace(/\n/g, "\n> ")}`);
|
|
48
45
|
lines.push("");
|
|
49
46
|
}
|
|
50
47
|
}
|
package/dist/core/gh.js
CHANGED
|
@@ -45,7 +45,7 @@ export async function fetchPrInfo(ref) {
|
|
|
45
45
|
"--repo",
|
|
46
46
|
`${ref.owner}/${ref.repo}`,
|
|
47
47
|
"--json",
|
|
48
|
-
"title,url,author,body,baseRefName,headRefName,headRefOid,additions,deletions,changedFiles,state",
|
|
48
|
+
"title,url,author,body,baseRefName,headRefName,headRefOid,additions,deletions,changedFiles,state,isDraft",
|
|
49
49
|
]);
|
|
50
50
|
const raw = JSON.parse(out);
|
|
51
51
|
return PrInfoSchema.parse({
|
|
@@ -60,6 +60,7 @@ export async function fetchPrInfo(ref) {
|
|
|
60
60
|
headRefName: raw.headRefName,
|
|
61
61
|
headSha: raw.headRefOid ?? "",
|
|
62
62
|
state: raw.state ?? "OPEN",
|
|
63
|
+
isDraft: raw.isDraft ?? false,
|
|
63
64
|
additions: raw.additions ?? 0,
|
|
64
65
|
deletions: raw.deletions ?? 0,
|
|
65
66
|
changedFiles: raw.changedFiles ?? 0,
|
|
@@ -92,11 +93,18 @@ async function membershipCheck(args) {
|
|
|
92
93
|
export async function fetchPrDiff(ref) {
|
|
93
94
|
return gh(["pr", "diff", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`]);
|
|
94
95
|
}
|
|
95
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Map `gh search prs` JSON output to PR refs.
|
|
98
|
+
*
|
|
99
|
+
* Drafts are included. The search is already `--review-requested=@me`, so a
|
|
100
|
+
* draft that reaches this point carries an explicit review request — the author
|
|
101
|
+
* pressed the button, which makes it a review someone is waiting on rather than
|
|
102
|
+
* work-in-progress to leave alone. Filtering drafts here would be redundant with
|
|
103
|
+
* a stronger filter that already ran, and it would contradict the promise the
|
|
104
|
+
* daemon config makes: the inbox holds what awaits you, drafts and all.
|
|
105
|
+
*/
|
|
96
106
|
export function mapSearchResults(raw) {
|
|
97
|
-
return raw
|
|
98
|
-
.filter((r) => !r.isDraft)
|
|
99
|
-
.flatMap((r) => {
|
|
107
|
+
return raw.flatMap((r) => {
|
|
100
108
|
const [owner, repo] = r.repository.nameWithOwner.split("/");
|
|
101
109
|
if (!owner || !repo)
|
|
102
110
|
return [];
|
|
@@ -184,7 +192,7 @@ export function searchAwaitingArgs(repoFilter, limit = 50) {
|
|
|
184
192
|
args.push("--repo", repoFilter);
|
|
185
193
|
return args;
|
|
186
194
|
}
|
|
187
|
-
/** Open
|
|
195
|
+
/** Open PRs where the current gh user's review is requested, drafts included. */
|
|
188
196
|
export async function searchAwaitingMe(repoFilter, limit = 50) {
|
|
189
197
|
const out = await gh(searchAwaitingArgs(repoFilter, limit));
|
|
190
198
|
return mapSearchResults(JSON.parse(out));
|
package/dist/core/send.js
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import { newSideLines } from "./diff.js";
|
|
2
|
+
import { withGrade } from "./severity.js";
|
|
2
3
|
export function eventForRecommendation(rec) {
|
|
3
4
|
return { approve: "APPROVE", comment: "COMMENT", request_changes: "REQUEST_CHANGES" }[rec];
|
|
4
5
|
}
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
* unprefixed comment says "please change this", so leaving a nit bare would
|
|
8
|
-
* invert its meaning. Ungraded remarks (questions, notes) stay bare.
|
|
9
|
-
*/
|
|
10
|
-
function withGrade(c) {
|
|
11
|
-
return c.severity ? `**${c.severity}:** ${c.body}` : c.body;
|
|
6
|
+
function graded(c) {
|
|
7
|
+
return withGrade(c.body, c.severity);
|
|
12
8
|
}
|
|
13
9
|
/**
|
|
14
10
|
* Build the GitHub review payload from an artifact.
|
|
@@ -25,7 +21,7 @@ export function buildReviewPayload(artifact, event) {
|
|
|
25
21
|
// holds different code — posting there would point the author at something
|
|
26
22
|
// the comment was never about.
|
|
27
23
|
if (!c.drifted && c.line != null && anchorable.get(c.path)?.has(c.line)) {
|
|
28
|
-
inline.push({ path: c.path, line: c.line, side: "RIGHT", body:
|
|
24
|
+
inline.push({ path: c.path, line: c.line, side: "RIGHT", body: graded(c) });
|
|
29
25
|
}
|
|
30
26
|
else {
|
|
31
27
|
folded.push(c);
|
|
@@ -47,7 +43,7 @@ export function buildReviewPayload(artifact, event) {
|
|
|
47
43
|
// A drifted comment's line no longer exists, so name it as "was at" —
|
|
48
44
|
// pointing a reader at a line number that moved is worse than no number.
|
|
49
45
|
const at = c.line != null ? `:${c.drifted ? `~${c.line}` : c.line}` : "";
|
|
50
|
-
parts.push(`- \`${c.path}${at}\` — ${
|
|
46
|
+
parts.push(`- \`${c.path}${at}\` — ${graded(c)}`);
|
|
51
47
|
}
|
|
52
48
|
}
|
|
53
49
|
parts.push("", "---", "_Reviewed with [cerber](https://github.com/fullstackhouse/cerber) 🐕 — drafted by AI, sent by a human._");
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One badge per grade. A reader skimming twenty inline comments on GitHub gets
|
|
3
|
+
* no chips and no colour — the grade has to be legible in the text itself, and
|
|
4
|
+
* an emoji is what survives GitHub's markdown.
|
|
5
|
+
*/
|
|
6
|
+
export const SEVERITY_BADGE = {
|
|
7
|
+
blocker: "🚨",
|
|
8
|
+
minor: "⚠️",
|
|
9
|
+
nit: "🎨",
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Prefix a comment body with its grade — in the cockpit, on GitHub and in the
|
|
13
|
+
* export alike, so a comment reads the same wherever it is read. It is applied
|
|
14
|
+
* at render time and never stored: `severity` stays the one place a grade
|
|
15
|
+
* lives, and the body the user edits is the body that gets sent.
|
|
16
|
+
*
|
|
17
|
+
* To a reader used to review convention, an unprefixed comment says "please
|
|
18
|
+
* change this", so leaving a nit bare would invert its meaning. Ungraded
|
|
19
|
+
* remarks (questions, notes) stay bare — they are not findings, and badging
|
|
20
|
+
* them would make them look like ones.
|
|
21
|
+
*/
|
|
22
|
+
export function withGrade(body, severity) {
|
|
23
|
+
if (!severity)
|
|
24
|
+
return body;
|
|
25
|
+
return `${SEVERITY_BADGE[severity]} **${severity}** — ${body}`;
|
|
26
|
+
}
|
package/dist/runner/prompt.js
CHANGED
|
@@ -35,7 +35,7 @@ export function buildReviewPrompt(pr, diff, opts = {}) {
|
|
|
35
35
|
Respond with ONLY a JSON object (no markdown fences, no prose before or after) matching exactly this shape:
|
|
36
36
|
|
|
37
37
|
{
|
|
38
|
-
"summary": "markdown — what this PR actually does and why,
|
|
38
|
+
"summary": "markdown — what this PR actually does and why, in headed sections. See \\"Summary shape\\" below.",
|
|
39
39
|
"chapters": [
|
|
40
40
|
{
|
|
41
41
|
"id": "short-kebab-id",
|
|
@@ -48,7 +48,7 @@ Respond with ONLY a JSON object (no markdown fences, no prose before or after) m
|
|
|
48
48
|
{
|
|
49
49
|
"path": "path/to/file.ts",
|
|
50
50
|
"line": 42,
|
|
51
|
-
"body": "markdown — the review comment.
|
|
51
|
+
"body": "markdown — the review comment. See \\"Comment shape\\" below.",
|
|
52
52
|
"severity": "blocker" | "minor" | "nit" | null,
|
|
53
53
|
"chapterId": "short-kebab-id"
|
|
54
54
|
}
|
|
@@ -56,7 +56,7 @@ Respond with ONLY a JSON object (no markdown fences, no prose before or after) m
|
|
|
56
56
|
"verdict": {
|
|
57
57
|
"recommendation": "approve" | "comment" | "request_changes",
|
|
58
58
|
"confidence": 0-100,
|
|
59
|
-
"reasoning": "why this recommendation, and what makes you more/less sure"
|
|
59
|
+
"reasoning": "markdown — why this recommendation, and what makes you more/less sure. See \\"Verdict shape\\" below."
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -67,6 +67,25 @@ Writing style (applies to every markdown field):
|
|
|
67
67
|
- Do NOT imitate the PR description's writing style. Keep this plain register even when the PR itself is dense or writerly.
|
|
68
68
|
- Describe the effect a person would see before the mechanism behind it. Not: "A stray closing code fence with prose on the same line left the block open, rendering the conditions below as code." But: "Also fixes a markdown typo: a code block was never closed, so everything after it displayed as code."
|
|
69
69
|
|
|
70
|
+
Summary shape:
|
|
71
|
+
- General to specific, and a reader must be able to stop after any section with a correct, just less detailed, picture.
|
|
72
|
+
- Past two paragraphs, break the summary into sections under \`###\` headings. These four sections, in this order, each opening with its emoji — drop every one you have nothing real to say under; four sections of substance beat six with filler:
|
|
73
|
+
- \`### 🎯 \` the problem — the symptom someone hit and what it cost them, in plain words. No identifiers yet; a product manager should follow this section. For a PR that fixes nothing, this is the goal instead.
|
|
74
|
+
- \`### 🔧 \` the fix — the cause in a sentence, then what the PR does about it. A reviewer in a hurry stops here.
|
|
75
|
+
- \`### 🔍 \` details — the technical narration: names, the choice behind the approach, what the PR's own tests pin down, anything counter-intuitive worth knowing.
|
|
76
|
+
- \`### ⚠️ \` worth knowing — risk, follow-up, breaking change. Skip it when there is none.
|
|
77
|
+
- The emoji and the order are fixed. The words after the emoji are yours: write the heading this PR needs ("### 🎯 Searching an order number returned every order"), and fall back to the plain label ("### 🎯 The problem") only when nothing sharper fits.
|
|
78
|
+
- A small PR gets one or two short paragraphs and NO headings. Headings on a three-line summary are noise.
|
|
79
|
+
|
|
80
|
+
Verdict shape ("reasoning"):
|
|
81
|
+
- One lead sentence: the finding that decides it, or "No blockers." Then bullets — never a second paragraph.
|
|
82
|
+
- Each bullet opens with a bolded two-or-three-word lead and holds one fact: "**Ran the tests** — ...", "**Read the caller** — ...", "**Couldn't check** — ...". Two to five of them.
|
|
83
|
+
- What you could not settle gets its own bullet at the end. Say what would settle it.
|
|
84
|
+
|
|
85
|
+
Comment shape ("body"):
|
|
86
|
+
- The first sentence is the finding, and stands on its own line. Then a blank line, then the evidence, the fix, or a code suggestion. A five-sentence paragraph is a wall — break it.
|
|
87
|
+
- Never write the grade into the body. Every surface prefixes it from "severity" — "⚠️ **minor** — " and so on — so a body opening with "Minor:" says it twice.
|
|
88
|
+
|
|
70
89
|
Rules:
|
|
71
90
|
- Group ALL changed files into chapters that tell the story of the PR (schema first, then logic, then tests, etc). Every changed file must appear in exactly one chapter.
|
|
72
91
|
- "line" is the line number in the NEW version of the file (the "+" side of the diff). Use null for file-level comments. Only reference lines that appear in the diff.
|
|
@@ -216,6 +235,7 @@ Rules:
|
|
|
216
235
|
- "line" is a line in the NEW version of the file and must appear in the diff. Use null for a file-level comment.
|
|
217
236
|
- "severity" is the finding's merge impact — "blocker" (would not approve with this in; the only tier that blocks), "minor" (should fix, author's call), "nit" (taste), null (not a finding: a question, a note). Grade as if the finding is true; hedge in the reply, never in the grade. A verdict revision must still follow from the worst finding left standing.
|
|
218
237
|
- Your reply is prose to a person, not a changelog. The cockpit already shows what you changed — do not list your own edits back to them.
|
|
238
|
+
- A revision keeps the shape the review is written in: a rewritten summary keeps its \`###\` sections (🎯 the problem, 🔧 the fix, 🔍 details, ⚠️ worth knowing — the ones that earn their place), a rewritten "reasoning" stays one lead sentence plus bullets, and a comment body leads with the finding on its own line and never writes its own grade in — every surface prefixes that from "severity".
|
|
219
239
|
|
|
220
240
|
Writing style:
|
|
221
241
|
- Plain words, short sentences, no jargon. Someone who has NOT read the diff should follow you.
|
package/dist/server/daemon.js
CHANGED
|
@@ -30,6 +30,7 @@ export function stubArtifact(ref) {
|
|
|
30
30
|
headRefName: "",
|
|
31
31
|
headSha: "",
|
|
32
32
|
state: "OPEN",
|
|
33
|
+
isDraft: ref.isDraft,
|
|
33
34
|
additions: 0,
|
|
34
35
|
deletions: 0,
|
|
35
36
|
changedFiles: 0,
|
|
@@ -48,7 +49,19 @@ export function stubArtifact(ref) {
|
|
|
48
49
|
pendingChat: null,
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* An artifact nothing has touched yet — safe to delete when its PR leaves the
|
|
54
|
+
* queue.
|
|
55
|
+
*
|
|
56
|
+
* Read this before loosening it. Artifacts now arrive two ways: the daemon
|
|
57
|
+
* discovers them, and the user pastes a PR into the cockpit. The reaper below
|
|
58
|
+
* deletes anything absent from the awaiting search, which is only ever correct
|
|
59
|
+
* for the first kind — a pasted PR was never in that search and never will be.
|
|
60
|
+
* What keeps it safe is `run === null`: the cockpit's create endpoint persists
|
|
61
|
+
* a run block before it answers, so a pulled-in review is never a pure stub,
|
|
62
|
+
* not even during the minutes before its first AI output lands. Widen this
|
|
63
|
+
* predicate and you delete work the user asked for.
|
|
64
|
+
*/
|
|
52
65
|
export function isPureStub(artifact) {
|
|
53
66
|
return artifact.status === "awaiting" && artifact.comments.length === 0 && artifact.run === null;
|
|
54
67
|
}
|
|
@@ -111,8 +124,20 @@ export function startDaemon(opts) {
|
|
|
111
124
|
async function syncQueue(refs) {
|
|
112
125
|
let discovered = 0;
|
|
113
126
|
for (const ref of refs) {
|
|
114
|
-
|
|
127
|
+
const existing = await loadArtifact(artifactId(ref));
|
|
128
|
+
if (existing) {
|
|
129
|
+
// The search result is what the PR is right now; the artifact can
|
|
130
|
+
// predate a draft→ready flip. Refresh from it — it costs nothing, it's
|
|
131
|
+
// already in hand — or the queue keeps calling a ready PR a draft until
|
|
132
|
+
// something happens to re-review it.
|
|
133
|
+
if (existing.pr.isDraft !== ref.isDraft) {
|
|
134
|
+
await updateArtifactByKey(artifactKey(existing.id), (a) => ({
|
|
135
|
+
...a,
|
|
136
|
+
pr: { ...a.pr, isDraft: ref.isDraft },
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
115
139
|
continue;
|
|
140
|
+
}
|
|
116
141
|
await saveArtifact(stubArtifact(ref));
|
|
117
142
|
discovered++;
|
|
118
143
|
}
|
|
@@ -157,13 +182,17 @@ export function startDaemon(opts) {
|
|
|
157
182
|
stateCheckedAt.set(artifact.id, Date.now());
|
|
158
183
|
try {
|
|
159
184
|
const pr = await fetchPrInfo(artifact.pr);
|
|
160
|
-
|
|
185
|
+
// This artifact isn't in the awaiting search, so the cheap draft refresh
|
|
186
|
+
// above never sees it — a PR pulled in from the cockpit's paste box gets
|
|
187
|
+
// its draft flag corrected here instead. Same fetch either way.
|
|
188
|
+
if (pr.state !== "OPEN" || pr.isDraft !== artifact.pr.isDraft) {
|
|
161
189
|
await updateArtifactByKey(artifactKey(artifact.id), (a) => ({
|
|
162
190
|
...a,
|
|
163
|
-
pr: { ...a.pr, state: pr.state },
|
|
191
|
+
pr: { ...a.pr, state: pr.state, isDraft: pr.isDraft },
|
|
164
192
|
}));
|
|
165
|
-
log(`[${artifact.id}] ${pr.state.toLowerCase()} — archived`);
|
|
166
193
|
}
|
|
194
|
+
if (pr.state !== "OPEN")
|
|
195
|
+
log(`[${artifact.id}] ${pr.state.toLowerCase()} — archived`);
|
|
167
196
|
}
|
|
168
197
|
catch {
|
|
169
198
|
// Same: book-keeping can wait for the next poll.
|
package/dist/server/index.js
CHANGED
|
@@ -6,15 +6,16 @@ import { promises as fs } from "node:fs";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
|
-
import {
|
|
9
|
+
import { isPureStub } from "./daemon.js";
|
|
10
|
+
import { ArtifactStatusSchema, SCHEMA_VERSION, SeveritySchema, VerdictSchema, artifactId, artifactKey, } from "../core/artifact.js";
|
|
10
11
|
import { toMarkdown } from "../core/export.js";
|
|
11
|
-
import { fetchPrDiff, fetchPrInfo, submitReview } from "../core/gh.js";
|
|
12
|
+
import { fetchPrDiff, fetchPrInfo, parsePrRef, submitReview } from "../core/gh.js";
|
|
12
13
|
import { z } from "zod";
|
|
13
14
|
import { DaemonConfigSchema, configPath, loadConfig, saveConfig } from "../core/config.js";
|
|
14
15
|
import { refreshArtifact } from "../core/refresh.js";
|
|
15
16
|
import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
|
|
16
17
|
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
17
|
-
import { listArtifacts, loadArtifactByKey, reconcileRunning, updateArtifactByKey, } from "../core/state.js";
|
|
18
|
+
import { listArtifacts, loadArtifact, loadArtifactByKey, reconcileRunning, saveArtifact, updateArtifactByKey, } from "../core/state.js";
|
|
18
19
|
import { isReviewRunning } from "../runner/inflight.js";
|
|
19
20
|
import { reviewPr } from "../runner/review.js";
|
|
20
21
|
import { runChatTurn } from "../runner/chat.js";
|
|
@@ -141,6 +142,7 @@ export async function buildApp(opts) {
|
|
|
141
142
|
repo: a.pr.repo,
|
|
142
143
|
number: a.pr.number,
|
|
143
144
|
state: a.pr.state,
|
|
145
|
+
isDraft: a.pr.isDraft,
|
|
144
146
|
additions: a.pr.additions,
|
|
145
147
|
deletions: a.pr.deletions,
|
|
146
148
|
changedFiles: a.pr.changedFiles,
|
|
@@ -159,6 +161,110 @@ export async function buildApp(opts) {
|
|
|
159
161
|
sent: a.sent ? { at: a.sent.at, event: a.sent.event, url: a.sent.url, auto: a.sent.auto ?? false } : null,
|
|
160
162
|
})));
|
|
161
163
|
});
|
|
164
|
+
// ---- Pull a PR in: review anything, not just what the inbox found ----
|
|
165
|
+
//
|
|
166
|
+
// The daemon only ever discovers PRs GitHub is asking *you* to review. This
|
|
167
|
+
// is the other door: paste a URL and cerber reviews it — your own PR, one you
|
|
168
|
+
// were never requested on, one you already settled and want a second read of.
|
|
169
|
+
const CreateReviewSchema = z.object({ input: z.string().min(1) });
|
|
170
|
+
app.post("/api/reviews", async (c) => {
|
|
171
|
+
const parsed = CreateReviewSchema.safeParse(await c.req.json().catch(() => null));
|
|
172
|
+
if (!parsed.success)
|
|
173
|
+
return c.json({ error: "expected { input: \"<pr url>\" }" }, 400);
|
|
174
|
+
let ref;
|
|
175
|
+
try {
|
|
176
|
+
ref = parsePrRef(parsed.data.input.trim());
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
// A bare number is the one parse failure worth explaining: parsePrRef can
|
|
180
|
+
// resolve it against a repo, and the cockpit has none to offer.
|
|
181
|
+
const message = /bare number/.test(err instanceof Error ? err.message : "")
|
|
182
|
+
? "A number alone doesn't say which repo — paste the full PR URL, or owner/repo#123."
|
|
183
|
+
: err instanceof Error
|
|
184
|
+
? err.message
|
|
185
|
+
: String(err);
|
|
186
|
+
return c.json({ error: message }, 400);
|
|
187
|
+
}
|
|
188
|
+
// Two tabs, or a retried request, can both get here before either has saved
|
|
189
|
+
// anything. Letting both through would start a second run whose inflight
|
|
190
|
+
// claim throws, and whose failure handler would then mark the *first* run's
|
|
191
|
+
// artifact failed underneath it.
|
|
192
|
+
if (isReviewRunning(artifactId(ref))) {
|
|
193
|
+
return c.json({ error: "a review of this PR is already running" }, 409);
|
|
194
|
+
}
|
|
195
|
+
// Already here? Hand back what we have rather than reviewing it twice — but
|
|
196
|
+
// only if there is something to hand back. A pure stub is the daemon saying
|
|
197
|
+
// "GitHub is asking you for this", not a review; pasting its URL is the user
|
|
198
|
+
// asking for the review that does not exist yet, so that falls through and
|
|
199
|
+
// runs. Handing the stub over instead would answer 200 while silently
|
|
200
|
+
// dropping what was actually requested.
|
|
201
|
+
const existing = await loadArtifact(artifactId(ref));
|
|
202
|
+
if (existing && !isPureStub(existing)) {
|
|
203
|
+
return c.json({ ...existing, key: artifactKey(existing.id) }, 200);
|
|
204
|
+
}
|
|
205
|
+
// Validated in-request, on purpose. The run below is detached, so a typo'd
|
|
206
|
+
// URL, a private repo or a logged-out `gh` would otherwise fail with no
|
|
207
|
+
// artifact to record it on and no response left to report it in. The PR
|
|
208
|
+
// this fetches is also what the artifact is built from, so it costs nothing.
|
|
209
|
+
let pr;
|
|
210
|
+
try {
|
|
211
|
+
pr = await fetchPrInfo(ref);
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 502);
|
|
215
|
+
}
|
|
216
|
+
const withSource = opts.withSource;
|
|
217
|
+
const now = new Date().toISOString();
|
|
218
|
+
// Saved as `running`, with a run block, BEFORE the 202 — not as an
|
|
219
|
+
// `awaiting` stub. reviewPr spends minutes on the diff, trust and checkout
|
|
220
|
+
// before it first writes the artifact itself, and for a PR that isn't in
|
|
221
|
+
// the awaiting search a stub sitting in that window is exactly what the
|
|
222
|
+
// daemon's reaper deletes (isPureStub). Persisting the run up front is what
|
|
223
|
+
// makes this artifact survive its own first poll.
|
|
224
|
+
const artifact = {
|
|
225
|
+
schemaVersion: SCHEMA_VERSION,
|
|
226
|
+
id: artifactId(ref),
|
|
227
|
+
status: "running",
|
|
228
|
+
createdAt: now,
|
|
229
|
+
updatedAt: now,
|
|
230
|
+
pr,
|
|
231
|
+
diff: "",
|
|
232
|
+
summary: "",
|
|
233
|
+
chapters: [],
|
|
234
|
+
comments: [],
|
|
235
|
+
verdict: null,
|
|
236
|
+
run: {
|
|
237
|
+
model: null,
|
|
238
|
+
startedAt: now,
|
|
239
|
+
finishedAt: null,
|
|
240
|
+
costUsd: null,
|
|
241
|
+
error: null,
|
|
242
|
+
withSource: withSource !== false,
|
|
243
|
+
trusted: false,
|
|
244
|
+
sessionId: null,
|
|
245
|
+
},
|
|
246
|
+
sent: null,
|
|
247
|
+
refresh: null,
|
|
248
|
+
calibration: null,
|
|
249
|
+
chat: [],
|
|
250
|
+
preChat: null,
|
|
251
|
+
pendingChat: null,
|
|
252
|
+
};
|
|
253
|
+
await saveArtifact(artifact);
|
|
254
|
+
void reviewPr(ref, {
|
|
255
|
+
force: true,
|
|
256
|
+
withSource,
|
|
257
|
+
trust: opts.trust,
|
|
258
|
+
onProgress: (m) => console.log(`[pull ${artifact.id}] ${m}`),
|
|
259
|
+
}).catch(async (err) => {
|
|
260
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
261
|
+
console.error(`[pull ${artifact.id}] failed: ${message}`);
|
|
262
|
+
await updateArtifactByKey(artifactKey(artifact.id), (a) => a.status === "running"
|
|
263
|
+
? { ...a, status: "failed", run: a.run ? { ...a.run, error: message } : null }
|
|
264
|
+
: a).catch(() => { });
|
|
265
|
+
});
|
|
266
|
+
return c.json({ ...artifact, key: artifactKey(artifact.id) }, 202);
|
|
267
|
+
});
|
|
162
268
|
app.get("/api/reviews/:key", async (c) => {
|
|
163
269
|
const artifact = await loadArtifactByKey(c.req.param("key"));
|
|
164
270
|
if (!artifact)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullstackhouse/cerber",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "AI code-review cockpit: Claude reviews your PRs into local artifacts; nothing reaches GitHub until you say so.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Full Stack House (https://fullstack.house)",
|
|
@@ -7,4 +7,4 @@
|
|
|
7
7
|
|
|
8
8
|
Outdated base version: https://github.com/primer/github-syntax-light
|
|
9
9
|
Current colors taken from GitHub's CSS
|
|
10
|
-
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#005cc5}.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-comment,.hljs-code,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}:root{--bg: #fcfcfd;--surface: #ffffff;--fg: #14171a;--fg-2: #3a3f45;--fg-3: #4a5058;--muted: #6b7178;--faint: #9aa0a8;--fainter: #b6bbc1;--line: #e6e8eb;--line-2: #dcdfe4;--line-3: #eef0f2;--accent: #3d4fd6;--accent-dark: #2c3aa8;--accent-wash: #f4f5ff;--green: #1a7f5a;--amber: #a06a00;--red: #c2314a;--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;--prose: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Helvetica, sans-serif;--measure: 1560px}*{box-sizing:border-box}body{margin:0;font-family:var(--mono);font-size:12px;color:var(--fg);background:var(--bg)}a{color:var(--accent)}a:hover{color:var(--accent-dark)}h1,h2,h3{font-size:13px;font-weight:700;margin:0}.app{padding-bottom:48px}.wrap{max-width:var(--measure);margin:0 auto;padding:0 40px}.bar-inner{max-width:var(--measure);margin:0 auto;padding:0 40px;display:flex;align-items:center;gap:14px}.grow{flex-grow:1}.pad{padding:24px 40px}.muted,.faint{color:var(--faint)}.warn{color:var(--amber)}.danger{color:var(--red)}.add{color:var(--green)}.del{color:var(--red)}.icon{flex-shrink:0}.lab{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint)}.key{font-size:10px;border:1px solid var(--line-2);border-bottom-width:2px;border-radius:3px;padding:1px 5px;color:var(--muted);background:var(--surface)}.hint{display:inline-flex;gap:5px;align-items:center;font-size:10.5px;color:var(--faint);white-space:nowrap}.prose{font-family:var(--prose);font-size:13px;line-height:1.6;color:var(--fg-2);text-wrap:pretty;max-width:720px}.prose.lead{font-size:13.5px;line-height:1.62;color:var(--fg)}.error{color:var(--red);background:#c2314a0f;border:1px solid rgba(194,49,74,.3);padding:8px 12px;border-radius:4px;margin:10px 0}.empty{text-align:center;color:var(--faint);margin:64px auto;flex-direction:column}.empty pre{display:inline-block;background:var(--line-3);padding:8px 16px;border-radius:4px}button{font:inherit;cursor:pointer}.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;font-size:11px;color:var(--fg-3);border:1px solid var(--line-2);border-radius:4px;padding:6px 10px;background:var(--surface);white-space:nowrap;text-decoration:none}.btn:hover:not(:disabled){color:var(--fg);border-color:var(--fainter)}.btn:disabled{opacity:.5;cursor:default}.btn-sm{font-size:10.5px;padding:3px 8px;gap:5px}.btn-accent{color:var(--accent);border-color:#3d4fd659}.btn-keep-on{color:#fff;background:var(--green);border-color:var(--green)}.btn-drop-on{color:var(--red);border-color:#c2314a66}.btn-dark{color:#fff;background:var(--fg);border-color:var(--fg);font-size:11.5px;padding:7px 14px}.btn-dark:disabled{color:var(--faint);background:#f0f1f3;border-color:var(--line-2)}.btn-dashed{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;color:var(--muted);border:1px dashed var(--line-2);border-radius:4px;padding:5px 10px;background:transparent;margin-top:10px}.btn-primary{display:flex;align-items:center;justify-content:center;gap:8px;height:40px;padding:0 14px;font-size:12px;font-weight:500;color:#fff;background:var(--accent);border:1px solid var(--accent);border-radius:4px}.btn-primary .key{border-color:#ffffff4d;color:#ffffffbf;background:transparent}.link{display:inline-flex;align-items:center;gap:5px;font:inherit;font-size:11px;color:var(--accent);background:none;border:none;padding:0;text-decoration:none}.link:hover:not(:disabled){color:var(--accent-dark)}.link:disabled{color:var(--faint);cursor:default}.topbar{border-bottom:1px solid var(--line);background:var(--surface)}.topbar .bar-inner{padding-top:14px;padding-bottom:14px}.brand{display:inline-flex;align-items:center;gap:7px;font-size:15px;font-weight:700;letter-spacing:-.02em;color:var(--fg);text-decoration:none}.brand .logo{color:var(--accent);flex-shrink:0}.tagline{font-size:11px;color:var(--faint)}.topbar-link{font-size:11px;color:var(--muted);text-decoration:none;border-left:1px solid var(--line);padding-left:14px;display:inline-flex;align-items:center}.topbar-link:hover{color:var(--fg)}.bell{background:none;border:0;border-left:1px solid var(--line);padding:0 0 0 14px;font-family:inherit;cursor:pointer}.bell-on,.bell-ask{color:var(--accent)}.bell-blocked{color:var(--fainter);cursor:help}.daemon-bar{border-bottom:1px solid var(--line);background:#f7f8fa;font-size:11px;color:var(--muted)}.daemon-bar .bar-inner{padding-top:8px;padding-bottom:8px;gap:18px;flex-wrap:wrap}.daemon-bar b{color:var(--fg);font-weight:400}.daemon-live{display:inline-flex;align-items:center;gap:6px;color:var(--fg)}.daemon-bar .warn{display:inline-flex;align-items:center;gap:6px}.dot{width:5px;height:5px;border-radius:999px;background:var(--green);display:inline-block}.dot-off{background:var(--fainter)}.tabs{display:flex;align-items:center;gap:2px;padding-top:14px}.tab{font-size:12px;padding:6px 12px;border:none;border-bottom:2px solid transparent;border-radius:4px 4px 0 0;background:none;color:var(--muted)}.tab:hover{color:var(--fg)}.tab-on{color:var(--fg);font-weight:500;border-bottom-color:var(--accent)}.tab-count{color:var(--fainter);margin-left:4px}.tab-on .tab-count{color:var(--accent)}.table-head,.row{display:flex;gap:12px;align-items:center}.table-head{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);padding:12px 10px 7px;border-bottom:1px solid var(--line-2)}.row{font-size:12px;padding:8px 10px;cursor:pointer;border-bottom:1px solid var(--line-3);border-left:2px solid transparent}.row:hover{background:#f7f8fa}.row-on{background:var(--accent-wash);border-left-color:var(--accent)}.row-on .col-caret{color:var(--accent)}.row-on .col-title{font-weight:500}.tag-done{flex-shrink:0;color:var(--muted);background:var(--line-3)}.tag-awaiting{flex-shrink:0;color:var(--amber);background:#a06a001a}.col-caret{width:12px;flex-shrink:0;color:transparent}.col-slug{width:184px;flex-shrink:0;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.col-title{flex-grow:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.col-author{width:104px;flex-shrink:0;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.col-verdict{width:136px;flex-shrink:0;text-align:right;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.col-comments{width:26px;flex-shrink:0;text-align:right;color:var(--muted)}.col-size{width:104px;flex-shrink:0;text-align:right;color:var(--muted)}.col-age{width:44px;flex-shrink:0;text-align:right;color:var(--faint)}.table-head .col-verdict,.table-head .col-slug,.table-head .col-author{color:var(--faint);font-weight:400}.cursor-strip{display:flex;gap:18px;align-items:flex-start;border:1px solid var(--line-2);border-top:none;border-radius:0 0 4px 4px;background:var(--surface);padding:14px 16px}.cursor-strip-body{flex-grow:1;min-width:0}.cursor-strip-body .prose{margin:6px 0 0;max-width:700px}.cursor-strip-meta{font-size:10.5px;color:var(--faint);margin-top:8px}.cursor-strip-actions{display:flex;flex-direction:column;gap:6px;width:176px;flex-shrink:0}.cursor-strip-row{display:flex;gap:6px}.cursor-strip-row .btn{flex-grow:1;padding:7px 0}.drawers{margin-top:16px;padding-bottom:8px}.drawer+.drawer{margin-top:6px}.drawer-toggle{font-size:11.5px;color:var(--muted);background:none;border:none;padding:4px 0}.drawer-toggle:hover{color:var(--fg)}.drawer-rows{margin:8px 0 14px}.row-settled{opacity:.62}.row-settled:hover,.row-awaiting{opacity:1}.review-top{position:sticky;top:0;z-index:20;background:var(--surface)}.crumb{border-bottom:1px solid var(--line);background:var(--surface);font-size:11.5px;color:var(--muted)}.crumb .bar-inner{padding-top:12px;padding-bottom:12px;gap:12px}.crumb a{text-decoration:none}.crumb-sep{color:var(--line-2)}.crumb-slug{color:var(--fg)}.crumb-next{font-size:11px;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.walk{display:inline-flex;gap:2px}.walk-btn{display:inline-flex;align-items:center;justify-content:center;padding:4px 6px;border:1px solid var(--line-2);border-radius:3px;background:var(--surface);color:var(--fg-3)}.walk-btn:disabled{color:#d4d7db;border-color:var(--line-3);cursor:default}.review-head{border-bottom:1px solid var(--line);background:var(--surface)}.review-head .wrap{padding-top:20px;padding-bottom:16px}.review-head-top,.review-head-meta{display:flex;align-items:center;gap:14px}.review-head-top{margin-bottom:8px}.review-title{font-size:19px;font-weight:700;letter-spacing:-.025em;color:var(--fg);text-decoration:none}.review-title:hover{color:var(--accent)}.review-head-meta{font-size:11px;color:var(--muted);gap:10px;flex-wrap:wrap}.verdict-pick{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;padding:4px 9px;border-radius:4px;border:1px solid transparent;background:none;color:var(--faint);white-space:nowrap}.verdict-pick:hover{color:var(--fg-3)}.verdict-pick-on{background:#f0f1f3;border-color:var(--line-2);font-weight:500}.review-body{display:flex;align-items:flex-start;gap:0}.rail{width:236px;flex-shrink:0;padding:22px 20px 22px 0;position:sticky;top:var(--top-h, 0px);z-index:5;align-self:flex-start;max-height:calc(100vh - var(--top-h, 0px));overflow-y:auto}.chapter,.comment,.verdict-bar{scroll-margin-top:calc(var(--top-h, 0px) + 12px)}.rail-lab{display:block;margin-top:22px}.rail .lab{margin-bottom:10px}.rail-item{display:flex;align-items:center;gap:6px;width:100%;text-align:left;font:inherit;font-size:11.5px;line-height:1.5;padding:5px 0 5px 9px;margin-left:-11px;background:none;border:none;border-left:2px solid transparent;color:var(--muted)}.rail-item:hover{color:var(--fg)}.rail-item-on{border-left-color:var(--accent);color:var(--fg);font-weight:500}.rail-group{margin-bottom:2px}.rail-count{font-size:10.5px;color:var(--fainter)}.rail-comment{display:flex;align-items:center;gap:6px;width:100%;font:inherit;font-size:11px;text-align:left;padding:3px 0 3px 20px;margin-left:-11px;background:none;border:none;border-left:2px solid transparent;color:var(--muted)}.rail-comment:hover{color:var(--fg)}.rail-comment-loc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rail-comment-dropped{opacity:.55}.rail-comment-dropped .rail-comment-loc{text-decoration:line-through}.rail-dot{font-size:8px}.comment-flash{animation:comment-flash 1.8s ease-out}@keyframes comment-flash{0%,60%{background:var(--accent-wash);box-shadow:inset 0 0 0 2px #3d4fd673}}.rail-facts{font-size:11.5px;line-height:1.85;color:var(--muted)}.rail-facts b{color:var(--fg);font-weight:400}.main{flex-grow:1;min-width:0;padding:22px 0 0}.card{border:1px solid var(--line);border-radius:4px;background:var(--surface);margin-bottom:10px}.card-head{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;padding:11px 16px;border-bottom:1px solid var(--line-3)}.card-head .faint{font-size:11px}.card-body{padding:14px 16px}.card-body .lab{display:block;margin-bottom:7px}.card-body .prose{margin:0}.card-body .btn{margin-top:12px}.card-why{margin-bottom:8px}.chapter{border:1px solid var(--line);border-radius:4px;background:var(--surface);margin-bottom:10px;overflow:hidden}.chapter-head{display:flex;align-items:center;gap:10px;padding:11px 16px;cursor:pointer;-webkit-user-select:none;user-select:none}.chapter-body{padding:14px 16px 16px;border-top:1px solid var(--line-3)}.chapter-meta{font-size:11px;font-weight:400}.chapter-explanation{margin:0 0 14px}.comment{border:1px solid var(--line);border-radius:4px;background:#fbfbfc;padding:12px 14px;margin:8px 0}.comment-dropped{opacity:.5}.comment-dropped .comment-body{text-decoration:line-through}.comment-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px}.comment-loc{font-size:10.5px;color:var(--faint)}.comment-body{font-size:13px;line-height:1.58;color:var(--fg);max-width:640px}.comment-edit,.add-comment textarea,.chat-input{width:100%;font-family:var(--mono);font-size:12.5px;line-height:1.55;color:var(--fg);padding:10px 12px;border:1px solid var(--line-2);border-radius:4px;background:var(--surface);resize:vertical}.add-comment{border:1px dashed var(--line-2);border-radius:4px;padding:10px 12px;margin:10px 0;display:flex;flex-direction:column;gap:8px}.add-comment-row{display:flex;gap:8px;align-items:center}.add-comment select,.add-comment input{font:inherit;font-size:11.5px;padding:5px 7px;border:1px solid var(--line-2);border-radius:4px;background:var(--surface)}.inline-comment-row td{padding:0;background:#fbfbfc;border-top:1px solid var(--line);border-bottom:1px solid var(--line);white-space:normal}.inline-comment-row .comment{margin:0;border:none;border-radius:0;padding-left:44px;max-width:none}.diff{margin-top:8px;font-size:11.5px}.diff .d2h-file-header,.diff .d2h-file-name{font-family:var(--mono);font-size:11px}.diff .hljs{display:inline;padding:0;background:transparent;color:inherit}.freshness{background:#a06a0012;border:1px solid rgba(160,106,0,.3);color:#57430a;padding:10px 12px;border-radius:4px;margin-bottom:10px;line-height:1.55;font-size:11.5px}.freshness code{background:#0000000f;padding:1px 4px;border-radius:3px}.freshness-running{background:#3d4fd60f;border-color:#3d4fd64d;color:#26307a}.freshness .btn{margin-left:8px}.chat-panel{margin:18px 0 10px}.chat-log{display:flex;flex-direction:column;gap:14px;margin-bottom:14px}.chat-turn{border-left:2px solid var(--line-2);padding:2px 0 2px 12px}.chat-turn-user{border-left-color:var(--accent)}.chat-turn-pending{border-left-style:dashed}.chat-turn-who{font-size:10.5px;color:var(--faint);margin-bottom:4px}.chat-turn-body{max-width:700px;color:var(--fg)}.chat-revision{font-size:10.5px;color:var(--green);margin-top:6px}.chat-refusal{font-size:10.5px;color:var(--amber);margin-top:6px}.chat-waiting{font-size:11.5px;color:var(--muted);margin-top:6px}.chat-progress{list-style:none;margin:0;padding:0}.chat-progress li{opacity:.55;padding:1px 0}.chat-progress li.now{opacity:1}.chat-progress li.now:after{content:"…"}.chat-failed{font-size:11.5px;color:var(--red);margin-top:6px}.chat-refs{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:8px}.chat-ref{font-size:10.5px;font-family:var(--mono);padding:3px 9px;border:1px solid var(--line-2);border-radius:999px;color:var(--muted);background:transparent}.chat-refs-pending .chat-ref{cursor:pointer;color:var(--accent);border-color:#3d4fd659}.chat-actions{display:flex;align-items:center;gap:12px;margin-top:10px}.send-panel{border:1px solid var(--line-2);border-radius:4px;background:#f7f8fa;padding:16px 18px}.verdict-bar+.send-panel{border-radius:0 0 4px 4px}.send-top{display:flex;align-items:baseline;gap:12px;margin-bottom:14px}.send-top .faint{font-size:11px}.send-top .link{text-decoration:underline;text-underline-offset:2px}.send-action{display:flex;align-items:center;gap:16px;flex-wrap:wrap}.send-btn{display:inline-flex;align-items:center;gap:10px;height:44px;padding:0 20px;font-size:13px;font-weight:500;color:#fff;border:none;border-radius:4px;box-shadow:0 1px 2px #14171a1f}.send-btn:disabled{opacity:.6;cursor:default}.send-btn .key{border-color:#ffffff4d;color:#fffc;background:transparent}.send-as{position:relative}.send-as-toggle{font:inherit;font-size:11.5px;color:var(--fg-3);background:none;border:none;padding:0}.send-as-toggle b{color:var(--fg);font-weight:500;border-bottom:1px dotted var(--fainter)}.send-as-why{font-size:10.5px;color:var(--faint);margin-top:3px}.menu{position:absolute;top:34px;left:0;z-index:10;background:var(--surface);border:1px solid var(--line-2);border-radius:4px;box-shadow:0 6px 18px #14171a17;padding:4px;min-width:190px}.menu-item{display:flex;align-items:center;gap:9px;width:100%;font:inherit;font-size:11.5px;text-align:left;padding:7px 9px;border:none;border-radius:3px;background:none;color:var(--fg-3)}.menu-item:hover{background:#f7f8fa}.menu-item-on{background:#f4f5f7;color:var(--fg)}.menu-check{font-size:10px;color:var(--green)}.verdict-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;border:1px solid var(--line-2);border-bottom:none;border-radius:4px 4px 0 0;background:var(--surface);padding:10px 18px;margin-top:18px}.verdict-bar .lab{margin-right:4px}.verdict-bar .faint{font-size:10.5px}.send-footer{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:16px;padding-top:14px;border-top:1px solid var(--line-2);font-size:10.5px}.body-preview{margin-top:14px;padding:14px 16px;background:var(--surface);border:1px solid var(--line);border-radius:4px;font-family:var(--prose);font-size:12.5px;line-height:1.62;color:var(--fg-2);white-space:pre-wrap;max-width:760px;max-height:420px;overflow:auto}.sent-strip{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;border:1px solid rgba(26,127,90,.4);background:#1a7f5a12;border-radius:4px;padding:14px 16px;font-size:11.5px}.sent-strip strong{color:var(--green);font-size:13px;white-space:nowrap}.sent-strip a{display:inline-flex;align-items:center;gap:5px;white-space:nowrap;text-decoration:none}.post-actions{display:flex;align-items:center;gap:12px;margin-top:12px;font-size:10.5px}.post-actions .link{font-size:10.5px;color:var(--muted)}.post-actions .link:hover{color:var(--fg)}.md>:first-child{margin-top:0}.md>:last-child{margin-bottom:0}.md p{margin:8px 0}.md code{font-family:var(--mono);font-size:.9em;background:#f0f2f5;padding:1px 5px;border-radius:3px}.md pre{background:#f7f8fa;border:1px solid var(--line);border-radius:4px;padding:10px 12px;overflow-x:auto}.md pre code{background:none;padding:0;font-size:11.5px}.md ul,.md ol{margin:8px 0;padding-left:24px}.md blockquote{margin:8px 0;padding-left:12px;border-left:2px solid var(--line-2);color:var(--muted)}.md h1,.md h2,.md h3{font-size:1.05em;margin:12px 0 4px}.settings{max-width:780px;margin:0 auto;padding:24px 40px 64px;font-family:var(--prose);font-size:13px;line-height:1.6;color:var(--fg-2)}.settings .back{display:inline-block;font-family:var(--mono);font-size:11.5px;text-decoration:none;margin-bottom:16px}.settings h1{font-family:var(--mono);font-size:15px;margin:32px 0 8px}.settings h2{font-family:var(--mono);font-size:13px;margin:28px 0 8px}.settings code{font-family:var(--mono);font-size:.92em;background:#f0f2f5;padding:1px 5px;border-radius:3px}.inbox-toggle{display:block;margin:8px 0}.trust-add{display:flex;gap:8px;margin:20px 0 12px}.trust-add input{flex:1;font:inherit;padding:7px 10px;color:var(--fg);border:1px solid var(--line-2);border-radius:4px;background:var(--surface)}.trust-table{border-collapse:collapse;width:100%}.trust-table td{padding:6px 10px 6px 0;border-bottom:1px solid var(--line);vertical-align:baseline}.trust-actions{text-align:right}.trust-deny code{text-decoration:line-through}.tone-approve{color:var(--green)}.tone-comment{color:var(--amber)}.tone-changes{color:var(--red)}.tone-running,.tone-sent,.tone-none{color:var(--muted);font-weight:400}.tone-failed{color:var(--red)}.tone-awaiting{color:var(--accent)}.tone-bg-approve{background:var(--green)}.tone-bg-comment{background:var(--amber)}.tone-bg-changes{background:var(--red)}.tone-border-approve{border-left:2px solid var(--green)}.tone-border-comment{border-left:2px solid var(--amber)}.tone-border-changes{border-left:2px solid var(--red)}.tone-border-none{border-left:2px solid var(--line-2)}.chip{font-size:11.5px;font-weight:500;padding:3px 9px;border-radius:3px;border:1px solid currentColor;background:#14171a08;white-space:nowrap}.chip-btn{display:inline-flex;align-items:center;gap:6px;font-family:inherit}.chip-btn:hover{background:#14171a12}.tag{font-size:10px;letter-spacing:.06em;padding:1px 6px;border-radius:3px;color:var(--muted);background:#f0f1f3;white-space:nowrap}.tag-yours{color:var(--accent);background:#3d4fd617}.tag-kept{color:var(--green);background:#1a7f5a1a}.tag-drift{color:var(--amber);background:#a06a001a}.tag-sev-blocker{color:var(--red);background:#c2314a1a;font-weight:600}.tag-sev-minor{color:var(--amber);background:#a06a001a}.tag-sev-nit{color:var(--muted);background:#f0f1f3}.sev-counts{font-size:12px;color:var(--muted);margin-bottom:6px}.verdict-hint{display:flex;align-items:center;gap:10px;flex-wrap:wrap;background:#a06a0012;border:1px solid rgba(160,106,0,.3);color:#57430a;padding:10px 12px;border-radius:4px}
|
|
10
|
+
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#005cc5}.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-comment,.hljs-code,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}:root{--bg: #fcfcfd;--surface: #ffffff;--fg: #14171a;--fg-2: #3a3f45;--fg-3: #4a5058;--muted: #6b7178;--faint: #9aa0a8;--fainter: #b6bbc1;--line: #e6e8eb;--line-2: #dcdfe4;--line-3: #eef0f2;--accent: #3d4fd6;--accent-dark: #2c3aa8;--accent-wash: #f4f5ff;--green: #1a7f5a;--amber: #a06a00;--red: #c2314a;--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;--prose: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Helvetica, sans-serif;--measure: 1560px}*{box-sizing:border-box}body{margin:0;font-family:var(--mono);font-size:12px;color:var(--fg);background:var(--bg)}a{color:var(--accent)}a:hover{color:var(--accent-dark)}h1,h2,h3{font-size:13px;font-weight:700;margin:0}.app{padding-bottom:48px}.wrap{max-width:var(--measure);margin:0 auto;padding:0 40px}.bar-inner{max-width:var(--measure);margin:0 auto;padding:0 40px;display:flex;align-items:center;gap:14px}.grow{flex-grow:1}.pad{padding:24px 40px}.muted,.faint{color:var(--faint)}.warn{color:var(--amber)}.danger{color:var(--red)}.add{color:var(--green)}.del{color:var(--red)}.icon{flex-shrink:0}.lab{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint)}.key{font-size:10px;border:1px solid var(--line-2);border-bottom-width:2px;border-radius:3px;padding:1px 5px;color:var(--muted);background:var(--surface)}.hint{display:inline-flex;gap:5px;align-items:center;font-size:10.5px;color:var(--faint);white-space:nowrap}.prose{font-family:var(--prose);font-size:13px;line-height:1.6;color:var(--fg-2);text-wrap:pretty;max-width:720px}.prose.lead{font-size:13.5px;line-height:1.62;color:var(--fg)}.error{color:var(--red);background:#c2314a0f;border:1px solid rgba(194,49,74,.3);padding:8px 12px;border-radius:4px;margin:10px 0}.empty{text-align:center;color:var(--faint);margin:64px auto;flex-direction:column}.empty pre{display:inline-block;background:var(--line-3);padding:8px 16px;border-radius:4px}button{font:inherit;cursor:pointer}.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;font-size:11px;color:var(--fg-3);border:1px solid var(--line-2);border-radius:4px;padding:6px 10px;background:var(--surface);white-space:nowrap;text-decoration:none}.btn:hover:not(:disabled){color:var(--fg);border-color:var(--fainter)}.btn:disabled{opacity:.5;cursor:default}.btn-sm{font-size:10.5px;padding:3px 8px;gap:5px}.btn-accent{color:var(--accent);border-color:#3d4fd659}.btn-keep-on{color:#fff;background:var(--green);border-color:var(--green)}.btn-drop-on{color:var(--red);border-color:#c2314a66}.btn-dark{color:#fff;background:var(--fg);border-color:var(--fg);font-size:11.5px;padding:7px 14px}.btn-dark:disabled{color:var(--faint);background:#f0f1f3;border-color:var(--line-2)}.btn-dashed{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;color:var(--muted);border:1px dashed var(--line-2);border-radius:4px;padding:5px 10px;background:transparent;margin-top:10px}.btn-primary{display:flex;align-items:center;justify-content:center;gap:8px;height:40px;padding:0 14px;font-size:12px;font-weight:500;color:#fff;background:var(--accent);border:1px solid var(--accent);border-radius:4px}.btn-primary .key{border-color:#ffffff4d;color:#ffffffbf;background:transparent}.link{display:inline-flex;align-items:center;gap:5px;font:inherit;font-size:11px;color:var(--accent);background:none;border:none;padding:0;text-decoration:none}.link:hover:not(:disabled){color:var(--accent-dark)}.link:disabled{color:var(--faint);cursor:default}.topbar{border-bottom:1px solid var(--line);background:var(--surface)}.topbar .bar-inner{padding-top:14px;padding-bottom:14px}.brand{display:inline-flex;align-items:center;gap:7px;font-size:15px;font-weight:700;letter-spacing:-.02em;color:var(--fg);text-decoration:none}.brand .logo{color:var(--accent);flex-shrink:0}.tagline{font-size:11px;color:var(--faint)}.topbar-link{font-size:11px;color:var(--muted);text-decoration:none;border-left:1px solid var(--line);padding-left:14px;display:inline-flex;align-items:center}.topbar-link:hover{color:var(--fg)}.bell{background:none;border:0;border-left:1px solid var(--line);padding:0 0 0 14px;font-family:inherit;cursor:pointer}.bell-on,.bell-ask{color:var(--accent)}.bell-blocked{color:var(--fainter);cursor:help}.daemon-bar{border-bottom:1px solid var(--line);background:#f7f8fa;font-size:11px;color:var(--muted)}.daemon-bar .bar-inner{padding-top:8px;padding-bottom:8px;gap:18px;flex-wrap:wrap}.daemon-bar b{color:var(--fg);font-weight:400}.daemon-live{display:inline-flex;align-items:center;gap:6px;color:var(--fg)}.daemon-bar .warn{display:inline-flex;align-items:center;gap:6px}.dot{width:5px;height:5px;border-radius:999px;background:var(--green);display:inline-block}.dot-off{background:var(--fainter)}.tabs{display:flex;align-items:center;gap:2px;padding-top:14px}.tab{font-size:12px;padding:6px 12px;border:none;border-bottom:2px solid transparent;border-radius:4px 4px 0 0;background:none;color:var(--muted)}.tab:hover{color:var(--fg)}.tab-on{color:var(--fg);font-weight:500;border-bottom-color:var(--accent)}.tab-count{color:var(--fainter);margin-left:4px}.tab-on .tab-count{color:var(--accent)}.table-head,.row{display:flex;gap:12px;align-items:center}.table-head{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);padding:12px 10px 7px;border-bottom:1px solid var(--line-2)}.row{font-size:12px;padding:8px 10px;cursor:pointer;border-bottom:1px solid var(--line-3);border-left:2px solid transparent}.row:hover{background:#f7f8fa}.row-on{background:var(--accent-wash);border-left-color:var(--accent)}.row-on .col-caret{color:var(--accent)}.row-on .col-title{font-weight:500}.tag-done{flex-shrink:0;color:var(--muted);background:var(--line-3)}.tag-awaiting{flex-shrink:0;color:var(--amber);background:#a06a001a}.pull{display:flex;align-items:center;gap:6px;position:relative;color:var(--muted)}.tabs .pull{margin-right:16px}.empty .pull{justify-content:center;margin-top:10px}.pull:hover,.pull:focus-within{color:var(--fg-3)}.pull-input{width:200px;max-width:100%;font:inherit;font-size:12px;padding:2px 0;color:var(--fg);background:transparent;border:none;border-bottom:1px solid transparent;transition:width .12s ease,border-color .12s ease}.pull-input:hover::placeholder{color:var(--fg)}.pull-input:focus{outline:none;width:320px;border-bottom-color:var(--accent)}.pull-input::placeholder{color:var(--muted);transition:color .12s ease}.btn-small{padding:2px 8px;font-size:10px}.pull-error{position:absolute;top:100%;right:0;z-index:5;margin-top:4px;max-width:360px;padding:5px 8px;font-size:11px;line-height:1.4;color:var(--red);background:var(--bg);border:1px solid var(--line-2);border-radius:4px;box-shadow:0 2px 8px #14171a14}.tag-draft{flex-shrink:0;color:var(--muted);background:var(--line-3)}.col-caret{width:12px;flex-shrink:0;color:transparent}.col-slug{width:184px;flex-shrink:0;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.col-title{flex-grow:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.col-author{width:104px;flex-shrink:0;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.col-verdict{width:136px;flex-shrink:0;text-align:right;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.col-comments{width:26px;flex-shrink:0;text-align:right;color:var(--muted)}.col-size{width:104px;flex-shrink:0;text-align:right;color:var(--muted)}.col-age{width:44px;flex-shrink:0;text-align:right;color:var(--faint)}.table-head .col-verdict,.table-head .col-slug,.table-head .col-author{color:var(--faint);font-weight:400}.cursor-strip{display:flex;gap:18px;align-items:flex-start;border:1px solid var(--line-2);border-top:none;border-radius:0 0 4px 4px;background:var(--surface);padding:14px 16px}.cursor-strip-body{flex-grow:1;min-width:0}.cursor-strip-body .prose{margin:6px 0 0;max-width:700px}.cursor-strip-meta{font-size:10.5px;color:var(--faint);margin-top:8px}.cursor-strip-actions{display:flex;flex-direction:column;gap:6px;width:176px;flex-shrink:0}.cursor-strip-row{display:flex;gap:6px}.cursor-strip-row .btn{flex-grow:1;padding:7px 0}.drawers{margin-top:16px;padding-bottom:8px}.drawer+.drawer{margin-top:6px}.drawer-toggle{font-size:11.5px;color:var(--muted);background:none;border:none;padding:4px 0}.drawer-toggle:hover{color:var(--fg)}.drawer-rows{margin:8px 0 14px}.row-settled{opacity:.62}.row-settled:hover,.row-awaiting{opacity:1}.review-top{position:sticky;top:0;z-index:20;background:var(--surface)}.crumb{border-bottom:1px solid var(--line);background:var(--surface);font-size:11.5px;color:var(--muted)}.crumb .bar-inner{padding-top:12px;padding-bottom:12px;gap:12px}.crumb a{text-decoration:none}.crumb-sep{color:var(--line-2)}.crumb-slug{color:var(--fg)}.crumb-next{font-size:11px;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.walk{display:inline-flex;gap:2px}.walk-btn{display:inline-flex;align-items:center;justify-content:center;padding:4px 6px;border:1px solid var(--line-2);border-radius:3px;background:var(--surface);color:var(--fg-3)}.walk-btn:disabled{color:#d4d7db;border-color:var(--line-3);cursor:default}.review-head{border-bottom:1px solid var(--line);background:var(--surface)}.review-head .wrap{padding-top:20px;padding-bottom:16px}.review-head-top,.review-head-meta{display:flex;align-items:center;gap:14px}.review-head-top{margin-bottom:8px}.review-title{font-size:19px;font-weight:700;letter-spacing:-.025em;color:var(--fg);text-decoration:none}.review-title:hover{color:var(--accent)}.review-head-meta{font-size:11px;color:var(--muted);gap:10px;flex-wrap:wrap}.verdict-pick{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;padding:4px 9px;border-radius:4px;border:1px solid transparent;background:none;color:var(--faint);white-space:nowrap}.verdict-pick:hover{color:var(--fg-3)}.verdict-pick-on{background:#f0f1f3;border-color:var(--line-2);font-weight:500}.review-body{display:flex;align-items:flex-start;gap:0}.rail{width:236px;flex-shrink:0;padding:22px 20px 22px 0;position:sticky;top:var(--top-h, 0px);z-index:5;align-self:flex-start;max-height:calc(100vh - var(--top-h, 0px));overflow-y:auto}.chapter,.comment,.verdict-bar{scroll-margin-top:calc(var(--top-h, 0px) + 12px)}.rail-lab{display:block;margin-top:22px}.rail .lab{margin-bottom:10px}.rail-item{display:flex;align-items:center;gap:6px;width:100%;text-align:left;font:inherit;font-size:11.5px;line-height:1.5;padding:5px 0 5px 9px;margin-left:-11px;background:none;border:none;border-left:2px solid transparent;color:var(--muted)}.rail-item:hover{color:var(--fg)}.rail-item-on{border-left-color:var(--accent);color:var(--fg);font-weight:500}.rail-group{margin-bottom:2px}.rail-count{font-size:10.5px;color:var(--fainter)}.rail-comment{display:flex;align-items:center;gap:6px;width:100%;font:inherit;font-size:11px;text-align:left;padding:3px 0 3px 20px;margin-left:-11px;background:none;border:none;border-left:2px solid transparent;color:var(--muted)}.rail-comment:hover{color:var(--fg)}.rail-comment-loc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rail-comment-dropped{opacity:.55}.rail-comment-dropped .rail-comment-loc{text-decoration:line-through}.rail-dot{font-size:8px}.comment-flash{animation:comment-flash 1.8s ease-out}@keyframes comment-flash{0%,60%{background:var(--accent-wash);box-shadow:inset 0 0 0 2px #3d4fd673}}.rail-facts{font-size:11.5px;line-height:1.85;color:var(--muted)}.rail-facts b{color:var(--fg);font-weight:400}.main{flex-grow:1;min-width:0;padding:22px 0 0}.card{border:1px solid var(--line);border-radius:4px;background:var(--surface);margin-bottom:10px}.card-head{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;padding:11px 16px;border-bottom:1px solid var(--line-3)}.card-head .faint{font-size:11px}.card-body{padding:14px 16px}.card-body .lab{display:block;margin-bottom:7px}.card-body .prose{margin:0}.card-body .btn{margin-top:12px}.card-why{margin-bottom:8px}.chapter{border:1px solid var(--line);border-radius:4px;background:var(--surface);margin-bottom:10px;overflow:hidden}.chapter-head{display:flex;align-items:center;gap:10px;padding:11px 16px;cursor:pointer;-webkit-user-select:none;user-select:none}.chapter-body{padding:14px 16px 16px;border-top:1px solid var(--line-3)}.chapter-meta{font-size:11px;font-weight:400}.chapter-explanation{margin:0 0 14px}.comment{border:1px solid var(--line);border-radius:4px;background:#fbfbfc;padding:12px 14px;margin:8px 0}.comment-dropped{opacity:.5}.comment-dropped .comment-body{text-decoration:line-through}.comment-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px}.comment-loc{font-size:10.5px;color:var(--faint)}.comment-body{font-size:13px;line-height:1.58;color:var(--fg);max-width:640px}.comment-edit,.add-comment textarea,.chat-input{width:100%;font-family:var(--mono);font-size:12.5px;line-height:1.55;color:var(--fg);padding:10px 12px;border:1px solid var(--line-2);border-radius:4px;background:var(--surface);resize:vertical}.add-comment{border:1px dashed var(--line-2);border-radius:4px;padding:10px 12px;margin:10px 0;display:flex;flex-direction:column;gap:8px}.add-comment-row{display:flex;gap:8px;align-items:center}.add-comment select,.add-comment input{font:inherit;font-size:11.5px;padding:5px 7px;border:1px solid var(--line-2);border-radius:4px;background:var(--surface)}.inline-comment-row td{padding:0;background:#fbfbfc;border-top:1px solid var(--line);border-bottom:1px solid var(--line);white-space:normal}.inline-comment-row .comment{margin:0;border:none;border-radius:0;padding-left:44px;max-width:none}.diff{margin-top:8px;font-size:11.5px}.diff .d2h-file-header,.diff .d2h-file-name{font-family:var(--mono);font-size:11px}.diff .hljs{display:inline;padding:0;background:transparent;color:inherit}.freshness{background:#a06a0012;border:1px solid rgba(160,106,0,.3);color:#57430a;padding:10px 12px;border-radius:4px;margin-bottom:10px;line-height:1.55;font-size:11.5px}.freshness code{background:#0000000f;padding:1px 4px;border-radius:3px}.freshness-running{background:#3d4fd60f;border-color:#3d4fd64d;color:#26307a}.freshness .btn{margin-left:8px}.chat-panel{margin:18px 0 10px}.chat-log{display:flex;flex-direction:column;gap:14px;margin-bottom:14px}.chat-turn{border-left:2px solid var(--line-2);padding:2px 0 2px 12px}.chat-turn-user{border-left-color:var(--accent)}.chat-turn-pending{border-left-style:dashed}.chat-turn-who{font-size:10.5px;color:var(--faint);margin-bottom:4px}.chat-turn-body{max-width:700px;color:var(--fg)}.chat-revision{font-size:10.5px;color:var(--green);margin-top:6px}.chat-refusal{font-size:10.5px;color:var(--amber);margin-top:6px}.chat-waiting{font-size:11.5px;color:var(--muted);margin-top:6px}.chat-progress{list-style:none;margin:0;padding:0}.chat-progress li{opacity:.55;padding:1px 0}.chat-progress li.now{opacity:1}.chat-progress li.now:after{content:"…"}.chat-failed{font-size:11.5px;color:var(--red);margin-top:6px}.chat-refs{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:8px}.chat-ref{font-size:10.5px;font-family:var(--mono);padding:3px 9px;border:1px solid var(--line-2);border-radius:999px;color:var(--muted);background:transparent}.chat-refs-pending .chat-ref{cursor:pointer;color:var(--accent);border-color:#3d4fd659}.chat-actions{display:flex;align-items:center;gap:12px;margin-top:10px}.send-panel{border:1px solid var(--line-2);border-radius:4px;background:#f7f8fa;padding:16px 18px}.verdict-bar+.send-panel{border-radius:0 0 4px 4px}.send-top{display:flex;align-items:baseline;gap:12px;margin-bottom:14px}.send-top .faint{font-size:11px}.send-top .link{text-decoration:underline;text-underline-offset:2px}.send-action{display:flex;align-items:center;gap:16px;flex-wrap:wrap}.send-btn{display:inline-flex;align-items:center;gap:10px;height:44px;padding:0 20px;font-size:13px;font-weight:500;color:#fff;border:none;border-radius:4px;box-shadow:0 1px 2px #14171a1f}.send-btn:disabled{opacity:.6;cursor:default}.send-btn .key{border-color:#ffffff4d;color:#fffc;background:transparent}.send-as{position:relative}.send-as-toggle{font:inherit;font-size:11.5px;color:var(--fg-3);background:none;border:none;padding:0}.send-as-toggle b{color:var(--fg);font-weight:500;border-bottom:1px dotted var(--fainter)}.send-as-why{font-size:10.5px;color:var(--faint);margin-top:3px}.menu{position:absolute;top:34px;left:0;z-index:10;background:var(--surface);border:1px solid var(--line-2);border-radius:4px;box-shadow:0 6px 18px #14171a17;padding:4px;min-width:190px}.menu-item{display:flex;align-items:center;gap:9px;width:100%;font:inherit;font-size:11.5px;text-align:left;padding:7px 9px;border:none;border-radius:3px;background:none;color:var(--fg-3)}.menu-item:hover{background:#f7f8fa}.menu-item-on{background:#f4f5f7;color:var(--fg)}.menu-check{font-size:10px;color:var(--green)}.verdict-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;border:1px solid var(--line-2);border-bottom:none;border-radius:4px 4px 0 0;background:var(--surface);padding:10px 18px;margin-top:18px}.verdict-bar .lab{margin-right:4px}.verdict-bar .faint{font-size:10.5px}.send-footer{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:16px;padding-top:14px;border-top:1px solid var(--line-2);font-size:10.5px}.body-preview{margin-top:14px;padding:14px 16px;background:var(--surface);border:1px solid var(--line);border-radius:4px;font-family:var(--prose);font-size:12.5px;line-height:1.62;color:var(--fg-2);white-space:pre-wrap;max-width:760px;max-height:420px;overflow:auto}.sent-strip{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;border:1px solid rgba(26,127,90,.4);background:#1a7f5a12;border-radius:4px;padding:14px 16px;font-size:11.5px}.sent-strip strong{color:var(--green);font-size:13px;white-space:nowrap}.sent-strip a{display:inline-flex;align-items:center;gap:5px;white-space:nowrap;text-decoration:none}.post-actions{display:flex;align-items:center;gap:12px;margin-top:12px;font-size:10.5px}.post-actions .link{font-size:10.5px;color:var(--muted)}.post-actions .link:hover{color:var(--fg)}.md>:first-child{margin-top:0}.md>:last-child{margin-bottom:0}.md p{margin:8px 0}.md code{font-family:var(--mono);font-size:.9em;background:#f0f2f5;padding:1px 5px;border-radius:3px}.md pre{background:#f7f8fa;border:1px solid var(--line);border-radius:4px;padding:10px 12px;overflow-x:auto}.md pre code{background:none;padding:0;font-size:11.5px}.md ul,.md ol{margin:8px 0;padding-left:24px}.md blockquote{margin:8px 0;padding-left:12px;border-left:2px solid var(--line-2);color:var(--muted)}.md h1,.md h2,.md h3,.md h4{font-size:1em;font-weight:650;letter-spacing:-.01em;margin:18px 0 2px;color:var(--fg)}.md h1+p,.md h2+p,.md h3+p,.md h4+p,.md h1+ul,.md h2+ul,.md h3+ul,.md h4+ul{margin-top:4px}.settings{max-width:780px;margin:0 auto;padding:24px 40px 64px;font-family:var(--prose);font-size:13px;line-height:1.6;color:var(--fg-2)}.settings .back{display:inline-block;font-family:var(--mono);font-size:11.5px;text-decoration:none;margin-bottom:16px}.settings h1{font-family:var(--mono);font-size:15px;margin:32px 0 8px}.settings h2{font-family:var(--mono);font-size:13px;margin:28px 0 8px}.settings code{font-family:var(--mono);font-size:.92em;background:#f0f2f5;padding:1px 5px;border-radius:3px}.inbox-toggle{display:block;margin:8px 0}.trust-add{display:flex;gap:8px;margin:20px 0 12px}.trust-add input{flex:1;font:inherit;padding:7px 10px;color:var(--fg);border:1px solid var(--line-2);border-radius:4px;background:var(--surface)}.trust-table{border-collapse:collapse;width:100%}.trust-table td{padding:6px 10px 6px 0;border-bottom:1px solid var(--line);vertical-align:baseline}.trust-actions{text-align:right}.trust-deny code{text-decoration:line-through}.tone-approve{color:var(--green)}.tone-comment{color:var(--amber)}.tone-changes{color:var(--red)}.tone-running,.tone-sent,.tone-none{color:var(--muted);font-weight:400}.tone-failed{color:var(--red)}.tone-awaiting{color:var(--accent)}.tone-bg-approve{background:var(--green)}.tone-bg-comment{background:var(--amber)}.tone-bg-changes{background:var(--red)}.tone-border-approve{border-left:2px solid var(--green)}.tone-border-comment{border-left:2px solid var(--amber)}.tone-border-changes{border-left:2px solid var(--red)}.tone-border-none{border-left:2px solid var(--line-2)}.chip{font-size:11.5px;font-weight:500;padding:3px 9px;border-radius:3px;border:1px solid currentColor;background:#14171a08;white-space:nowrap}.chip-btn{display:inline-flex;align-items:center;gap:6px;font-family:inherit}.chip-btn:hover{background:#14171a12}.tag{font-size:10px;letter-spacing:.06em;padding:1px 6px;border-radius:3px;color:var(--muted);background:#f0f1f3;white-space:nowrap}.tag-yours{color:var(--accent);background:#3d4fd617}.tag-kept{color:var(--green);background:#1a7f5a1a}.tag-drift{color:var(--amber);background:#a06a001a}.sev-counts{font-size:12px;color:var(--muted);margin-bottom:6px}.verdict-hint{display:flex;align-items:center;gap:10px;flex-wrap:wrap;background:#a06a0012;border:1px solid rgba(160,106,0,.3);color:#57430a;padding:10px 12px;border-radius:4px}
|