@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.
@@ -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");
@@ -3,8 +3,9 @@ import {
3
3
  highlightText as _highlightText,
4
4
  preserveScroll as _preserveScroll,
5
5
  latestVerdict,
6
+ isEscalated,
6
7
  } from "./lib";
7
- import { state, apiFetch, showError } from "./state";
8
+ import { state, apiFetch, showError, reportMutationFailure } from "./state";
8
9
  import {
9
10
  buildCommentCard,
10
11
  captureTypingState,
@@ -171,7 +172,7 @@ export async function saveComment(
171
172
  showError(data.error || "Failed to save comment");
172
173
  }
173
174
  } catch (err: unknown) {
174
- showError("Failed to save comment: " + (err as Error).message);
175
+ reportMutationFailure("Failed to save comment", err);
175
176
  }
176
177
  }
177
178
 
@@ -195,7 +196,7 @@ export async function submitReply(id: string, message: string): Promise<void> {
195
196
  showError(data.error || "Failed to save reply");
196
197
  }
197
198
  } catch (err: unknown) {
198
- showError("Failed to save reply: " + (err as Error).message);
199
+ reportMutationFailure("Failed to save reply", err);
199
200
  }
200
201
  }
201
202
 
@@ -220,7 +221,7 @@ export async function resolveComment(id: string): Promise<void> {
220
221
  showError(data.error || "Failed to resolve comment");
221
222
  }
222
223
  } catch (err: unknown) {
223
- showError("Failed to resolve: " + (err as Error).message);
224
+ reportMutationFailure("Failed to resolve", err);
224
225
  }
225
226
  }
226
227
 
@@ -240,7 +241,7 @@ export async function reopenComment(id: string): Promise<void> {
240
241
  showError(data.error || "Failed to reopen comment");
241
242
  }
242
243
  } catch (err: unknown) {
243
- showError("Failed to reopen: " + (err as Error).message);
244
+ reportMutationFailure("Failed to reopen", err);
244
245
  }
245
246
  }
246
247
 
@@ -345,6 +346,17 @@ export function applyRoundState(): void {
345
346
  bannerText = `${reviseCount} of ${total} comments imply edits.`;
346
347
  }
347
348
  banner.textContent = bannerText;
349
+ // Escalations are orthogonal to the revise/accept verdict — a comment
350
+ // can be answered in-thread yet still need the launching agent. Call
351
+ // it out on its own line so it survives all three banner branches.
352
+ const escCount = state.comments.filter(isEscalated).length;
353
+ if (escCount > 0) {
354
+ const escLine = document.createElement("div");
355
+ escLine.className = "banner-escalation";
356
+ escLine.textContent =
357
+ `↑ ${escCount} comment${escCount !== 1 ? "s" : ""} escalated to the launching agent.`;
358
+ banner.appendChild(escLine);
359
+ }
348
360
  banner.style.display = "block";
349
361
  }
350
362
 
