@levistudio/redline 0.1.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/CHANGELOG.md +37 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/ROADMAP.md +39 -0
- package/SECURITY.md +33 -0
- package/bin/redline.cjs +61 -0
- package/package.json +61 -0
- package/scripts/install-skill.sh +78 -0
- package/skills/redline-review/SKILL.md +102 -0
- package/src/agent.ts +283 -0
- package/src/cli.ts +332 -0
- package/src/client/cards.ts +385 -0
- package/src/client/diff.ts +100 -0
- package/src/client/firstRunBanner.ts +26 -0
- package/src/client/lib.ts +299 -0
- package/src/client/main.ts +119 -0
- package/src/client/render.ts +413 -0
- package/src/client/selection.ts +253 -0
- package/src/client/sse.ts +179 -0
- package/src/client/state.ts +56 -0
- package/src/client/styles.css +994 -0
- package/src/contextBlock.ts +16 -0
- package/src/diff.ts +166 -0
- package/src/parseReply.ts +115 -0
- package/src/pickModel.ts +38 -0
- package/src/promptEnvelope.ts +58 -0
- package/src/render.ts +83 -0
- package/src/resolve.ts +290 -0
- package/src/server-page.ts +119 -0
- package/src/server.ts +634 -0
- package/src/sidecar.ts +190 -0
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { appendFile, copyFile, mkdir, readFile, writeFile } from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { loadSidecar, saveSidecar } from "./sidecar";
|
|
4
|
+
import type { Round } from "./sidecar";
|
|
5
|
+
import { pickRevisionModel } from "./pickModel";
|
|
6
|
+
import { newEnvelope } from "./promptEnvelope";
|
|
7
|
+
import { contextBlock } from "./contextBlock";
|
|
8
|
+
|
|
9
|
+
const serverBase = () => `http://localhost:${process.env.REDLINE_PORT ?? "3000"}`;
|
|
10
|
+
const csrfHeader = (): Record<string, string> => ({ "X-Redline-Token": process.env.REDLINE_TOKEN ?? "" });
|
|
11
|
+
|
|
12
|
+
export async function resolve(filePath: string, options: { model?: string } = {}) {
|
|
13
|
+
const model = options.model ?? null;
|
|
14
|
+
const sidecar = await loadSidecar(filePath);
|
|
15
|
+
|
|
16
|
+
// Find the most recently accepted round
|
|
17
|
+
const resolvedRounds = sidecar.rounds.filter((r) => r.resolved_at != null);
|
|
18
|
+
if (resolvedRounds.length === 0) {
|
|
19
|
+
throw new Error("No accepted round found — human must click 'Accept & revise' first");
|
|
20
|
+
}
|
|
21
|
+
const round: Round = resolvedRounds[resolvedRounds.length - 1];
|
|
22
|
+
const settled = round.comments.filter((c) => c.resolved);
|
|
23
|
+
const chosenModel = model ?? pickRevisionModel(settled);
|
|
24
|
+
|
|
25
|
+
const docText = await readFile(filePath, "utf-8");
|
|
26
|
+
|
|
27
|
+
// Save history snapshot before any changes
|
|
28
|
+
const historyDir = path.join(path.dirname(filePath), ".review", "history");
|
|
29
|
+
await mkdir(historyDir, { recursive: true });
|
|
30
|
+
const snapTs = new Date().toISOString();
|
|
31
|
+
const snapFile = path.join(historyDir, `${path.basename(filePath)}.${snapTs}.md`);
|
|
32
|
+
await copyFile(filePath, snapFile);
|
|
33
|
+
console.log(`Saved snapshot → .review/history/${path.basename(snapFile)}\n`);
|
|
34
|
+
|
|
35
|
+
if (settled.length === 0) {
|
|
36
|
+
console.log("No settled comments — no revision needed.");
|
|
37
|
+
await openNextRound(sidecar, filePath);
|
|
38
|
+
try { await fetch(`${serverBase()}/api/reload`, { method: "POST", headers: csrfHeader() }); } catch { /* non-fatal */ }
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Build prompt — wrap user-controlled strings (quote, discussion text, the
|
|
43
|
+
// document body, prior-round agent replies that may echo user content) in a
|
|
44
|
+
// per-prompt envelope so adversarial comment content can't masquerade as
|
|
45
|
+
// system instructions or another section.
|
|
46
|
+
const env = newEnvelope();
|
|
47
|
+
const commentsBlock = settled
|
|
48
|
+
.map((c, i) => {
|
|
49
|
+
const discussion = c.thread
|
|
50
|
+
.map((e) => ` ${e.role === "agent" ? "Agent" : "Reviewer"}: ${e.message}`)
|
|
51
|
+
.join("\n");
|
|
52
|
+
return `${i + 1}. Quote:\n${env.wrap(`comment-${i}-quote`, c.quote)}\n Discussion:\n${env.wrap(`comment-${i}-discussion`, discussion)}`;
|
|
53
|
+
})
|
|
54
|
+
.join("\n\n");
|
|
55
|
+
|
|
56
|
+
// Summarise what was agreed in earlier rounds so the model doesn't undo them
|
|
57
|
+
const priorRounds = resolvedRounds.slice(0, -1);
|
|
58
|
+
let priorChangesBlock = "";
|
|
59
|
+
if (priorRounds.length > 0) {
|
|
60
|
+
const lines = priorRounds.flatMap((r) =>
|
|
61
|
+
r.comments
|
|
62
|
+
.filter((c) => c.resolved)
|
|
63
|
+
.map((c) => {
|
|
64
|
+
const lastAgent = [...c.thread].reverse().find((e) => e.role === "agent");
|
|
65
|
+
return `- Round ${r.round}: ${env.wrap(`prior-${r.round}-quote`, c.quote)} → ${env.wrap(`prior-${r.round}-reply`, lastAgent?.message ?? "(resolved)")}`;
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
if (lines.length > 0) {
|
|
69
|
+
priorChangesBlock = `\n\n<previously-agreed-changes>\n${lines.join("\n")}\n</previously-agreed-changes>`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const systemPrompt =
|
|
74
|
+
"You are revising a Markdown document based on settled reviewer comments.\n" +
|
|
75
|
+
"For each comment, edit the relevant passage in the document to reflect what was agreed.\n" +
|
|
76
|
+
"Preserve all sections that have no comments exactly as they are.\n" +
|
|
77
|
+
"Return ONLY the revised contents of <document> — the full Markdown document, nothing else.\n" +
|
|
78
|
+
"Do NOT include the <document> tags themselves in your output.\n" +
|
|
79
|
+
"Do NOT echo, summarize, or append any of the <comments-to-apply> or <previously-agreed-changes> content.\n" +
|
|
80
|
+
"Do NOT add commentary, preamble, or meta-sections like 'Settled comments' or 'Changelog'.\n" +
|
|
81
|
+
"The output should look like a clean revision of the original document — as if a human editor made the changes silently.\n" +
|
|
82
|
+
"\n" +
|
|
83
|
+
env.systemPromptHint();
|
|
84
|
+
|
|
85
|
+
// Prepend the reviewer's stated focus when --context was set; the helper
|
|
86
|
+
// returns empty otherwise. Goes before the comments so the model reads the
|
|
87
|
+
// user's lens first and weights the revision accordingly.
|
|
88
|
+
const userMessage =
|
|
89
|
+
contextBlock(sidecar.context, env) +
|
|
90
|
+
`<comments-to-apply>\n${commentsBlock}\n</comments-to-apply>${priorChangesBlock}\n\n<document>\n${env.wrap("document", docText)}\n</document>`;
|
|
91
|
+
|
|
92
|
+
// Call the claude CLI (inherits auth from the user's Claude Code session — no API key needed)
|
|
93
|
+
console.log(`Revising with ${chosenModel}...\n`);
|
|
94
|
+
console.log("─".repeat(60));
|
|
95
|
+
const revisionStartedAt = Date.now();
|
|
96
|
+
|
|
97
|
+
const cliBin = process.env.CLAUDE_CODE_EXECPATH ?? "claude";
|
|
98
|
+
const proc = Bun.spawn(
|
|
99
|
+
[cliBin, "-p", "--system-prompt", systemPrompt, "--model", chosenModel,
|
|
100
|
+
"--output-format", "stream-json", "--include-partial-messages", "--verbose"],
|
|
101
|
+
{
|
|
102
|
+
stdin: "pipe",
|
|
103
|
+
stdout: "pipe",
|
|
104
|
+
stderr: "pipe",
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
proc.stdin.write(userMessage);
|
|
109
|
+
proc.stdin.end();
|
|
110
|
+
|
|
111
|
+
// Drain stderr concurrently so we can include it in any error report
|
|
112
|
+
let stderrText = "";
|
|
113
|
+
(async () => {
|
|
114
|
+
const r = proc.stderr.getReader();
|
|
115
|
+
const dec = new TextDecoder();
|
|
116
|
+
while (true) {
|
|
117
|
+
const { done, value } = await r.read();
|
|
118
|
+
if (done) break;
|
|
119
|
+
const chunk = dec.decode(value);
|
|
120
|
+
stderrText += chunk;
|
|
121
|
+
process.stderr.write(chunk);
|
|
122
|
+
}
|
|
123
|
+
})();
|
|
124
|
+
|
|
125
|
+
let revised = "";
|
|
126
|
+
let buffer = "";
|
|
127
|
+
const reader = proc.stdout.getReader();
|
|
128
|
+
const broadcastChunk = (text: string, kind: "thinking" | "text") => {
|
|
129
|
+
fetch(`${serverBase()}/api/revision-chunk`, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: { "Content-Type": "application/json", ...csrfHeader() },
|
|
132
|
+
body: JSON.stringify({ text, kind }),
|
|
133
|
+
}).catch(() => {});
|
|
134
|
+
};
|
|
135
|
+
while (true) {
|
|
136
|
+
const { done, value } = await reader.read();
|
|
137
|
+
if (done) break;
|
|
138
|
+
buffer += new TextDecoder().decode(value);
|
|
139
|
+
const lines = buffer.split("\n");
|
|
140
|
+
buffer = lines.pop() ?? "";
|
|
141
|
+
for (const line of lines) {
|
|
142
|
+
if (!line.trim()) continue;
|
|
143
|
+
try {
|
|
144
|
+
const obj = JSON.parse(line);
|
|
145
|
+
if (obj.type === "stream_event" && obj.event?.type === "content_block_delta") {
|
|
146
|
+
const delta = obj.event.delta;
|
|
147
|
+
if (delta?.type === "text_delta" && delta.text) {
|
|
148
|
+
revised += delta.text;
|
|
149
|
+
process.stdout.write(delta.text);
|
|
150
|
+
broadcastChunk(delta.text, "text");
|
|
151
|
+
} else if (delta?.type === "thinking_delta" && delta.thinking) {
|
|
152
|
+
broadcastChunk(delta.thinking, "thinking");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch { /* malformed JSON line, skip */ }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const exitCode = await proc.exited;
|
|
160
|
+
const revisionDurationMs = Date.now() - revisionStartedAt;
|
|
161
|
+
console.log("\n" + "─".repeat(60));
|
|
162
|
+
console.log(`Model: ${chosenModel} · Duration: ${(revisionDurationMs / 1000).toFixed(1)}s · Exit: ${exitCode}`);
|
|
163
|
+
console.log("─".repeat(60) + "\n");
|
|
164
|
+
|
|
165
|
+
const fail = async (reason: string) => {
|
|
166
|
+
await logRevisionFailure(filePath, {
|
|
167
|
+
reason,
|
|
168
|
+
model: chosenModel,
|
|
169
|
+
exitCode,
|
|
170
|
+
stderr: stderrText.trim(),
|
|
171
|
+
stdoutSample: revised.slice(0, 2000),
|
|
172
|
+
stdoutLength: revised.length,
|
|
173
|
+
});
|
|
174
|
+
throw new Error(reason);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
if (exitCode !== 0) {
|
|
178
|
+
await fail(`claude CLI exited with code ${exitCode}${stderrText.trim() ? ` — ${stderrText.trim().split("\n").slice(-3).join(" | ")}` : ""}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Validate output. Strip a wrapping code fence if present, a preamble before the
|
|
182
|
+
// first heading, any <document> wrapper tags, and any meta-sections the model
|
|
183
|
+
// sometimes appends (Settled comments, Previously agreed changes, Changelog).
|
|
184
|
+
let trimmed = revised.trim()
|
|
185
|
+
.replace(/^```(?:markdown)?\n([\s\S]*)\n```$/, "$1")
|
|
186
|
+
.replace(/^<document>\s*/i, "")
|
|
187
|
+
.replace(/\s*<\/document>\s*$/i, "")
|
|
188
|
+
.trim();
|
|
189
|
+
// Strip trailing meta-sections at any heading level (## or ###).
|
|
190
|
+
const metaHeading = trimmed.match(/\n#{2,3} (Settled comments|Previously agreed changes|Changelog|Revision notes)\b/i);
|
|
191
|
+
if (metaHeading) {
|
|
192
|
+
console.log(`Stripping trailing meta-section: ${metaHeading[1]}`);
|
|
193
|
+
trimmed = trimmed.slice(0, metaHeading.index).trimEnd();
|
|
194
|
+
// Strip a trailing horizontal rule that often precedes the meta-section.
|
|
195
|
+
trimmed = trimmed.replace(/\n+---\s*$/, "").trimEnd();
|
|
196
|
+
}
|
|
197
|
+
if (!trimmed) {
|
|
198
|
+
await fail("Agent returned empty output (no text deltas streamed)");
|
|
199
|
+
}
|
|
200
|
+
if (!trimmed.startsWith("#")) {
|
|
201
|
+
const firstHeadingIdx = trimmed.search(/^# /m);
|
|
202
|
+
if (firstHeadingIdx > 0) {
|
|
203
|
+
console.log("Stripping preamble before first heading.");
|
|
204
|
+
trimmed = trimmed.slice(firstHeadingIdx).trim();
|
|
205
|
+
} else {
|
|
206
|
+
await fail("Output contains no Markdown heading — model returned non-document content");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// If the model made no changes, skip the write and signal the browser
|
|
211
|
+
if (trimmed === docText.trim()) {
|
|
212
|
+
console.log("No changes — output identical to input. Skipping file write.");
|
|
213
|
+
await openNextRound(sidecar, filePath);
|
|
214
|
+
try { await fetch(`${serverBase()}/api/revision-no-changes`, { method: "POST", headers: csrfHeader() }); } catch { /* non-fatal */ }
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Write revised document
|
|
219
|
+
await writeFile(filePath, trimmed, "utf-8");
|
|
220
|
+
|
|
221
|
+
// Print change summary
|
|
222
|
+
printChangeSummary(docText, trimmed, filePath);
|
|
223
|
+
|
|
224
|
+
// Open next round
|
|
225
|
+
await openNextRound(sidecar, filePath);
|
|
226
|
+
|
|
227
|
+
// Notify browser to reload
|
|
228
|
+
try {
|
|
229
|
+
await fetch(`${serverBase()}/api/reload`, { method: "POST", headers: csrfHeader() });
|
|
230
|
+
} catch { /* server may not be running — non-fatal */ }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function logRevisionFailure(
|
|
234
|
+
filePath: string,
|
|
235
|
+
details: { reason: string; model: string; exitCode: number; stderr: string; stdoutSample: string; stdoutLength: number }
|
|
236
|
+
) {
|
|
237
|
+
const logDir = path.join(path.dirname(filePath), ".review");
|
|
238
|
+
await mkdir(logDir, { recursive: true });
|
|
239
|
+
const logPath = path.join(logDir, "errors.log");
|
|
240
|
+
const entry =
|
|
241
|
+
`\n=== ${new Date().toISOString()} — ${path.basename(filePath)} ===\n` +
|
|
242
|
+
`reason: ${details.reason}\n` +
|
|
243
|
+
`model: ${details.model}\n` +
|
|
244
|
+
`exitCode: ${details.exitCode}\n` +
|
|
245
|
+
`stdoutLength: ${details.stdoutLength}\n` +
|
|
246
|
+
(details.stderr ? `stderr:\n${details.stderr}\n` : "") +
|
|
247
|
+
(details.stdoutSample ? `stdoutSample (first 2000 chars):\n${details.stdoutSample}\n` : "") +
|
|
248
|
+
`===\n`;
|
|
249
|
+
try {
|
|
250
|
+
await appendFile(logPath, entry, "utf-8");
|
|
251
|
+
console.error(`Logged failure → .review/errors.log`);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
console.error("Failed to write error log:", e);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function printChangeSummary(oldText: string, newText: string, filePath: string) {
|
|
258
|
+
const heading = (text: string) => text.match(/^#{1,3} .+$/gm) ?? [];
|
|
259
|
+
const oldH = heading(oldText);
|
|
260
|
+
const newH = heading(newText);
|
|
261
|
+
|
|
262
|
+
const added = newH.filter((h) => !oldH.includes(h));
|
|
263
|
+
const removed = oldH.filter((h) => !newH.includes(h));
|
|
264
|
+
|
|
265
|
+
console.log(`✓ Revised: ${path.basename(filePath)}`);
|
|
266
|
+
removed.forEach((h) => console.log(` − Removed: "${h}"`));
|
|
267
|
+
added.forEach((h) => console.log(` + Added: "${h}"`));
|
|
268
|
+
if (added.length === 0 && removed.length === 0) {
|
|
269
|
+
console.log(" ~ Sections unchanged, content updated in place");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function openNextRound(sidecar: ReturnType<typeof loadSidecar> extends Promise<infer T> ? T : never, filePath: string) {
|
|
274
|
+
const hasOpenRound = sidecar.rounds.some((r) => r.resolved_at === null);
|
|
275
|
+
if (!hasOpenRound) {
|
|
276
|
+
const nextNum = sidecar.rounds.length + 1;
|
|
277
|
+
sidecar.rounds.push({
|
|
278
|
+
round: nextNum,
|
|
279
|
+
started_at: new Date().toISOString(),
|
|
280
|
+
submitted_at: null,
|
|
281
|
+
agent_replied_at: null,
|
|
282
|
+
resolved_at: null,
|
|
283
|
+
comments: [],
|
|
284
|
+
});
|
|
285
|
+
await saveSidecar(filePath, sidecar);
|
|
286
|
+
console.log(`\nOpened round ${nextNum} — ready for next review.`);
|
|
287
|
+
} else {
|
|
288
|
+
await saveSidecar(filePath, sidecar);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Comment } from "./sidecar";
|
|
2
|
+
|
|
3
|
+
function escapeHtml(s: string): string {
|
|
4
|
+
return s
|
|
5
|
+
.replace(/&/g, "&")
|
|
6
|
+
.replace(/</g, "<")
|
|
7
|
+
.replace(/>/g, ">")
|
|
8
|
+
.replace(/"/g, """);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function pageTemplate(
|
|
12
|
+
title: string,
|
|
13
|
+
content: string,
|
|
14
|
+
comments: Comment[],
|
|
15
|
+
roundResolved: boolean,
|
|
16
|
+
agentRepliedAt: string | null,
|
|
17
|
+
roundNumber: number,
|
|
18
|
+
totalRounds: number,
|
|
19
|
+
context?: string,
|
|
20
|
+
readOnly = false,
|
|
21
|
+
csrfToken = "",
|
|
22
|
+
noAgent = false
|
|
23
|
+
): string {
|
|
24
|
+
const commentsJson = JSON.stringify(comments);
|
|
25
|
+
|
|
26
|
+
return `<!DOCTYPE html>
|
|
27
|
+
<html lang="en">
|
|
28
|
+
<head>
|
|
29
|
+
<meta charset="UTF-8">
|
|
30
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
31
|
+
<title>${escapeHtml(title)} — Redline</title>
|
|
32
|
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/styles/github.min.css">
|
|
33
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/highlight.min.js"></script>
|
|
34
|
+
<link rel="stylesheet" href="/styles.css">
|
|
35
|
+
</head>
|
|
36
|
+
<body>
|
|
37
|
+
<div class="layout">
|
|
38
|
+
<div class="reader-col">
|
|
39
|
+
<div class="doc-header">
|
|
40
|
+
<span class="doc-title">
|
|
41
|
+
${escapeHtml(title)}
|
|
42
|
+
<span style="position:relative">
|
|
43
|
+
<span class="round-badge${totalRounds > 1 ? ' repeat' : ''}${totalRounds > 1 ? ' clickable' : ''}" id="round-badge">Round ${roundNumber} of ${totalRounds}</span>
|
|
44
|
+
${totalRounds > 1 ? `<div class="round-picker" id="round-picker" style="display:none">${
|
|
45
|
+
Array.from({length: totalRounds}, (_, i) => i + 1).map(n => {
|
|
46
|
+
const isCurrent = n === roundNumber;
|
|
47
|
+
const href = n === totalRounds ? '/' : `/round/${n}`;
|
|
48
|
+
const label = n === totalRounds ? 'Round ' + n + ' — current' : 'Round ' + n;
|
|
49
|
+
return `<a class="round-picker-item${isCurrent ? ' current' : ''}" href="${href}">${label}</a>`;
|
|
50
|
+
}).join('')
|
|
51
|
+
}</div>` : ''}
|
|
52
|
+
</span>
|
|
53
|
+
</span>
|
|
54
|
+
<div class="header-actions">
|
|
55
|
+
<span id="agent-status" class="agent-status" hidden></span>
|
|
56
|
+
${noAgent ? `<span class="manual-mode-pill" title="Started with --no-agent. No Claude replies, no revision pass.">Manual mode</span>` : ''}
|
|
57
|
+
${readOnly
|
|
58
|
+
? `<span style="font-size:13px;color:var(--text-muted);font-style:italic">Read-only — <a href="/" style="color:var(--accent)">back to current</a></span>`
|
|
59
|
+
: `<button class="btn-accept" id="btn-accept" disabled>Revise document</button>
|
|
60
|
+
${totalRounds > 1 ? `<button class="btn-diff-compare" id="btn-compare">Compare with previous</button>` : ''}`
|
|
61
|
+
}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
${context ? `<div class="context-banner" id="context-banner">
|
|
65
|
+
<span class="context-label">Context</span>
|
|
66
|
+
<span class="context-text">${escapeHtml(context)}</span>
|
|
67
|
+
<button class="context-dismiss" onclick="dismissContextBanner()" aria-label="Dismiss">✕</button>
|
|
68
|
+
</div>` : ''}
|
|
69
|
+
${!readOnly ? `<div class="first-run-banner" id="first-run-banner" hidden>
|
|
70
|
+
<span class="first-run-icon" aria-hidden="true">⚠</span>
|
|
71
|
+
<span class="first-run-text">Redline sends document and comment text to your local Claude Code agent. Use trusted docs.</span>
|
|
72
|
+
<button class="first-run-dismiss" id="first-run-dismiss" aria-label="Dismiss">Got it</button>
|
|
73
|
+
</div>` : ''}
|
|
74
|
+
<article class="prose" id="prose">
|
|
75
|
+
${content}
|
|
76
|
+
</article>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div class="sidebar-col">
|
|
80
|
+
<div id="sidebar-status-banner"></div>
|
|
81
|
+
<div id="comment-nav" style="display:none">
|
|
82
|
+
<span class="nav-count" id="nav-count"></span>
|
|
83
|
+
<button id="nav-prev">↑ Prev</button>
|
|
84
|
+
<button id="nav-next">Next ↓</button>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
<div id="done-banner"></div>
|
|
91
|
+
<div id="diff-overlay">
|
|
92
|
+
<div id="diff-panel">
|
|
93
|
+
<div id="diff-panel-header">
|
|
94
|
+
<h2>Review changes</h2>
|
|
95
|
+
<button class="btn-diff-feedback" id="diff-btn-feedback">Give more feedback</button>
|
|
96
|
+
<button class="btn-diff-accept" id="diff-btn-accept">Looks good — close session</button>
|
|
97
|
+
<button class="btn-diff-close" id="diff-btn-close" aria-label="Close">✕</button>
|
|
98
|
+
</div>
|
|
99
|
+
<div id="diff-panel-body"></div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
<div id="error-banner" style="display:none;position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#b71c1c;color:white;padding:12px 24px;border-radius:6px;font-size:14px;font-weight:500;box-shadow:0 1px 4px rgba(0,0,0,0.08);z-index:999;white-space:nowrap;"></div>
|
|
103
|
+
|
|
104
|
+
<script>
|
|
105
|
+
window.__REDLINE__ = {
|
|
106
|
+
comments: ${commentsJson},
|
|
107
|
+
roundResolved: ${roundResolved},
|
|
108
|
+
totalRounds: ${totalRounds},
|
|
109
|
+
contextTitle: ${JSON.stringify(title)},
|
|
110
|
+
csrfToken: ${JSON.stringify(csrfToken)},
|
|
111
|
+
noAgent: ${JSON.stringify(noAgent)},
|
|
112
|
+
};
|
|
113
|
+
</script>
|
|
114
|
+
<script src="/client.js" defer></script>
|
|
115
|
+
</body>
|
|
116
|
+
</html>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export { escapeHtml, pageTemplate };
|