@levistudio/redline 0.1.0 → 0.2.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 +16 -7
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/client/cards.ts +2 -1
- package/src/client/diff.ts +58 -23
- package/src/client/diffToggle.ts +32 -0
- package/src/client/lib.ts +20 -10
- package/src/client/main.ts +10 -4
- package/src/client/styles.css +107 -93
- package/src/render.ts +9 -0
- package/src/server-page.ts +4 -7
- package/src/server.ts +20 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,17 +4,23 @@ All notable changes to Redline are documented here. The format follows [Keep a C
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.0] - 2026-05-11
|
|
8
|
+
|
|
7
9
|
### Added
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
+
- **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`.
|
|
11
|
+
- **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
12
|
|
|
11
13
|
### Changed
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
+
- **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.
|
|
15
|
+
- **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.
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
### Fixed
|
|
18
|
+
- **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.
|
|
19
|
+
- 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)).
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
## [0.1.0] - 2026-05-11
|
|
22
|
+
|
|
23
|
+
Initial public release on npm as `@levistudio/redline`.
|
|
18
24
|
|
|
19
25
|
### Added
|
|
20
26
|
- Local review reader (Bun + Hono server, browser UI) for Markdown files.
|
|
@@ -25,6 +31,8 @@ Initial public release.
|
|
|
25
31
|
- 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
32
|
- One-shot revision command: `redline resolve <file> [--model <id>]`.
|
|
27
33
|
- `redline-review` skill for outer-agent handoff (Claude Code etc.).
|
|
34
|
+
- Node-compatible launcher (`bin/redline.cjs`) so `npx @levistudio/redline <file>` works alongside `bunx`.
|
|
35
|
+
- `ROADMAP.md` and this changelog.
|
|
28
36
|
- CSRF token on every mutating `/api/*` request.
|
|
29
37
|
- Cross-process file lock around sidecar transactions.
|
|
30
38
|
- `realpath` check on the static-asset route to block symlink escapes.
|
|
@@ -33,5 +41,6 @@ Initial public release.
|
|
|
33
41
|
- Auto-installs missing dependencies on first CLI run.
|
|
34
42
|
- Initial test suite: server, sidecar, parsing, model-picking, rendering, diff, SSE, integration, happy-dom client.
|
|
35
43
|
|
|
36
|
-
[Unreleased]: https://github.com/alevi/redline/compare/v0.
|
|
44
|
+
[Unreleased]: https://github.com/alevi/redline/compare/v0.2.0...HEAD
|
|
45
|
+
[0.2.0]: https://github.com/alevi/redline/releases/tag/v0.2.0
|
|
37
46
|
[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/client/cards.ts
CHANGED
|
@@ -166,9 +166,10 @@ function buildThreadEntry(entry: ThreadEntry, isLatestVerdict: boolean): HTMLDiv
|
|
|
166
166
|
const reason = entry.revision_reason ? escapeHtml(entry.revision_reason) : "edit queued";
|
|
167
167
|
verdictHtml = `<div class="verdict revise"><span class="verdict-icon">\u270E</span><span>${reason}</span></div>`;
|
|
168
168
|
}
|
|
169
|
+
const messageHtml = entry.messageHtml ?? escapeHtml(entry.message);
|
|
169
170
|
div.innerHTML = `
|
|
170
171
|
<div class="thread-role ${role}">${escapeHtml(label)}</div>
|
|
171
|
-
<div class="thread-message">${
|
|
172
|
+
<div class="thread-message">${messageHtml}</div>
|
|
172
173
|
${verdictHtml}
|
|
173
174
|
`;
|
|
174
175
|
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,6 +16,10 @@ 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;
|
|
21
25
|
};
|
|
@@ -110,23 +114,29 @@ export function captureSelection(prose: Element, sel: Selection, text: string):
|
|
|
110
114
|
return { quote: text, context_before: "", context_after: "" };
|
|
111
115
|
}
|
|
112
116
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
// Block boundaries: sel.toString() inserts "\n\n" between blocks (e.g. a
|
|
118
|
+
// heading followed by a paragraph), but the text-node walker concatenates
|
|
119
|
+
// with no separator. Strip those runs from `text` so it can match `flat`.
|
|
120
|
+
// We keep single \n (which can appear inside a single text node, e.g. a
|
|
121
|
+
// code block) untouched.
|
|
122
|
+
const normalized = text.replace(/\n{2,}/g, "");
|
|
123
|
+
|
|
124
|
+
const slice = flat.slice(quoteStart, quoteStart + normalized.length);
|
|
125
|
+
if (slice !== normalized) {
|
|
126
|
+
// Trimming in the caller can shift the true start a few chars away from
|
|
127
|
+
// range.startOffset. Search a small window around quoteStart for the
|
|
128
|
+
// normalized text before giving up.
|
|
119
129
|
const windowStart = Math.max(0, quoteStart - 64);
|
|
120
|
-
const windowEnd = Math.min(flat.length, quoteStart +
|
|
121
|
-
const found = flat.indexOf(
|
|
130
|
+
const windowEnd = Math.min(flat.length, quoteStart + normalized.length + 64);
|
|
131
|
+
const found = flat.indexOf(normalized, windowStart);
|
|
122
132
|
if (found === -1 || found >= windowEnd) return null;
|
|
123
133
|
quoteStart = found;
|
|
124
134
|
}
|
|
125
135
|
|
|
126
136
|
return {
|
|
127
|
-
quote:
|
|
137
|
+
quote: normalized,
|
|
128
138
|
context_before: flat.slice(Math.max(0, quoteStart - 32), quoteStart),
|
|
129
|
-
context_after: flat.slice(quoteStart +
|
|
139
|
+
context_after: flat.slice(quoteStart + normalized.length, quoteStart + normalized.length + 32),
|
|
130
140
|
};
|
|
131
141
|
}
|
|
132
142
|
|
package/src/client/main.ts
CHANGED
|
@@ -16,7 +16,8 @@ import {
|
|
|
16
16
|
import { state } from "./state";
|
|
17
17
|
import { initSelectionHandlers } from "./selection";
|
|
18
18
|
import { initSSE } from "./sse";
|
|
19
|
-
import {
|
|
19
|
+
import { enableDiffMode, initDiffHandlers } from "./diff";
|
|
20
|
+
import { diffStateKey } from "./diffToggle";
|
|
20
21
|
import { observeCardSizes } from "./cards";
|
|
21
22
|
|
|
22
23
|
// Wire card callbacks (breaks circular dependency between cards and render)
|
|
@@ -89,9 +90,14 @@ positionCards();
|
|
|
89
90
|
updateNav();
|
|
90
91
|
applyRoundState();
|
|
91
92
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
const justRevised = sessionStorage.getItem("just-revised");
|
|
94
|
+
if (justRevised) sessionStorage.removeItem("just-revised");
|
|
95
|
+
const diffPersisted = (() => {
|
|
96
|
+
try { return sessionStorage.getItem(diffStateKey(window.__REDLINE__.contextTitle)) === "1"; }
|
|
97
|
+
catch { return false; }
|
|
98
|
+
})();
|
|
99
|
+
if (justRevised || diffPersisted) {
|
|
100
|
+
void enableDiffMode();
|
|
95
101
|
}
|
|
96
102
|
if (sessionStorage.getItem("rl-no-changes")) {
|
|
97
103
|
sessionStorage.removeItem("rl-no-changes");
|
package/src/client/styles.css
CHANGED
|
@@ -58,7 +58,6 @@ body {
|
|
|
58
58
|
font-weight: 500;
|
|
59
59
|
color: var(--text-muted);
|
|
60
60
|
letter-spacing: 0.02em;
|
|
61
|
-
text-transform: uppercase;
|
|
62
61
|
display: flex;
|
|
63
62
|
align-items: center;
|
|
64
63
|
gap: 10px;
|
|
@@ -110,6 +109,16 @@ body {
|
|
|
110
109
|
.round-picker-item:hover { background: var(--thread-bg); }
|
|
111
110
|
.round-picker-item.current { font-weight: 600; pointer-events: none; color: var(--text-muted); }
|
|
112
111
|
.round-picker-meta { font-size: 11px; color: var(--text-muted); margin-left: auto; }
|
|
112
|
+
.round-picker-action {
|
|
113
|
+
width: 100%;
|
|
114
|
+
text-align: left;
|
|
115
|
+
background: none;
|
|
116
|
+
border: none;
|
|
117
|
+
border-top: 1px solid var(--border);
|
|
118
|
+
cursor: pointer;
|
|
119
|
+
font-family: inherit;
|
|
120
|
+
color: var(--accent);
|
|
121
|
+
}
|
|
113
122
|
|
|
114
123
|
.btn-resolve {
|
|
115
124
|
background: var(--accent);
|
|
@@ -141,6 +150,23 @@ body {
|
|
|
141
150
|
.btn-accept:hover:not(:disabled) { border-color: #9ca3af; color: #111827; }
|
|
142
151
|
.btn-accept:disabled { opacity: 0.4; cursor: default; }
|
|
143
152
|
|
|
153
|
+
.btn-toggle-diff {
|
|
154
|
+
background: white;
|
|
155
|
+
color: #374151;
|
|
156
|
+
border: 1.5px solid #d1d5db;
|
|
157
|
+
padding: 6px 12px;
|
|
158
|
+
border-radius: var(--radius);
|
|
159
|
+
font-size: 13px;
|
|
160
|
+
font-weight: 500;
|
|
161
|
+
cursor: pointer;
|
|
162
|
+
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
|
163
|
+
}
|
|
164
|
+
.btn-toggle-diff:hover { border-color: #9ca3af; color: #111827; }
|
|
165
|
+
.btn-toggle-diff.active {
|
|
166
|
+
background: #f3f4f6;
|
|
167
|
+
color: #111827;
|
|
168
|
+
}
|
|
169
|
+
|
|
144
170
|
.header-actions { display: flex; gap: 8px; align-items: center; }
|
|
145
171
|
|
|
146
172
|
/* Asymmetric status: hidden when healthy, visible only when the agent process
|
|
@@ -475,6 +501,57 @@ mark.rl-highlight.rl-img:hover, mark.rl-highlight.rl-img.active {
|
|
|
475
501
|
font-size: 13.5px;
|
|
476
502
|
line-height: 1.5;
|
|
477
503
|
color: var(--text);
|
|
504
|
+
word-wrap: break-word;
|
|
505
|
+
overflow-wrap: anywhere;
|
|
506
|
+
}
|
|
507
|
+
.thread-message > :first-child { margin-top: 0; }
|
|
508
|
+
.thread-message > :last-child { margin-bottom: 0; }
|
|
509
|
+
.thread-message p {
|
|
510
|
+
margin: 0 0 8px;
|
|
511
|
+
}
|
|
512
|
+
.thread-message ul,
|
|
513
|
+
.thread-message ol {
|
|
514
|
+
margin: 4px 0 8px;
|
|
515
|
+
padding-left: 22px;
|
|
516
|
+
}
|
|
517
|
+
.thread-message li { margin: 2px 0; }
|
|
518
|
+
.thread-message li > p { margin: 0; }
|
|
519
|
+
.thread-message code {
|
|
520
|
+
background: rgba(0, 0, 0, 0.05);
|
|
521
|
+
padding: 1px 4px;
|
|
522
|
+
border-radius: 3px;
|
|
523
|
+
font-size: 12.5px;
|
|
524
|
+
}
|
|
525
|
+
.thread-message pre {
|
|
526
|
+
background: rgba(0, 0, 0, 0.05);
|
|
527
|
+
padding: 8px 10px;
|
|
528
|
+
border-radius: 4px;
|
|
529
|
+
overflow-x: auto;
|
|
530
|
+
font-size: 12.5px;
|
|
531
|
+
line-height: 1.45;
|
|
532
|
+
margin: 4px 0 8px;
|
|
533
|
+
}
|
|
534
|
+
.thread-message pre code {
|
|
535
|
+
background: transparent;
|
|
536
|
+
padding: 0;
|
|
537
|
+
}
|
|
538
|
+
.thread-message blockquote {
|
|
539
|
+
margin: 4px 0 8px;
|
|
540
|
+
padding-left: 10px;
|
|
541
|
+
border-left: 3px solid var(--border, #ddd);
|
|
542
|
+
color: var(--text-muted, #666);
|
|
543
|
+
}
|
|
544
|
+
.thread-message h1,
|
|
545
|
+
.thread-message h2,
|
|
546
|
+
.thread-message h3,
|
|
547
|
+
.thread-message h4 {
|
|
548
|
+
font-size: 13.5px;
|
|
549
|
+
font-weight: 600;
|
|
550
|
+
margin: 4px 0;
|
|
551
|
+
}
|
|
552
|
+
.thread-message a {
|
|
553
|
+
color: var(--accent);
|
|
554
|
+
text-decoration: underline;
|
|
478
555
|
}
|
|
479
556
|
|
|
480
557
|
/* ── Verdict footer on agent replies ── */
|
|
@@ -821,22 +898,12 @@ mark.rl-highlight.rl-img:hover, mark.rl-highlight.rl-img.active {
|
|
|
821
898
|
.diff-prose li { margin: 0.3em 0; line-height: 1.7; }
|
|
822
899
|
.diff-block { border-radius: 4px; margin: 2px -12px; padding: 2px 12px; }
|
|
823
900
|
.diff-block-add { background: #e6ffed; border-left: 3px solid #28a745; }
|
|
824
|
-
.diff-block-del { background: #ffeef0; border-left: 3px solid #d73a49; opacity: 0.8; }
|
|
901
|
+
.diff-block-del { background: #ffeef0; border-left: 3px solid #d73a49; opacity: 0.8; text-decoration: line-through; text-decoration-color: #b91c2c; text-decoration-thickness: 1.5px; }
|
|
902
|
+
.diff-block-del a { text-decoration: line-through; }
|
|
825
903
|
.diff-block-mod { background: #fffbe6; border-left: 3px solid #f0ad00; }
|
|
826
904
|
ins.diff-word-add { background: #acf2bd; text-decoration: none; border-radius: 2px; padding: 0 1px; }
|
|
827
905
|
del.diff-word-del { background: #fdb8c0; border-radius: 2px; padding: 0 1px; }
|
|
828
906
|
.diff-no-changes { padding: 24px 0; color: var(--text-muted); }
|
|
829
|
-
.btn-diff-compare {
|
|
830
|
-
font-size: 12px;
|
|
831
|
-
padding: 4px 10px;
|
|
832
|
-
border-radius: var(--radius);
|
|
833
|
-
border: 1px solid var(--border);
|
|
834
|
-
background: white;
|
|
835
|
-
cursor: pointer;
|
|
836
|
-
color: var(--text-muted);
|
|
837
|
-
transition: all 0.1s;
|
|
838
|
-
}
|
|
839
|
-
.btn-diff-compare:hover { color: var(--text); border-color: #aaa; }
|
|
840
907
|
.btn-diff-accept {
|
|
841
908
|
background: #2e7d32;
|
|
842
909
|
color: white;
|
|
@@ -880,84 +947,34 @@ del.diff-word-del { background: #fdb8c0; border-radius: 2px; padding: 0 1px; }
|
|
|
880
947
|
opacity: 0.75;
|
|
881
948
|
}
|
|
882
949
|
|
|
883
|
-
/* ──
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
align-items: center;
|
|
887
|
-
gap: 10px;
|
|
888
|
-
padding: 8px 14px;
|
|
889
|
-
background: #eff6ff;
|
|
890
|
-
border: 1px solid #bfdbfe;
|
|
891
|
-
border-radius: var(--radius);
|
|
892
|
-
margin-bottom: 14px;
|
|
893
|
-
font-size: 13.5px;
|
|
894
|
-
color: #1e40af;
|
|
895
|
-
}
|
|
896
|
-
.revision-banner-text { flex: 1; }
|
|
897
|
-
.revision-banner-link {
|
|
898
|
-
background: none;
|
|
899
|
-
border: none;
|
|
900
|
-
cursor: pointer;
|
|
901
|
-
color: #1d4ed8;
|
|
902
|
-
font-size: 13.5px;
|
|
903
|
-
font-weight: 500;
|
|
904
|
-
padding: 0;
|
|
905
|
-
text-decoration: underline;
|
|
906
|
-
text-underline-offset: 2px;
|
|
907
|
-
}
|
|
908
|
-
.revision-banner-link:hover { color: #1e40af; }
|
|
909
|
-
.revision-banner-dismiss {
|
|
910
|
-
background: none;
|
|
911
|
-
border: none;
|
|
912
|
-
cursor: pointer;
|
|
913
|
-
color: #60a5fa;
|
|
914
|
-
font-size: 13px;
|
|
915
|
-
padding: 1px 4px;
|
|
916
|
-
border-radius: 3px;
|
|
917
|
-
line-height: 1;
|
|
918
|
-
opacity: 0.7;
|
|
919
|
-
flex-shrink: 0;
|
|
920
|
-
}
|
|
921
|
-
.revision-banner-dismiss:hover { opacity: 1; background: rgba(96,165,250,0.15); }
|
|
922
|
-
|
|
923
|
-
/* ── Context banner ── */
|
|
950
|
+
/* ── Context banner ──
|
|
951
|
+
Muted left-accent stripe; the styling itself signals "framing for what
|
|
952
|
+
follows", so we don't need a CONTEXT label or a saturated fill. */
|
|
924
953
|
.context-banner {
|
|
925
954
|
display: flex;
|
|
926
955
|
align-items: flex-start;
|
|
927
956
|
gap: 10px;
|
|
928
|
-
padding:
|
|
929
|
-
|
|
930
|
-
border: 1px solid #fde68a;
|
|
931
|
-
border-radius: var(--radius);
|
|
957
|
+
padding: 8px 12px 8px 16px;
|
|
958
|
+
border-left: 3px solid var(--accent);
|
|
932
959
|
margin-bottom: 14px;
|
|
933
|
-
font-size:
|
|
934
|
-
color:
|
|
935
|
-
line-height: 1.
|
|
936
|
-
}
|
|
937
|
-
.context-label {
|
|
938
|
-
font-weight: 600;
|
|
939
|
-
text-transform: uppercase;
|
|
940
|
-
letter-spacing: 0.05em;
|
|
941
|
-
font-size: 10.5px;
|
|
942
|
-
color: #b45309;
|
|
943
|
-
white-space: nowrap;
|
|
944
|
-
padding-top: 2px;
|
|
945
|
-
flex-shrink: 0;
|
|
960
|
+
font-size: 14px;
|
|
961
|
+
color: var(--text);
|
|
962
|
+
line-height: 1.55;
|
|
946
963
|
}
|
|
947
964
|
.context-text { flex: 1; }
|
|
948
965
|
.context-dismiss {
|
|
949
966
|
background: none;
|
|
950
967
|
border: none;
|
|
951
968
|
cursor: pointer;
|
|
952
|
-
color:
|
|
953
|
-
font-size:
|
|
969
|
+
color: var(--text-muted);
|
|
970
|
+
font-size: 12px;
|
|
954
971
|
padding: 1px 4px;
|
|
955
972
|
border-radius: 3px;
|
|
956
973
|
line-height: 1;
|
|
957
|
-
opacity: 0.
|
|
974
|
+
opacity: 0.4;
|
|
958
975
|
flex-shrink: 0;
|
|
959
976
|
}
|
|
960
|
-
.context-dismiss:hover { opacity:
|
|
977
|
+
.context-dismiss:hover { opacity: 0.9; background: rgba(0,0,0,0.04); }
|
|
961
978
|
|
|
962
979
|
/* ── First-run security banner ── */
|
|
963
980
|
/* Visible only on the first run on a given browser. Dismissal sets
|
|
@@ -967,28 +984,25 @@ del.diff-word-del { background: #fdb8c0; border-radius: 2px; padding: 0 1px; }
|
|
|
967
984
|
.first-run-banner[hidden] { display: none; }
|
|
968
985
|
.first-run-banner {
|
|
969
986
|
display: flex;
|
|
970
|
-
align-items:
|
|
971
|
-
gap:
|
|
972
|
-
padding:
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
margin-bottom: 14px;
|
|
977
|
-
font-size: 13px;
|
|
978
|
-
color: #7f1d1d;
|
|
987
|
+
align-items: baseline;
|
|
988
|
+
gap: 8px;
|
|
989
|
+
padding: 4px 4px 4px 16px;
|
|
990
|
+
margin-bottom: 10px;
|
|
991
|
+
font-size: 11.5px;
|
|
992
|
+
color: var(--text-muted);
|
|
979
993
|
line-height: 1.5;
|
|
994
|
+
opacity: 0.75;
|
|
980
995
|
}
|
|
981
|
-
.first-run-icon { font-size:
|
|
996
|
+
.first-run-icon { font-size: 10px; flex-shrink: 0; opacity: 0.6; }
|
|
982
997
|
.first-run-text { flex: 1; }
|
|
983
998
|
.first-run-dismiss {
|
|
984
|
-
background:
|
|
985
|
-
border:
|
|
986
|
-
color:
|
|
999
|
+
background: none;
|
|
1000
|
+
border: none;
|
|
1001
|
+
color: var(--text-muted);
|
|
987
1002
|
cursor: pointer;
|
|
988
|
-
font-size:
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
border-radius: 4px;
|
|
1003
|
+
font-size: 11.5px;
|
|
1004
|
+
padding: 0 4px;
|
|
1005
|
+
text-decoration: underline;
|
|
992
1006
|
flex-shrink: 0;
|
|
993
1007
|
}
|
|
994
|
-
.first-run-dismiss:hover {
|
|
1008
|
+
.first-run-dismiss:hover { color: var(--text); }
|
package/src/render.ts
CHANGED
|
@@ -57,6 +57,15 @@ export function renderMarkdown(content: string): string {
|
|
|
57
57
|
);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// Render a comment-thread message through the same sanitized markdown
|
|
61
|
+
// pipeline as the document body. Agents reply in markdown (bold, lists,
|
|
62
|
+
// inline code, occasional fenced blocks) and the chat-style rendering in
|
|
63
|
+
// the sidebar previously displayed the raw `**bold**` and `1.` syntax,
|
|
64
|
+
// which looked like the agent had failed to render its own output.
|
|
65
|
+
export function renderMessageMarkdown(content: string): string {
|
|
66
|
+
return renderMarkdown(content);
|
|
67
|
+
}
|
|
68
|
+
|
|
60
69
|
/**
|
|
61
70
|
* Locate where a quoted passage starts inside the flat text of a document.
|
|
62
71
|
*
|
package/src/server-page.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { Comment } from "./sidecar";
|
|
2
|
-
|
|
3
1
|
function escapeHtml(s: string): string {
|
|
4
2
|
return s
|
|
5
3
|
.replace(/&/g, "&")
|
|
@@ -11,7 +9,7 @@ function escapeHtml(s: string): string {
|
|
|
11
9
|
function pageTemplate(
|
|
12
10
|
title: string,
|
|
13
11
|
content: string,
|
|
14
|
-
comments:
|
|
12
|
+
comments: unknown[],
|
|
15
13
|
roundResolved: boolean,
|
|
16
14
|
agentRepliedAt: string | null,
|
|
17
15
|
roundNumber: number,
|
|
@@ -48,21 +46,20 @@ function pageTemplate(
|
|
|
48
46
|
const label = n === totalRounds ? 'Round ' + n + ' — current' : 'Round ' + n;
|
|
49
47
|
return `<a class="round-picker-item${isCurrent ? ' current' : ''}" href="${href}">${label}</a>`;
|
|
50
48
|
}).join('')
|
|
51
|
-
}</div>` : ''}
|
|
49
|
+
}${!readOnly ? `<button class="round-picker-item round-picker-action" id="btn-compare" type="button">Compare with previous →</button>` : ''}</div>` : ''}
|
|
52
50
|
</span>
|
|
53
51
|
</span>
|
|
54
52
|
<div class="header-actions">
|
|
55
53
|
<span id="agent-status" class="agent-status" hidden></span>
|
|
56
54
|
${noAgent ? `<span class="manual-mode-pill" title="Started with --no-agent. No Claude replies, no revision pass.">Manual mode</span>` : ''}
|
|
55
|
+
${!readOnly && totalRounds > 1 ? `<button class="btn-toggle-diff" id="btn-toggle-diff" type="button" aria-pressed="false">Show changes</button>` : ''}
|
|
57
56
|
${readOnly
|
|
58
57
|
? `<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>` : ''}`
|
|
58
|
+
: `<button class="btn-accept" id="btn-accept" disabled>Revise document</button>`
|
|
61
59
|
}
|
|
62
60
|
</div>
|
|
63
61
|
</div>
|
|
64
62
|
${context ? `<div class="context-banner" id="context-banner">
|
|
65
|
-
<span class="context-label">Context</span>
|
|
66
63
|
<span class="context-text">${escapeHtml(context)}</span>
|
|
67
64
|
<button class="context-dismiss" onclick="dismissContextBanner()" aria-label="Dismiss">✕</button>
|
|
68
65
|
</div>` : ''}
|
package/src/server.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { readFile, realpath } from "fs/promises";
|
|
3
3
|
import path from "path";
|
|
4
|
-
import { renderMarkdown } from "./render";
|
|
4
|
+
import { renderMarkdown, renderMessageMarkdown } from "./render";
|
|
5
5
|
import { renderDocDiff } from "./diff";
|
|
6
6
|
import { pageTemplate } from "./server-page";
|
|
7
7
|
import {
|
|
@@ -13,6 +13,22 @@ import {
|
|
|
13
13
|
type Comment,
|
|
14
14
|
} from "./sidecar";
|
|
15
15
|
|
|
16
|
+
// Attach a sanitized HTML rendering of each thread message so the client
|
|
17
|
+
// can show markdown formatting (bold, lists, inline code) instead of the
|
|
18
|
+
// raw source. Done server-side because renderMarkdown depends on marked +
|
|
19
|
+
// sanitize-html which are Node-targeted. The HTML is added as a `messageHtml`
|
|
20
|
+
// field alongside the original `message` so callers that read the source
|
|
21
|
+
// (eg. agent prompt building) are unaffected.
|
|
22
|
+
export function serializeCommentsForClient(comments: Comment[]): unknown[] {
|
|
23
|
+
return comments.map((c) => ({
|
|
24
|
+
...c,
|
|
25
|
+
thread: c.thread.map((e) => ({
|
|
26
|
+
...e,
|
|
27
|
+
messageHtml: renderMessageMarkdown(e.message),
|
|
28
|
+
})),
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
// Bundle the client JS once per server lifetime. The build is ~50-100ms — felt
|
|
17
33
|
// only at server startup, not on page loads (the bundle is cached in memory)
|
|
18
34
|
// and not when the source file is re-read. See M10 for the on-disk cache idea.
|
|
@@ -239,7 +255,7 @@ export function createServer(
|
|
|
239
255
|
const agentRepliedAt = latestRound?.agent_replied_at ?? null;
|
|
240
256
|
const roundNumber = latestRound?.round ?? 1;
|
|
241
257
|
const totalRounds = sidecar.rounds.length;
|
|
242
|
-
return c.html(pageTemplate(fileName, html, comments, roundResolved, agentRepliedAt, roundNumber, totalRounds, sidecar.context, false, csrfToken, opts.noAgent ?? false));
|
|
258
|
+
return c.html(pageTemplate(fileName, html, serializeCommentsForClient(comments), roundResolved, agentRepliedAt, roundNumber, totalRounds, sidecar.context, false, csrfToken, opts.noAgent ?? false));
|
|
243
259
|
});
|
|
244
260
|
|
|
245
261
|
// Add a comment to the active round
|
|
@@ -482,7 +498,7 @@ export function createServer(
|
|
|
482
498
|
const sidecar = await loadSidecar(filePath);
|
|
483
499
|
const latestRound = sidecar.rounds[sidecar.rounds.length - 1] ?? null;
|
|
484
500
|
return c.json({
|
|
485
|
-
comments: latestRound?.comments ?? [],
|
|
501
|
+
comments: serializeCommentsForClient(latestRound?.comments ?? []),
|
|
486
502
|
roundResolved: latestRound?.resolved_at != null,
|
|
487
503
|
totalRounds: sidecar.rounds.length,
|
|
488
504
|
});
|
|
@@ -529,7 +545,7 @@ export function createServer(
|
|
|
529
545
|
return c.html(pageTemplate(
|
|
530
546
|
fileName,
|
|
531
547
|
html,
|
|
532
|
-
roundData.comments,
|
|
548
|
+
serializeCommentsForClient(roundData.comments),
|
|
533
549
|
true, // treat as resolved (read-only)
|
|
534
550
|
roundData.agent_replied_at ?? null,
|
|
535
551
|
n,
|