@@ -197,7 +197,7 @@ export function initSelectionHandlers(): void {
197
197
 
198
198
  const captured = captureSelection(sel, text);
199
199
  if (!captured) {
200
- showError("Highlight a single passage \u2014 selections that cross images or sections can't be anchored.");
200
+ showError("Couldn't anchor that selection \u2014 try highlighting the passage again.");
201
201
  sel.removeAllRanges();
202
202
  return;
203
203
  }
package/src/client/sse.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { state } from "./state";
1
+ import { state, markSessionEnded } from "./state";
2
2
  import {
3
3
  renderComments,
4
4
  applyHighlights,
@@ -10,6 +10,11 @@ import {
10
10
  let sseHasConnectedOnce = false;
11
11
  let currentEs: EventSource | null = null;
12
12
  let lastEventAt = Date.now();
13
+ // Consecutive failed connection attempts with no successful open in between.
14
+ // A transient blip resolves on the next reconnect (resetting this to 0); a
15
+ // dead server never reconnects, so a sustained run means the server is gone.
16
+ let consecutiveSseErrors = 0;
17
+ const MAX_SSE_ERRORS = 4;
13
18
 
14
19
  export async function softRefresh({ rehighlight = false } = {}): Promise<void> {
15
20
  try {
@@ -53,6 +58,24 @@ export function initSSE(): void {
53
58
  document.addEventListener("visibilitychange", onVisibleOrFocus);
54
59
  window.addEventListener("focus", onVisibleOrFocus);
55
60
 
61
+ // Tell the server explicitly when this tab is going away, so it can
62
+ // distinguish a real close from a bare SSE drop (sleep, network blip) and
63
+ // not abandon a session the user means to keep. `keepalive` lets the POST
64
+ // survive unload; `pagehide` is more reliable than `beforeunload`. Skip the
65
+ // bfcache case (e.persisted) — the page may be restored and reconnect.
66
+ window.addEventListener("pagehide", (e) => {
67
+ if ((e as PageTransitionEvent).persisted) return;
68
+ try {
69
+ fetch("/api/tab-closed", {
70
+ method: "POST",
71
+ keepalive: true,
72
+ headers: { "X-Redline-Token": state.csrfToken },
73
+ });
74
+ } catch {
75
+ /* unload is best-effort */
76
+ }
77
+ });
78
+
56
79
  setInterval(() => {
57
80
  const banner = document.getElementById("sidebar-status-banner");
58
81
  const revising = banner?.classList.contains("revising");
@@ -74,6 +97,7 @@ export function initSSE(): void {
74
97
  });
75
98
  es.onopen = () => {
76
99
  lastEventAt = Date.now();
100
+ consecutiveSseErrors = 0;
77
101
  if (sseHasConnectedOnce) {
78
102
  softRefresh({ rehighlight: true });
79
103
  }
@@ -173,6 +197,14 @@ export function initSSE(): void {
173
197
  es.onerror = () => {
174
198
  es.close();
175
199
  if (currentEs === es) currentEs = null;
200
+ consecutiveSseErrors += 1;
201
+ // A run of failures with no successful open in between means the server
202
+ // is gone for good (it exited, or restarted on a fresh port this tab
203
+ // can't reach). Stop the silent retry loop and tell the user.
204
+ if (consecutiveSseErrors >= MAX_SSE_ERRORS) {
205
+ markSessionEnded();
206
+ return;
207
+ }
176
208
  setTimeout(connectEvents, 3000);
177
209
  };
178
210
  })();
@@ -25,6 +25,7 @@ export const state = {
25
25
  pendingSelection: null as PendingSelection | null,
26
26
  navIdx: 0,
27
27
  deliberateScrollUntil: 0,
28
+ sessionEnded: false,
28
29
  };
29
30
 
30
31
  export type PendingSelection = {
@@ -54,3 +55,24 @@ export function showError(msg: string): void {
54
55
  el.style.display = "block";
55
56
  setTimeout(() => (el.style.display = "none"), 4000);
56
57
  }
58
+
59
+ // The redline server has exited (session ended, or process killed). This tab
60
+ // can no longer do anything useful — show a persistent banner instead of
61
+ // letting actions fail with a cryptic "Failed to fetch". Idempotent.
62
+ export function markSessionEnded(): void {
63
+ if (state.sessionEnded) return;
64
+ state.sessionEnded = true;
65
+ const el = document.getElementById("session-ended-banner");
66
+ if (el) el.style.display = "block";
67
+ }
68
+
69
+ // A fetch network failure (TypeError) on a mutating request means the server
70
+ // is unreachable — treat it as a definitively ended session. A non-network
71
+ // failure (server replied with an error) is shown as a transient toast.
72
+ export function reportMutationFailure(action: string, err: unknown): void {
73
+ if (err instanceof TypeError) {
74
+ markSessionEnded();
75
+ } else {
76
+ showError(action + ": " + (err as Error).message);
77
+ }
78
+ }
@@ -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 ── */
@@ -492,6 +569,7 @@ mark.rl-highlight.rl-img:hover, mark.rl-highlight.rl-img.active {
492
569
  line-height: 1.5;
493
570
  }
494
571
  .verdict.revise { color: #92400e; }
572
+ .verdict.escalate { color: #5b21b6; }
495
573
 
496
574
  /* Warm-tinted resolve button when the latest verdict implies an edit */
497
575
  .btn-resolve-comment.revise {
@@ -519,6 +597,22 @@ mark.rl-highlight.rl-img:hover, mark.rl-highlight.rl-img.active {
519
597
  .verdict-badge.revise { background: #fef3c7; color: #92400e; }
520
598
  .verdict-badge.accept { background: #e5e7eb; color: var(--text-muted); }
521
599
 
600
+ /* Escalation badge on the quote line — comment routed to the launching agent */
601
+ .escalate-badge {
602
+ display: inline-flex;
603
+ align-items: center;
604
+ margin-left: 6px;
605
+ padding: 1px 6px;
606
+ font-size: 10.5px;
607
+ font-weight: 600;
608
+ text-transform: uppercase;
609
+ letter-spacing: 0.04em;
610
+ border-radius: 3px;
611
+ font-style: normal;
612
+ background: #ede9fe;
613
+ color: #5b21b6;
614
+ }
615
+
522
616
  /* Round-level secondary action (under the primary banner button) */
523
617
  .round-secondary {
524
618
  margin-top: 8px;
@@ -709,6 +803,16 @@ mark.rl-highlight.rl-img:hover, mark.rl-highlight.rl-img.active {
709
803
  align-items: center;
710
804
  gap: 8px;
711
805
  }
806
+ /* Escalation sub-line under the round banner — purple to match the
807
+ ↑ Escalated comment badges, distinct from the green verdict copy. */
808
+ .banner-escalation {
809
+ margin-top: 6px;
810
+ padding-top: 6px;
811
+ border-top: 1px solid #a5d6a7;
812
+ color: #5b21b6;
813
+ font-size: 12.5px;
814
+ font-weight: 600;
815
+ }
712
816
  #sidebar-status-banner.revising {
713
817
  background: #fff3e0;
714
818
  border-bottom-color: #ffb74d;
@@ -821,22 +925,12 @@ mark.rl-highlight.rl-img:hover, mark.rl-highlight.rl-img.active {
821
925
  .diff-prose li { margin: 0.3em 0; line-height: 1.7; }
822
926
  .diff-block { border-radius: 4px; margin: 2px -12px; padding: 2px 12px; }
823
927
  .diff-block-add { background: #e6ffed; border-left: 3px solid #28a745; }
824
- .diff-block-del { background: #ffeef0; border-left: 3px solid #d73a49; opacity: 0.8; }
928
+ .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; }
929
+ .diff-block-del a { text-decoration: line-through; }
825
930
  .diff-block-mod { background: #fffbe6; border-left: 3px solid #f0ad00; }
826
931
  ins.diff-word-add { background: #acf2bd; text-decoration: none; border-radius: 2px; padding: 0 1px; }
827
932
  del.diff-word-del { background: #fdb8c0; border-radius: 2px; padding: 0 1px; }
828
933
  .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
934
  .btn-diff-accept {
841
935
  background: #2e7d32;
842
936
  color: white;
@@ -880,84 +974,34 @@ del.diff-word-del { background: #fdb8c0; border-radius: 2px; padding: 0 1px; }
880
974
  opacity: 0.75;
881
975
  }
882
976
 
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 ── */
977
+ /* ── Context banner ──
978
+ Muted left-accent stripe; the styling itself signals "framing for what
979
+ follows", so we don't need a CONTEXT label or a saturated fill. */
924
980
  .context-banner {
925
981
  display: flex;
926
982
  align-items: flex-start;
927
983
  gap: 10px;
928
- padding: 9px 14px;
929
- background: #fffbeb;
930
- border: 1px solid #fde68a;
931
- border-radius: var(--radius);
984
+ padding: 8px 12px 8px 16px;
985
+ border-left: 3px solid var(--accent);
932
986
  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;
987
+ font-size: 14px;
988
+ color: var(--text);
989
+ line-height: 1.55;
946
990
  }
947
991
  .context-text { flex: 1; }
948
992
  .context-dismiss {
949
993
  background: none;
950
994
  border: none;
951
995
  cursor: pointer;
952
- color: #b45309;
953
- font-size: 13px;
996
+ color: var(--text-muted);
997
+ font-size: 12px;
954
998
  padding: 1px 4px;
955
999
  border-radius: 3px;
956
1000
  line-height: 1;
957
- opacity: 0.6;
1001
+ opacity: 0.4;
958
1002
  flex-shrink: 0;
959
1003
  }
960
- .context-dismiss:hover { opacity: 1; background: rgba(180,83,9,0.1); }
1004
+ .context-dismiss:hover { opacity: 0.9; background: rgba(0,0,0,0.04); }
961
1005
 
962
1006
  /* ── First-run security banner ── */
963
1007
  /* Visible only on the first run on a given browser. Dismissal sets
@@ -967,28 +1011,25 @@ del.diff-word-del { background: #fdb8c0; border-radius: 2px; padding: 0 1px; }
967
1011
  .first-run-banner[hidden] { display: none; }
968
1012
  .first-run-banner {
969
1013
  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;
1014
+ align-items: baseline;
1015
+ gap: 8px;
1016
+ padding: 4px 4px 4px 16px;
1017
+ margin-bottom: 10px;
1018
+ font-size: 11.5px;
1019
+ color: var(--text-muted);
979
1020
  line-height: 1.5;
1021
+ opacity: 0.75;
980
1022
  }
981
- .first-run-icon { font-size: 14px; flex-shrink: 0; }
1023
+ .first-run-icon { font-size: 10px; flex-shrink: 0; opacity: 0.6; }
982
1024
  .first-run-text { flex: 1; }
983
1025
  .first-run-dismiss {
984
- background: white;
985
- border: 1px solid #fca5a5;
986
- color: #7f1d1d;
1026
+ background: none;
1027
+ border: none;
1028
+ color: var(--text-muted);
987
1029
  cursor: pointer;
988
- font-size: 12px;
989
- font-weight: 500;
990
- padding: 3px 10px;
991
- border-radius: 4px;
1030
+ font-size: 11.5px;
1031
+ padding: 0 4px;
1032
+ text-decoration: underline;
992
1033
  flex-shrink: 0;
993
1034
  }
994
- .first-run-dismiss:hover { background: #fee2e2; }
1035
+ .first-run-dismiss:hover { color: var(--text); }
package/src/parseReply.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  // text into the UI. The delimiter form needs no escaping:
8
8
  //
9
9
  // REQUIRES_REVISION: <true|false>
10
+ // ESCALATE: <true|false> (optional — absent means false)
10
11
  // REASON: <one short sentence, or empty>
11
12
  // ---MESSAGE---
12
13
  // <free-form prose, may contain anything>
@@ -25,6 +26,10 @@ export interface ParsedReply {
25
26
  message: string;
26
27
  requires_revision: boolean;
27
28
  reason: string;
29
+ // True when the agent flagged the comment for the launching ("outer") agent
30
+ // — something it couldn't act on from inside the review. Optional in the
31
+ // envelope; absent means false.
32
+ escalate: boolean;
28
33
  }
29
34
 
30
35
  function tryDelimiterEnvelope(s: string): ParsedReply | null {
@@ -37,11 +42,13 @@ function tryDelimiterEnvelope(s: string): ParsedReply | null {
37
42
 
38
43
  const reqMatch = header.match(/REQUIRES_REVISION\s*:\s*(true|false)\b/i);
39
44
  const reasonMatch = header.match(/REASON\s*:\s*(.*?)\s*(?:\n|$)/i);
45
+ const escMatch = header.match(/ESCALATE\s*:\s*(true|false)\b/i);
40
46
 
41
47
  return {
42
48
  message,
43
49
  requires_revision: reqMatch ? reqMatch[1].toLowerCase() === "true" : true,
44
50
  reason: reasonMatch ? reasonMatch[1].trim() : "",
51
+ escalate: escMatch ? escMatch[1].toLowerCase() === "true" : false,
45
52
  };
46
53
  }
47
54
 
@@ -93,6 +100,7 @@ export function parseReply(raw: string): ParsedReply {
93
100
  message: obj.message.trim(),
94
101
  requires_revision: obj.requires_revision !== false, // default true if missing/non-bool
95
102
  reason: typeof obj.reason === "string" ? obj.reason.trim() : "",
103
+ escalate: obj.escalate === true,
96
104
  };
97
105
  }
98
106
  } catch { /* fall through */ }
@@ -111,5 +119,5 @@ export function parseReply(raw: string): ParsedReply {
111
119
  if (obj) return obj;
112
120
  }
113
121
 
114
- return { message: trimmed, requires_revision: true, reason: "" };
122
+ return { message: trimmed, requires_revision: true, reason: "", escalate: false };
115
123
  }
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
  *