@levistudio/redline 0.1.0 → 0.3.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 +27 -7
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/agent.ts +8 -4
- package/src/cli.ts +41 -4
- package/src/client/cards.ts +12 -2
- package/src/client/diff.ts +58 -23
- package/src/client/diffToggle.ts +32 -0
- package/src/client/lib.ts +147 -72
- package/src/client/main.ts +10 -4
- package/src/client/render.ts +17 -5
- package/src/client/selection.ts +1 -1
- package/src/client/sse.ts +33 -1
- package/src/client/state.ts +22 -0
- package/src/client/styles.css +134 -93
- package/src/parseReply.ts +9 -1
- package/src/render.ts +9 -0
- package/src/resolve.ts +174 -91
- package/src/reviewSummary.ts +93 -0
- package/src/server-page.ts +5 -7
- package/src/server.ts +65 -14
- package/src/sidecar.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,17 +4,33 @@ All notable changes to Redline are documented here. The format follows [Keep a C
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.3.0] - 2026-05-15
|
|
8
|
+
|
|
7
9
|
### Added
|
|
8
|
-
-
|
|
9
|
-
|
|
10
|
+
- **Escalation handoff** ([#86](https://github.com/alevi/redline/pull/86)). When a comment needs something the inline review agent can't provide — an external style guide, a spec it can't see, a decision that needs the wider project — the agent flags its reply with an escalate verdict. The comment shows an "Escalated" badge, the round banner notes the count, and the session's closeout summary and `.review/<file>.result` carry an `escalations` array so the agent that launched the review picks the feedback up.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Revision integrity check and retry** ([#86](https://github.com/alevi/redline/pull/86)). The revision pass now validates its output on every run and rejects a revision that silently drops document sections no comment touched, instead of writing a mangled document to disk. A failed validation retries once before surfacing an error.
|
|
14
|
+
- **Cross-block and image-spanning selections** ([#85](https://github.com/alevi/redline/pull/85)) now anchor correctly. Selecting across a block boundary, or across an image, no longer fails with an anchoring error.
|
|
15
|
+
- **Sessions are no longer abandoned on a transient SSE drop** ([#84](https://github.com/alevi/redline/pull/84)). A backgrounded tab, laptop sleep, or a brief network blip no longer causes the server to exit; only an explicit tab close ends the session. A server that does go away surfaces a clear "session ended" banner instead of a raw fetch error.
|
|
16
|
+
|
|
17
|
+
## [0.2.0] - 2026-05-11
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- **Inline diff view** with a header toggle on revised rounds ([#79](https://github.com/alevi/redline/pull/79)). After a revision pass, the document opens with the diff rendered in place — block-level insert/delete bands and word-level marks for modified paragraphs. `Show changes` / `Hide changes` in the header flips between diff and clean view at any time. View choice persists per file in `sessionStorage`.
|
|
21
|
+
- **Markdown rendering in agent thread replies** ([#75](https://github.com/alevi/redline/pull/75)). Agent replies in the sidebar now render `**bold**`, lists, inline `code`, fenced blocks, and links instead of showing raw markdown. Same `marked` + `sanitize-html` pipeline as the document body.
|
|
10
22
|
|
|
11
23
|
### Changed
|
|
12
|
-
-
|
|
13
|
-
-
|
|
24
|
+
- **Doc header decluttered** ([#74](https://github.com/alevi/redline/pull/74)). `Compare with previous` moved out of the header and into the round-badge dropdown (only when there's a prior round to compare against). Filename no longer renders all-caps.
|
|
25
|
+
- **Banner hierarchy fixed** ([#77](https://github.com/alevi/redline/pull/77)). The reviewer's `--context` focus now reads at full document weight with a 3px accent stripe; the first-run safety notice is demoted to a muted inline line with an underlined "Got it" link. Importance now matches behavior: context shapes every reply for the session, the safety notice is once-per-machine.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- **Selections spanning a heading into a paragraph** ([#78](https://github.com/alevi/redline/pull/78)) are no longer rejected. The browser's `Selection.toString()` emits `\n\n` between blocks; `captureSelection` now normalizes those before searching the document text, and `highlightText` stores the normalized form so the highlight survives re-renders.
|
|
29
|
+
- Block-level deletes in the diff view now render with strikethrough (previously only word-level deletes inside modified paragraphs were struck through) ([#79](https://github.com/alevi/redline/pull/79)).
|
|
14
30
|
|
|
15
|
-
## [0.1.0] - 2026-05-
|
|
31
|
+
## [0.1.0] - 2026-05-11
|
|
16
32
|
|
|
17
|
-
Initial public release
|
|
33
|
+
Initial public release on npm as `@levistudio/redline`.
|
|
18
34
|
|
|
19
35
|
### Added
|
|
20
36
|
- Local review reader (Bun + Hono server, browser UI) for Markdown files.
|
|
@@ -25,6 +41,8 @@ Initial public release.
|
|
|
25
41
|
- Verdict-aware resolve: every agent reply ships with `requires_revision` so the round-level button auto-defaults to **Revise document** or **Accept as-is**.
|
|
26
42
|
- One-shot revision command: `redline resolve <file> [--model <id>]`.
|
|
27
43
|
- `redline-review` skill for outer-agent handoff (Claude Code etc.).
|
|
44
|
+
- Node-compatible launcher (`bin/redline.cjs`) so `npx @levistudio/redline <file>` works alongside `bunx`.
|
|
45
|
+
- `ROADMAP.md` and this changelog.
|
|
28
46
|
- CSRF token on every mutating `/api/*` request.
|
|
29
47
|
- Cross-process file lock around sidecar transactions.
|
|
30
48
|
- `realpath` check on the static-asset route to block symlink escapes.
|
|
@@ -33,5 +51,7 @@ Initial public release.
|
|
|
33
51
|
- Auto-installs missing dependencies on first CLI run.
|
|
34
52
|
- Initial test suite: server, sidecar, parsing, model-picking, rendering, diff, SSE, integration, happy-dom client.
|
|
35
53
|
|
|
36
|
-
[Unreleased]: https://github.com/alevi/redline/compare/v0.
|
|
54
|
+
[Unreleased]: https://github.com/alevi/redline/compare/v0.3.0...HEAD
|
|
55
|
+
[0.3.0]: https://github.com/alevi/redline/releases/tag/v0.3.0
|
|
56
|
+
[0.2.0]: https://github.com/alevi/redline/releases/tag/v0.2.0
|
|
37
57
|
[0.1.0]: https://github.com/alevi/redline/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ Two long-lived processes:
|
|
|
42
42
|
|
|
43
43
|
Review state lives in a sidecar JSON file at `.review/<filename>.json` next to the doc. History snapshots of every revision land in `.review/history/<filename>.<iso>.md` *before* the revision is written, so you can roll back from disk if a revision goes sideways. Both should be gitignored unless you want them in the repo.
|
|
44
44
|
|
|
45
|
-
After each revision, the
|
|
45
|
+
After each revision, the new round opens with the document rendered inline as a diff against the previous round — insert and delete bands at the block level, word-level marks within modified paragraphs — so you can read the agent's changes in place. Use the **Show changes** / **Hide changes** toggle in the header to flip back to the clean view at any time. From there you either open another round of comments or click **Looks good** to finish.
|
|
46
46
|
|
|
47
47
|
[CLAUDE.md](CLAUDE.md) has the full architecture tour: sidecar schema, SSE event vocabulary, model picking, frontend gotchas.
|
|
48
48
|
|
package/package.json
CHANGED
package/src/agent.ts
CHANGED
|
@@ -89,8 +89,11 @@ const REPLY_SYSTEM_PROMPT_BODY =
|
|
|
89
89
|
"Good: message: \"Got it.\" reason: \"Add a third line to the hard line breaks example\"\n" +
|
|
90
90
|
"When requires_revision is false, leave reason empty (or a very short note about why no edit). The reply IS the answer.\n" +
|
|
91
91
|
"\n" +
|
|
92
|
+
"Separately, decide whether this comment needs the agent that LAUNCHED this review rather than you. That outer agent has the project context, tools, and authority you don't — e.g. an external style guide or canon you can't see, a spec to check the document against, or a decision that depends on the wider project. When the comment asks for something like that, set ESCALATE: true and briefly tell the reviewer in your message that you've routed it to the launching agent. Otherwise ESCALATE: false. Escalating does not change requires_revision — judge that on its own.\n" +
|
|
93
|
+
"\n" +
|
|
92
94
|
"Output exactly this format and nothing else (no prose before/after, no code fences). Use these literal markers — do not escape quotes inside the message:\n" +
|
|
93
95
|
"REQUIRES_REVISION: <true|false>\n" +
|
|
96
|
+
"ESCALATE: <true|false>\n" +
|
|
94
97
|
"REASON: <one short sentence describing the edit, or empty>\n" +
|
|
95
98
|
"---MESSAGE---\n" +
|
|
96
99
|
"<your reply text — quotes, punctuation, anything>\n" +
|
|
@@ -114,12 +117,13 @@ async function postReply(
|
|
|
114
117
|
commentId: string,
|
|
115
118
|
message: string,
|
|
116
119
|
requires_revision: boolean,
|
|
117
|
-
revision_reason: string
|
|
120
|
+
revision_reason: string,
|
|
121
|
+
escalate: boolean
|
|
118
122
|
) {
|
|
119
123
|
await fetch(`${BASE_URL}/api/comment/${commentId}/reply`, {
|
|
120
124
|
method: "POST",
|
|
121
125
|
headers: { "Content-Type": "application/json", ...CSRF_HEADER },
|
|
122
|
-
body: JSON.stringify({ role: "agent", name: "Claude", message, requires_revision, revision_reason }),
|
|
126
|
+
body: JSON.stringify({ role: "agent", name: "Claude", message, requires_revision, revision_reason, escalate }),
|
|
123
127
|
});
|
|
124
128
|
}
|
|
125
129
|
|
|
@@ -185,8 +189,8 @@ async function handleComment(commentId: string) {
|
|
|
185
189
|
|
|
186
190
|
if (reply.trim()) {
|
|
187
191
|
const parsed = parseReply(reply);
|
|
188
|
-
console.log(`[agent] verdict for ${commentId}: requires_revision=${parsed.requires_revision}`);
|
|
189
|
-
await postReply(commentId, parsed.message, parsed.requires_revision, parsed.reason);
|
|
192
|
+
console.log(`[agent] verdict for ${commentId}: requires_revision=${parsed.requires_revision} escalate=${parsed.escalate}`);
|
|
193
|
+
await postReply(commentId, parsed.message, parsed.requires_revision, parsed.reason, parsed.escalate);
|
|
190
194
|
}
|
|
191
195
|
} catch (err) {
|
|
192
196
|
await logReplyFailure(commentId, err);
|
package/src/cli.ts
CHANGED
|
@@ -4,6 +4,10 @@ import { mkdir, writeFile } from "fs/promises";
|
|
|
4
4
|
import { spawnSync } from "child_process";
|
|
5
5
|
import { createRequire } from "module";
|
|
6
6
|
import path from "path";
|
|
7
|
+
// reviewSummary only `import type`s from ./sidecar, so it pulls in no
|
|
8
|
+
// third-party deps at runtime — safe to import statically ahead of the
|
|
9
|
+
// preflight. abandon() needs it synchronously (signal context).
|
|
10
|
+
import { collectEscalations, formatReviewSummary } from "./reviewSummary";
|
|
7
11
|
|
|
8
12
|
// Ensure redline's own dependencies are resolvable before any third-party
|
|
9
13
|
// imports load. `redline` is invoked from arbitrary projects: in a checkout
|
|
@@ -137,6 +141,7 @@ if (args[0] === "resolve") {
|
|
|
137
141
|
|
|
138
142
|
const resultFile = path.join(path.dirname(resolved), ".review", path.basename(resolved) + ".result");
|
|
139
143
|
const startupFile = path.join(path.dirname(resolved), ".review", path.basename(resolved) + ".startup.json");
|
|
144
|
+
const sidecarFile = path.join(path.dirname(resolved), ".review", path.basename(resolved) + ".json");
|
|
140
145
|
|
|
141
146
|
// Clear stale state from a prior run so a polling agent can't be misled
|
|
142
147
|
// by a leftover .result or .startup.json file that predates this process.
|
|
@@ -274,19 +279,36 @@ if (args[0] === "resolve") {
|
|
|
274
279
|
const status = lastRevisionError ? "error" : "abandoned";
|
|
275
280
|
const payload: Record<string, unknown> = { status, file: resolved };
|
|
276
281
|
if (lastRevisionError) payload.reason = lastRevisionError;
|
|
282
|
+
// Carry escalations through the error/abandon path too — on an incomplete
|
|
283
|
+
// session they matter more, not less. Read the sidecar synchronously:
|
|
284
|
+
// abandon runs in signal context where async I/O may not complete.
|
|
285
|
+
let escalations: import("./reviewSummary").EscalationItem[] = [];
|
|
286
|
+
try {
|
|
287
|
+
if (existsSync(sidecarFile)) {
|
|
288
|
+
const raw = readFileSync(sidecarFile, "utf-8");
|
|
289
|
+
if (raw.trim()) escalations = collectEscalations(JSON.parse(raw));
|
|
290
|
+
}
|
|
291
|
+
} catch { /* best effort — never block shutdown on the summary */ }
|
|
292
|
+
payload.escalations = escalations;
|
|
277
293
|
// Synchronous write so the result file lands even if the runtime is
|
|
278
294
|
// terminating due to a signal (async I/O may not complete in that case).
|
|
279
295
|
try {
|
|
280
296
|
mkdirSync(path.dirname(resultFile), { recursive: true });
|
|
281
297
|
writeFileSync(resultFile, JSON.stringify(payload, null, 2));
|
|
282
298
|
} catch { /* best effort */ }
|
|
283
|
-
|
|
299
|
+
if (escalations.length) {
|
|
300
|
+
console.log(
|
|
301
|
+
`\n⚠ ${escalations.length} comment${escalations.length !== 1 ? "s" : ""} escalated to the launching agent — see ${path.basename(resultFile)}`
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
const escSuffix = escalations.length ? ` escalations=${escalations.length}` : "";
|
|
305
|
+
console.log(`\nREDLINE_RESULT: ${status}${lastRevisionError ? ` reason="${lastRevisionError}"` : ""}${escSuffix}`);
|
|
284
306
|
// Exit 3 = revision error; 2 = abandoned. Both still distinguish from 0 (approved).
|
|
285
307
|
process.exit(lastRevisionError ? 3 : 2);
|
|
286
308
|
};
|
|
287
309
|
|
|
288
310
|
// Happy-path finish: human clicked Done.
|
|
289
|
-
app.onFinished(({ totalRounds, totalComments }) => {
|
|
311
|
+
app.onFinished(async ({ totalRounds, totalComments }) => {
|
|
290
312
|
serverExiting = true;
|
|
291
313
|
killAgent().catch(() => { /* shutdown already in flight */ });
|
|
292
314
|
try { unlinkSync(startupFile); } catch { /* best effort */ }
|
|
@@ -296,10 +318,25 @@ if (args[0] === "resolve") {
|
|
|
296
318
|
console.log(` ${totalRounds} round${totalRounds !== 1 ? "s" : ""} · ${totalComments} comment${totalComments !== 1 ? "s" : ""} addressed`);
|
|
297
319
|
console.log(` Revised document: ${resolved}`);
|
|
298
320
|
console.log(`${line}`);
|
|
321
|
+
|
|
322
|
+
// Print the full comment threads so the launching agent — which has no
|
|
323
|
+
// live channel to the inline review agent — sees everything the reviewer
|
|
324
|
+
// said, including escalated feedback meant for it.
|
|
325
|
+
let escalations: import("./reviewSummary").EscalationItem[] = [];
|
|
326
|
+
try {
|
|
327
|
+
const { loadSidecar } = await import("./sidecar");
|
|
328
|
+
const sidecar = await loadSidecar(resolved);
|
|
329
|
+
escalations = collectEscalations(sidecar);
|
|
330
|
+
console.log(`\n${formatReviewSummary(sidecar)}`);
|
|
331
|
+
} catch (e) {
|
|
332
|
+
console.error("[redline] Failed to build review summary:", e);
|
|
333
|
+
}
|
|
334
|
+
|
|
299
335
|
// Machine-greppable result line for a calling agent. Keep this stable.
|
|
300
|
-
|
|
336
|
+
const escSuffix = escalations.length ? ` escalations=${escalations.length}` : "";
|
|
337
|
+
console.log(`\nREDLINE_RESULT: approved file=${resolved} rounds=${totalRounds} comments=${totalComments}${escSuffix}`);
|
|
301
338
|
console.log("");
|
|
302
|
-
writeResult({ status: "approved", file: resolved, rounds: totalRounds, comments: totalComments })
|
|
339
|
+
writeResult({ status: "approved", file: resolved, rounds: totalRounds, comments: totalComments, escalations })
|
|
303
340
|
.finally(() => process.exit(0));
|
|
304
341
|
});
|
|
305
342
|
|
package/src/client/cards.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { escapeHtml, latestVerdict, type ClientComment, type ThreadEntry } from "./lib";
|
|
1
|
+
import { escapeHtml, isEscalated, latestVerdict, type ClientComment, type ThreadEntry } from "./lib";
|
|
2
2
|
import { state } from "./state";
|
|
3
3
|
|
|
4
4
|
export type CardCallbacks = {
|
|
@@ -41,6 +41,12 @@ export function buildCommentCard(comment: ClientComment): HTMLDivElement {
|
|
|
41
41
|
quote.appendChild(vbadge);
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
if (isEscalated(comment)) {
|
|
45
|
+
const ebadge = document.createElement("span");
|
|
46
|
+
ebadge.className = "escalate-badge";
|
|
47
|
+
ebadge.textContent = "↑ Escalated";
|
|
48
|
+
quote.appendChild(ebadge);
|
|
49
|
+
}
|
|
44
50
|
quote.appendChild(document.createTextNode('"' + comment.quote + '"'));
|
|
45
51
|
card.appendChild(quote);
|
|
46
52
|
|
|
@@ -166,9 +172,13 @@ function buildThreadEntry(entry: ThreadEntry, isLatestVerdict: boolean): HTMLDiv
|
|
|
166
172
|
const reason = entry.revision_reason ? escapeHtml(entry.revision_reason) : "edit queued";
|
|
167
173
|
verdictHtml = `<div class="verdict revise"><span class="verdict-icon">\u270E</span><span>${reason}</span></div>`;
|
|
168
174
|
}
|
|
175
|
+
if (role === "agent" && entry.escalate === true) {
|
|
176
|
+
verdictHtml += `<div class="verdict escalate"><span class="verdict-icon">\u2191</span><span>Routed to the launching agent</span></div>`;
|
|
177
|
+
}
|
|
178
|
+
const messageHtml = entry.messageHtml ?? escapeHtml(entry.message);
|
|
169
179
|
div.innerHTML = `
|
|
170
180
|
<div class="thread-role ${role}">${escapeHtml(label)}</div>
|
|
171
|
-
<div class="thread-message">${
|
|
181
|
+
<div class="thread-message">${messageHtml}</div>
|
|
172
182
|
${verdictHtml}
|
|
173
183
|
`;
|
|
174
184
|
return div;
|
package/src/client/diff.ts
CHANGED
|
@@ -1,37 +1,72 @@
|
|
|
1
1
|
import { apiFetch } from "./state";
|
|
2
|
-
import { state } from "./state";
|
|
3
2
|
import {
|
|
4
|
-
|
|
3
|
+
applyDiffSwap,
|
|
4
|
+
diffStateKey,
|
|
5
|
+
revertDiffSwap,
|
|
6
|
+
updateToggleButton,
|
|
7
|
+
} from "./diffToggle";
|
|
8
|
+
import {
|
|
9
|
+
applyHighlights,
|
|
10
|
+
renderComments,
|
|
5
11
|
triggerRoundAction,
|
|
6
12
|
} from "./render";
|
|
7
13
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const banner = document.createElement("div");
|
|
11
|
-
banner.id = "revision-banner";
|
|
12
|
-
banner.className = "revision-banner";
|
|
13
|
-
banner.innerHTML =
|
|
14
|
-
'<span class="revision-banner-text">Document revised.</span>' +
|
|
15
|
-
'<button class="revision-banner-link" id="revision-banner-diff">See what changed \u2192</button>' +
|
|
16
|
-
'<button class="revision-banner-dismiss" aria-label="Dismiss">\u2715</button>';
|
|
17
|
-
banner.querySelector("#revision-banner-diff")!.addEventListener("click", () => {
|
|
18
|
-
banner.remove();
|
|
19
|
-
showDiffOverlay();
|
|
20
|
-
});
|
|
21
|
-
banner.querySelector(".revision-banner-dismiss")!.addEventListener("click", () => banner.remove());
|
|
22
|
-
const prose = document.getElementById("prose")!;
|
|
23
|
-
prose.parentNode!.insertBefore(banner, prose);
|
|
24
|
-
}
|
|
14
|
+
let originalProseHtml: string | null = null;
|
|
15
|
+
let diffHtmlCache: string | null = null;
|
|
25
16
|
|
|
26
|
-
async function
|
|
17
|
+
async function fetchDiffHtml(): Promise<string | null> {
|
|
18
|
+
if (diffHtmlCache !== null) return diffHtmlCache;
|
|
27
19
|
const res = await fetch("/api/diff");
|
|
28
20
|
const data = await res.json();
|
|
29
|
-
if (!data.ok) return;
|
|
30
|
-
|
|
21
|
+
if (!data.ok) return null;
|
|
22
|
+
diffHtmlCache = data.html as string;
|
|
23
|
+
return diffHtmlCache;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function syncToggleButton(on: boolean): void {
|
|
27
|
+
const btn = document.getElementById("btn-toggle-diff") as HTMLButtonElement | null;
|
|
28
|
+
if (btn) updateToggleButton(btn, on);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function enableDiffMode(): Promise<void> {
|
|
32
|
+
const prose = document.getElementById("prose");
|
|
33
|
+
if (!prose) return;
|
|
34
|
+
const html = await fetchDiffHtml();
|
|
35
|
+
if (html == null) return;
|
|
36
|
+
const previous = applyDiffSwap(prose, html);
|
|
37
|
+
if (previous != null) originalProseHtml = previous;
|
|
38
|
+
syncToggleButton(true);
|
|
39
|
+
try { sessionStorage.setItem(diffStateKey(window.__REDLINE__.contextTitle), "1"); } catch {}
|
|
40
|
+
applyHighlights();
|
|
41
|
+
renderComments();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function disableDiffMode(): void {
|
|
45
|
+
const prose = document.getElementById("prose");
|
|
46
|
+
if (!prose || originalProseHtml == null) return;
|
|
47
|
+
if (!revertDiffSwap(prose, originalProseHtml)) return;
|
|
48
|
+
syncToggleButton(false);
|
|
49
|
+
try { sessionStorage.removeItem(diffStateKey(window.__REDLINE__.contextTitle)); } catch {}
|
|
50
|
+
applyHighlights();
|
|
51
|
+
renderComments();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function toggleDiff(): Promise<void> {
|
|
55
|
+
const prose = document.getElementById("prose");
|
|
56
|
+
if (!prose) return;
|
|
57
|
+
if (prose.dataset.diffMode === "on") disableDiffMode();
|
|
58
|
+
else await enableDiffMode();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function showDiffOverlay(): Promise<void> {
|
|
62
|
+
const html = await fetchDiffHtml();
|
|
63
|
+
if (html == null) return;
|
|
64
|
+
document.getElementById("diff-panel-body")!.innerHTML = html;
|
|
31
65
|
document.getElementById("diff-overlay")!.classList.add("open");
|
|
32
66
|
}
|
|
33
67
|
|
|
34
68
|
export function initDiffHandlers(): void {
|
|
69
|
+
document.getElementById("btn-toggle-diff")?.addEventListener("click", () => { void toggleDiff(); });
|
|
35
70
|
document.getElementById("btn-compare")?.addEventListener("click", () => showDiffOverlay());
|
|
36
71
|
|
|
37
72
|
document.getElementById("diff-btn-accept")!.addEventListener("click", async () => {
|
|
@@ -40,7 +75,7 @@ export function initDiffHandlers(): void {
|
|
|
40
75
|
const btnAccept = document.getElementById("btn-accept") as HTMLButtonElement | null;
|
|
41
76
|
if (btnAccept) {
|
|
42
77
|
btnAccept.disabled = true;
|
|
43
|
-
btnAccept.textContent = "
|
|
78
|
+
btnAccept.textContent = "✓ Done";
|
|
44
79
|
}
|
|
45
80
|
const banner = document.getElementById("sidebar-status-banner");
|
|
46
81
|
if (banner) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Pure helpers for the inline-diff toggle. Kept in their own module so they
|
|
2
|
+
// can be unit-tested without pulling in render.ts (which expects a populated
|
|
3
|
+
// window.__REDLINE__ at module-load time).
|
|
4
|
+
|
|
5
|
+
export function diffStateKey(contextTitle: string): string {
|
|
6
|
+
return "rl-diff-on-" + contextTitle;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function updateToggleButton(btn: HTMLButtonElement, on: boolean): void {
|
|
10
|
+
btn.classList.toggle("active", on);
|
|
11
|
+
btn.setAttribute("aria-pressed", on ? "true" : "false");
|
|
12
|
+
btn.textContent = on ? "Hide changes" : "Show changes";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Swap prose contents into diff view. Returns the previous innerHTML so the
|
|
16
|
+
// caller can restore it later, or null if the prose is already in diff mode
|
|
17
|
+
// (we don't want to clobber the saved-original by overwriting it with the
|
|
18
|
+
// diff HTML we just installed).
|
|
19
|
+
export function applyDiffSwap(prose: HTMLElement, diffHtml: string): string | null {
|
|
20
|
+
if (prose.dataset.diffMode === "on") return null;
|
|
21
|
+
const previous = prose.innerHTML;
|
|
22
|
+
prose.innerHTML = diffHtml;
|
|
23
|
+
prose.dataset.diffMode = "on";
|
|
24
|
+
return previous;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function revertDiffSwap(prose: HTMLElement, originalHtml: string): boolean {
|
|
28
|
+
if (prose.dataset.diffMode !== "on") return false;
|
|
29
|
+
prose.innerHTML = originalHtml;
|
|
30
|
+
prose.dataset.diffMode = "off";
|
|
31
|
+
return true;
|
|
32
|
+
}
|
package/src/client/lib.ts
CHANGED
|
@@ -16,8 +16,13 @@ export type ThreadEntry = {
|
|
|
16
16
|
role?: "human" | "agent";
|
|
17
17
|
name?: string;
|
|
18
18
|
message: string;
|
|
19
|
+
// Server-rendered sanitized HTML for `message`. Present on entries
|
|
20
|
+
// delivered through the API/bootstrap; absent on entries the client
|
|
21
|
+
// builds locally before the next refresh. Falls back to escaped text.
|
|
22
|
+
messageHtml?: string;
|
|
19
23
|
requires_revision?: boolean;
|
|
20
24
|
revision_reason?: string;
|
|
25
|
+
escalate?: boolean;
|
|
21
26
|
};
|
|
22
27
|
|
|
23
28
|
export function escapeHtml(s: string): string {
|
|
@@ -40,6 +45,11 @@ export function latestVerdict(comment: ClientComment): "revise" | "accept" | nul
|
|
|
40
45
|
return null;
|
|
41
46
|
}
|
|
42
47
|
|
|
48
|
+
// True when an agent reply flagged this comment for the launching agent.
|
|
49
|
+
export function isEscalated(comment: ClientComment): boolean {
|
|
50
|
+
return (comment.thread || []).some((e) => e.role === "agent" && e.escalate === true);
|
|
51
|
+
}
|
|
52
|
+
|
|
43
53
|
export function nearestCell(node: Node): HTMLElement | null {
|
|
44
54
|
const el = node.nodeType === Node.TEXT_NODE ? node.parentNode : node;
|
|
45
55
|
return el && (el as Element).closest ? ((el as Element).closest("td, th") as HTMLElement | null) : null;
|
|
@@ -82,57 +92,138 @@ export type Captured = {
|
|
|
82
92
|
context_after: string;
|
|
83
93
|
};
|
|
84
94
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
95
|
+
type FlatSegment = {
|
|
96
|
+
node: Text | HTMLImageElement;
|
|
97
|
+
start: number;
|
|
98
|
+
len: number;
|
|
99
|
+
isImg: boolean;
|
|
100
|
+
};
|
|
90
101
|
|
|
91
|
-
|
|
92
|
-
|
|
102
|
+
// An <img> contributes no characters of its own, so on its own it can't be
|
|
103
|
+
// anchored against. We give it a presence in the flat text as an
|
|
104
|
+
// `[image: alt]` token — the same shape the image-only comment path uses — so
|
|
105
|
+
// a selection can run text → image → text and still round-trip.
|
|
106
|
+
function imgToken(img: HTMLImageElement): string {
|
|
107
|
+
return "[image: " + (img.alt || "") + "]";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Walk a container into a flat string plus the segments that produced it.
|
|
111
|
+
// Text nodes contribute their value; <img> elements contribute an
|
|
112
|
+
// `[image: alt]` token. Segments are in document order. captureSelection and
|
|
113
|
+
// highlightText both build flat through here so their coordinates agree.
|
|
114
|
+
function buildFlat(container: Element): { flat: string; segments: FlatSegment[] } {
|
|
115
|
+
const doc = container.ownerDocument || document;
|
|
116
|
+
const walker = doc.createTreeWalker(container, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
|
|
117
|
+
acceptNode(n: Node) {
|
|
118
|
+
if (n.nodeType === Node.TEXT_NODE) return NodeFilter.FILTER_ACCEPT;
|
|
119
|
+
return (n as Element).tagName === "IMG" ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
const segments: FlatSegment[] = [];
|
|
93
123
|
let flat = "";
|
|
94
124
|
let node: Node | null;
|
|
95
125
|
while ((node = walker.nextNode())) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
126
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
127
|
+
const v = (node as Text).nodeValue ?? "";
|
|
128
|
+
segments.push({ node: node as Text, start: flat.length, len: v.length, isImg: false });
|
|
129
|
+
flat += v;
|
|
130
|
+
} else {
|
|
131
|
+
const img = node as HTMLImageElement;
|
|
132
|
+
const tok = imgToken(img);
|
|
133
|
+
segments.push({ node: img, start: flat.length, len: tok.length, isImg: true });
|
|
134
|
+
flat += tok;
|
|
135
|
+
}
|
|
99
136
|
}
|
|
137
|
+
return { flat, segments };
|
|
138
|
+
}
|
|
100
139
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
140
|
+
// Map both range endpoints onto the flat text and return the quote with
|
|
141
|
+
// surrounding context. Returns null only when the selection can't be resolved
|
|
142
|
+
// against the flat text at all.
|
|
143
|
+
//
|
|
144
|
+
// The quote is sliced straight out of `flat` rather than reconstructed from
|
|
145
|
+
// `sel.toString()`. sel.toString() joins blocks with "\n"/"\n\n" separators
|
|
146
|
+
// that don't exist in the walker's output, and marked emits stray whitespace
|
|
147
|
+
// text nodes between block tags — so the two never line up across a block
|
|
148
|
+
// boundary. Working in flat coordinates sidesteps the mismatch: `flat` is the
|
|
149
|
+
// single source of truth, and highlightText re-finds the quote against the
|
|
150
|
+
// same flat string later. `text` is kept only as a last-resort fallback for
|
|
151
|
+
// the rare case where an endpoint can't be resolved.
|
|
152
|
+
export function captureSelection(prose: Element, sel: Selection, text: string): Captured | null {
|
|
153
|
+
const range = sel.getRangeAt(0);
|
|
154
|
+
const { flat, segments } = buildFlat(prose);
|
|
155
|
+
if (segments.length === 0) return null;
|
|
156
|
+
|
|
157
|
+
// Map a range boundary point onto an index into `flat`. Text-node boundaries
|
|
158
|
+
// are the common case; Chrome sometimes anchors a drag to an element node
|
|
159
|
+
// plus a child index, which we resolve to the nearest segment edge.
|
|
160
|
+
const pointToFlat = (container: Node, offset: number, side: "start" | "end"): number => {
|
|
161
|
+
for (const seg of segments) {
|
|
162
|
+
if (seg.node === container) {
|
|
163
|
+
if (seg.isImg) return offset > 0 ? seg.start + seg.len : seg.start;
|
|
164
|
+
return seg.start + offset;
|
|
165
|
+
}
|
|
106
166
|
}
|
|
107
|
-
|
|
167
|
+
if (container.nodeType === Node.TEXT_NODE) return -1;
|
|
168
|
+
const kids = container.childNodes;
|
|
169
|
+
const segEnd = (seg: FlatSegment) => seg.start + seg.len;
|
|
170
|
+
if (side === "start") {
|
|
171
|
+
const ref = offset < kids.length ? kids[offset]! : null;
|
|
172
|
+
if (ref) {
|
|
173
|
+
for (const seg of segments) {
|
|
174
|
+
if (ref === seg.node || ref.contains(seg.node)) return seg.start;
|
|
175
|
+
if (ref.compareDocumentPosition(seg.node) & Node.DOCUMENT_POSITION_FOLLOWING)
|
|
176
|
+
return seg.start;
|
|
177
|
+
}
|
|
178
|
+
return flat.length;
|
|
179
|
+
}
|
|
180
|
+
let end = -1;
|
|
181
|
+
for (const seg of segments) if (container.contains(seg.node)) end = segEnd(seg);
|
|
182
|
+
return end === -1 ? flat.length : end;
|
|
183
|
+
}
|
|
184
|
+
const ref = offset > 0 ? kids[offset - 1]! : null;
|
|
185
|
+
if (ref) {
|
|
186
|
+
let end = -1;
|
|
187
|
+
for (const seg of segments) {
|
|
188
|
+
if (
|
|
189
|
+
ref === seg.node ||
|
|
190
|
+
ref.contains(seg.node) ||
|
|
191
|
+
ref.compareDocumentPosition(seg.node) & Node.DOCUMENT_POSITION_PRECEDING
|
|
192
|
+
) {
|
|
193
|
+
end = segEnd(seg);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return end === -1 ? 0 : end;
|
|
197
|
+
}
|
|
198
|
+
for (const seg of segments) if (container.contains(seg.node)) return seg.start;
|
|
199
|
+
return 0;
|
|
200
|
+
};
|
|
108
201
|
|
|
109
|
-
|
|
110
|
-
|
|
202
|
+
let flatStart = pointToFlat(range.startContainer, range.startOffset, "start");
|
|
203
|
+
let flatEnd = pointToFlat(range.endContainer, range.endOffset, "end");
|
|
204
|
+
if (flatStart === -1 || flatEnd === -1 || flatEnd <= flatStart) {
|
|
205
|
+
return text ? { quote: text, context_before: "", context_after: "" } : null;
|
|
111
206
|
}
|
|
112
207
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const windowStart = Math.max(0, quoteStart - 64);
|
|
120
|
-
const windowEnd = Math.min(flat.length, quoteStart + text.length + 64);
|
|
121
|
-
const found = flat.indexOf(text, windowStart);
|
|
122
|
-
if (found === -1 || found >= windowEnd) return null;
|
|
123
|
-
quoteStart = found;
|
|
124
|
-
}
|
|
208
|
+
// Trim whitespace overshoot: a drag that ends a hair past a block — or
|
|
209
|
+
// starts in the gap before one — shouldn't fail or carry stray newlines.
|
|
210
|
+
// This is the "clamp": a small overshoot anchors to what the user meant.
|
|
211
|
+
while (flatStart < flatEnd && /\s/.test(flat[flatStart]!)) flatStart++;
|
|
212
|
+
while (flatEnd > flatStart && /\s/.test(flat[flatEnd - 1]!)) flatEnd--;
|
|
213
|
+
if (flatEnd <= flatStart) return null;
|
|
125
214
|
|
|
126
215
|
return {
|
|
127
|
-
quote:
|
|
128
|
-
context_before: flat.slice(Math.max(0,
|
|
129
|
-
context_after: flat.slice(
|
|
216
|
+
quote: flat.slice(flatStart, flatEnd),
|
|
217
|
+
context_before: flat.slice(Math.max(0, flatStart - 32), flatStart),
|
|
218
|
+
context_after: flat.slice(flatEnd, flatEnd + 32),
|
|
130
219
|
};
|
|
131
220
|
}
|
|
132
221
|
|
|
133
222
|
// Wrap occurrences of `text` inside `container` with <mark> elements. Uses
|
|
134
|
-
// `contextBefore` to disambiguate when a quote appears multiple times.
|
|
135
|
-
//
|
|
223
|
+
// `contextBefore` to disambiguate when a quote appears multiple times. The
|
|
224
|
+
// quote is matched against the same flat text captureSelection produced, so
|
|
225
|
+
// `[image: alt]` tokens in the quote wrap the corresponding <img> — whether
|
|
226
|
+
// the quote is image-only or text mixed with an image.
|
|
136
227
|
// Returns the marks created (caller can attach event listeners).
|
|
137
228
|
export function highlightText(
|
|
138
229
|
container: Element,
|
|
@@ -144,35 +235,9 @@ export function highlightText(
|
|
|
144
235
|
const doc = container.ownerDocument || document;
|
|
145
236
|
const marks: HTMLElement[] = [];
|
|
146
237
|
|
|
147
|
-
const imgMatch = text.match(/^\[image:\s*(.*)\]$/);
|
|
148
|
-
if (imgMatch) {
|
|
149
|
-
const alt = imgMatch[1];
|
|
150
|
-
const imgs = container.querySelectorAll("img");
|
|
151
|
-
for (const img of imgs) {
|
|
152
|
-
if ((img.alt || "") === alt) {
|
|
153
|
-
const mark = doc.createElement("mark");
|
|
154
|
-
mark.className = "rl-highlight rl-img" + (resolved ? " resolved" : "");
|
|
155
|
-
mark.dataset.commentId = id;
|
|
156
|
-
img.parentNode!.insertBefore(mark, img);
|
|
157
|
-
mark.appendChild(img);
|
|
158
|
-
marks.push(mark);
|
|
159
|
-
return marks;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
return marks;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
238
|
(container as HTMLElement).normalize();
|
|
166
239
|
|
|
167
|
-
const
|
|
168
|
-
const segments: { node: Text; start: number }[] = [];
|
|
169
|
-
let flat = "";
|
|
170
|
-
let node: Node | null;
|
|
171
|
-
while ((node = walker.nextNode())) {
|
|
172
|
-
const tn = node as Text;
|
|
173
|
-
segments.push({ node: tn, start: flat.length });
|
|
174
|
-
flat += tn.nodeValue ?? "";
|
|
175
|
-
}
|
|
240
|
+
const { flat, segments } = buildFlat(container);
|
|
176
241
|
|
|
177
242
|
let quoteStart = -1;
|
|
178
243
|
if (contextBefore) {
|
|
@@ -184,18 +249,28 @@ export function highlightText(
|
|
|
184
249
|
|
|
185
250
|
const quoteEnd = quoteStart + text.length;
|
|
186
251
|
|
|
187
|
-
const toWrap: { node: Text; localStart: number; localEnd: number }[] = [];
|
|
188
252
|
for (const seg of segments) {
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
253
|
+
const segStart = seg.start;
|
|
254
|
+
const segEnd = seg.start + seg.len;
|
|
255
|
+
if (segEnd <= quoteStart || segStart >= quoteEnd) continue;
|
|
256
|
+
|
|
257
|
+
if (seg.isImg) {
|
|
258
|
+
// Image tokens are atomic — wrap the <img> only when the quote covers
|
|
259
|
+
// the whole token, never on a partial overlap.
|
|
260
|
+
if (segStart < quoteStart || segEnd > quoteEnd) continue;
|
|
261
|
+
const img = seg.node as HTMLImageElement;
|
|
262
|
+
const mark = doc.createElement("mark");
|
|
263
|
+
mark.className = "rl-highlight rl-img" + (resolved ? " resolved" : "");
|
|
264
|
+
mark.dataset.commentId = id;
|
|
265
|
+
img.parentNode!.insertBefore(mark, img);
|
|
266
|
+
mark.appendChild(img);
|
|
267
|
+
marks.push(mark);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
197
270
|
|
|
198
|
-
|
|
271
|
+
const tn = seg.node as Text;
|
|
272
|
+
const localStart = Math.max(0, quoteStart - segStart);
|
|
273
|
+
const localEnd = Math.min(seg.len, quoteEnd - segStart);
|
|
199
274
|
const mark = doc.createElement("mark");
|
|
200
275
|
mark.className = "rl-highlight" + (resolved ? " resolved" : "");
|
|
201
276
|
mark.dataset.commentId = id;
|