@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 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
- - Node-compatible launcher (`bin/redline.cjs`) so `npx @levistudio/redline <file>` works alongside `bunx`.
9
- - `ROADMAP.md` and this changelog.
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
- - Package renamed to scoped `@levistudio/redline` for npm publishing.
13
- - README rewritten around the AI-doc-review use case.
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
- ## [0.1.0] - 2026-05-09
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
- Initial public release.
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.1.0...HEAD
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 browser overlays a side-by-side diff against the previous round so you can read the agent's changes in context. From there you either open another round of comments or click **Looks good** to finish.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levistudio/redline",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Inline comments on Markdown files, for human-in-the-loop AI doc review.",
5
5
  "keywords": [
6
6
  "markdown",
@@ -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">${escapeHtml(entry.message)}</div>
172
+ <div class="thread-message">${messageHtml}</div>
172
173
  ${verdictHtml}
173
174
  `;
174
175
  return div;
@@ -1,37 +1,72 @@
1
1
  import { apiFetch } from "./state";
2
- import { state } from "./state";
3
2
  import {
4
- applyRoundState,
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
- export function showRevisionBanner(): void {
9
- if (document.getElementById("revision-banner")) return;
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 showDiffOverlay(): Promise<void> {
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
- document.getElementById("diff-panel-body")!.innerHTML = data.html;
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 = "\u2713 Done";
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
- const slice = flat.slice(quoteStart, quoteStart + text.length);
114
- if (slice !== text) {
115
- // Trimming in the caller, or block-boundary newlines that sel.toString()
116
- // inserts but our flat-text walker doesn't, can shift the true start a
117
- // few chars away from range.startOffset. Search a small window around
118
- // quoteStart for the text before giving up.
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 + text.length + 64);
121
- const found = flat.indexOf(text, windowStart);
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: text,
137
+ quote: normalized,
128
138
  context_before: flat.slice(Math.max(0, quoteStart - 32), quoteStart),
129
- context_after: flat.slice(quoteStart + text.length, quoteStart + text.length + 32),
139
+ context_after: flat.slice(quoteStart + normalized.length, quoteStart + normalized.length + 32),
130
140
  };
131
141
  }
132
142
 
@@ -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 { showRevisionBanner, initDiffHandlers } from "./diff";
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
- if (sessionStorage.getItem("just-revised")) {
93
- sessionStorage.removeItem("just-revised");
94
- showRevisionBanner();
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");
@@ -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
- /* ── Revision banner ── */
884
- .revision-banner {
885
- display: flex;
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: 9px 14px;
929
- background: #fffbeb;
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: 13.5px;
934
- color: #78350f;
935
- line-height: 1.5;
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: #b45309;
953
- font-size: 13px;
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.6;
974
+ opacity: 0.4;
958
975
  flex-shrink: 0;
959
976
  }
960
- .context-dismiss:hover { opacity: 1; background: rgba(180,83,9,0.1); }
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: center;
971
- gap: 10px;
972
- padding: 8px 14px;
973
- background: #fef2f2;
974
- border: 1px solid #fecaca;
975
- border-radius: var(--radius);
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: 14px; flex-shrink: 0; }
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: white;
985
- border: 1px solid #fca5a5;
986
- color: #7f1d1d;
999
+ background: none;
1000
+ border: none;
1001
+ color: var(--text-muted);
987
1002
  cursor: pointer;
988
- font-size: 12px;
989
- font-weight: 500;
990
- padding: 3px 10px;
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 { background: #fee2e2; }
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
  *
@@ -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, "&amp;")
@@ -11,7 +9,7 @@ function escapeHtml(s: string): string {
11
9
  function pageTemplate(
12
10
  title: string,
13
11
  content: string,
14
- comments: Comment[],
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,