@levistudio/redline 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,385 @@
1
+ import { escapeHtml, latestVerdict, type ClientComment, type ThreadEntry } from "./lib";
2
+ import { state } from "./state";
3
+
4
+ export type CardCallbacks = {
5
+ focusComment: (id: string) => void;
6
+ updateNav: () => void;
7
+ positionCards: () => void;
8
+ resolveComment: (id: string) => void;
9
+ reopenComment: (id: string) => void;
10
+ toggleReplyForm: (id: string) => void;
11
+ submitReply: (id: string, message: string) => void;
12
+ };
13
+
14
+ let _callbacks: CardCallbacks | null = null;
15
+
16
+ export function setCardCallbacks(cb: CardCallbacks): void {
17
+ _callbacks = cb;
18
+ }
19
+
20
+ function cb(): CardCallbacks {
21
+ return _callbacks!;
22
+ }
23
+
24
+ export function buildCommentCard(comment: ClientComment): HTMLDivElement {
25
+ const card = document.createElement("div");
26
+ card.className = "comment-card" + (comment.resolved ? " resolved" : "");
27
+ card.id = "card-" + comment.id;
28
+
29
+ const quote = document.createElement("div");
30
+ quote.className = "comment-quote";
31
+ if (comment.resolved) {
32
+ const badge = document.createElement("span");
33
+ badge.className = "resolved-badge";
34
+ badge.textContent = "\u2713 Resolved";
35
+ quote.appendChild(badge);
36
+ const verdict = latestVerdict(comment);
37
+ if (verdict) {
38
+ const vbadge = document.createElement("span");
39
+ vbadge.className = "verdict-badge " + verdict;
40
+ vbadge.textContent = verdict === "revise" ? "\u270E Edit queued" : "\u2713 Answered";
41
+ quote.appendChild(vbadge);
42
+ }
43
+ }
44
+ quote.appendChild(document.createTextNode('"' + comment.quote + '"'));
45
+ card.appendChild(quote);
46
+
47
+ if (comment.resolved) {
48
+ const lastAgentMsg = [...comment.thread].reverse().find((e) => e.role === "agent");
49
+ if (lastAgentMsg) {
50
+ const full = lastAgentMsg.message;
51
+ const sentenceEnd = full.search(/[.!?](\s|$)/);
52
+ let summary = sentenceEnd !== -1 ? full.slice(0, sentenceEnd + 1) : full;
53
+ if (summary.length > 140) summary = summary.slice(0, 137).trimEnd() + "\u2026";
54
+ const commitment = document.createElement("div");
55
+ commitment.className = "card-commitment";
56
+ commitment.textContent = summary;
57
+ card.appendChild(commitment);
58
+ }
59
+ }
60
+
61
+ const thread = document.createElement("div");
62
+ thread.className = "comment-thread";
63
+ thread.id = "thread-" + comment.id;
64
+
65
+ let latestVerdictIdx = -1;
66
+ for (let i = comment.thread.length - 1; i >= 0; i--) {
67
+ const e = comment.thread[i]!;
68
+ if (e.role === "agent" && typeof e.requires_revision === "boolean") {
69
+ latestVerdictIdx = i;
70
+ break;
71
+ }
72
+ }
73
+ comment.thread.forEach((entry, i) => {
74
+ thread.appendChild(buildThreadEntry(entry, i === latestVerdictIdx));
75
+ });
76
+ if (state.thinkingCommentIds.has(comment.id)) {
77
+ const indicator = document.createElement("div");
78
+ indicator.className = "thread-entry thinking-indicator";
79
+ indicator.innerHTML =
80
+ '<div class="thread-role agent">Agent</div><div class="thread-message thinking-dots"><span></span><span></span><span></span></div>';
81
+ thread.appendChild(indicator);
82
+ }
83
+
84
+ const body = document.createElement("div");
85
+ body.className = "comment-body";
86
+ body.appendChild(thread);
87
+
88
+ const actions = document.createElement("div");
89
+ actions.className = "comment-actions";
90
+
91
+ if (!comment.resolved && !state.roundResolved) {
92
+ const replyBtn = document.createElement("button");
93
+ replyBtn.className = "btn-reply";
94
+ replyBtn.textContent = "Reply";
95
+ replyBtn.addEventListener("click", () => cb().toggleReplyForm(comment.id));
96
+ actions.appendChild(replyBtn);
97
+
98
+ const resolveBtn = document.createElement("button");
99
+ const verdict = latestVerdict(comment);
100
+ resolveBtn.className = "btn-resolve-comment" + (verdict === "revise" ? " revise" : "");
101
+ resolveBtn.textContent = verdict === "revise" ? "Resolve \u2192 queue edit" : "Resolve";
102
+ resolveBtn.addEventListener("click", () => cb().resolveComment(comment.id));
103
+ actions.appendChild(resolveBtn);
104
+ }
105
+
106
+ if (comment.resolved && !state.roundResolved) {
107
+ const reopenBtn = document.createElement("button");
108
+ reopenBtn.className = "btn-reopen";
109
+ reopenBtn.textContent = "Reopen";
110
+ reopenBtn.addEventListener("click", () => cb().reopenComment(comment.id));
111
+ actions.appendChild(reopenBtn);
112
+ }
113
+
114
+ body.appendChild(actions);
115
+
116
+ const replyForm = document.createElement("div");
117
+ replyForm.className = "reply-form";
118
+ replyForm.id = "reply-" + comment.id;
119
+ replyForm.innerHTML = `
120
+ <textarea class="reply-input" placeholder="Reply\u2026"></textarea>
121
+ <button class="reply-submit">Send <kbd>\u2318\u21B5</kbd></button>
122
+ `;
123
+ const sendReply = () => {
124
+ const ta = replyForm.querySelector(".reply-input") as HTMLTextAreaElement;
125
+ const message = ta.value.trim();
126
+ if (!message) return;
127
+ ta.value = "";
128
+ cb().submitReply(comment.id, message);
129
+ };
130
+ replyForm.querySelector(".reply-submit")!.addEventListener("click", sendReply);
131
+ (replyForm.querySelector(".reply-input") as HTMLTextAreaElement).addEventListener(
132
+ "keydown",
133
+ (e: KeyboardEvent) => {
134
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) sendReply();
135
+ },
136
+ );
137
+ body.appendChild(replyForm);
138
+
139
+ card.appendChild(body);
140
+
141
+ if (comment.resolved) {
142
+ quote.addEventListener("click", (e) => {
143
+ e.stopPropagation();
144
+ card.classList.toggle("expanded");
145
+ cb().positionCards();
146
+ });
147
+ }
148
+
149
+ card.addEventListener("click", (e) => {
150
+ if ((e.target as HTMLElement).tagName === "BUTTON" || (e.target as HTMLElement).tagName === "TEXTAREA") return;
151
+ if (comment.resolved) return;
152
+ cb().focusComment(comment.id);
153
+ cb().updateNav();
154
+ });
155
+
156
+ return card;
157
+ }
158
+
159
+ function buildThreadEntry(entry: ThreadEntry, isLatestVerdict: boolean): HTMLDivElement {
160
+ const role = entry.role ?? "agent";
161
+ const label = entry.name ?? (role === "agent" ? "Agent" : "Human");
162
+ const div = document.createElement("div");
163
+ div.className = "thread-entry";
164
+ let verdictHtml = "";
165
+ if (role === "agent" && entry.requires_revision === true && isLatestVerdict) {
166
+ const reason = entry.revision_reason ? escapeHtml(entry.revision_reason) : "edit queued";
167
+ verdictHtml = `<div class="verdict revise"><span class="verdict-icon">\u270E</span><span>${reason}</span></div>`;
168
+ }
169
+ div.innerHTML = `
170
+ <div class="thread-role ${role}">${escapeHtml(label)}</div>
171
+ <div class="thread-message">${escapeHtml(entry.message)}</div>
172
+ ${verdictHtml}
173
+ `;
174
+ return div;
175
+ }
176
+
177
+ export interface TypingState {
178
+ focused: {
179
+ kind: "new" | "reply";
180
+ commentId?: string;
181
+ value: string;
182
+ selectionStart: number;
183
+ selectionEnd: number;
184
+ } | null;
185
+ drafts: {
186
+ commentId: string;
187
+ value: string;
188
+ selectionStart: number;
189
+ selectionEnd: number;
190
+ }[];
191
+ }
192
+
193
+ export function captureTypingState(): TypingState {
194
+ const typingState: TypingState = { focused: null, drafts: [] };
195
+
196
+ const active = document.activeElement as HTMLTextAreaElement | null;
197
+ if (active && active.tagName === "TEXTAREA") {
198
+ const newForm = active.closest("#new-comment-form");
199
+ const replyForm = active.closest(".reply-form");
200
+ const card = active.closest(".comment-card");
201
+ if (newForm) {
202
+ typingState.focused = {
203
+ kind: "new",
204
+ value: active.value,
205
+ selectionStart: active.selectionStart,
206
+ selectionEnd: active.selectionEnd,
207
+ };
208
+ } else if (replyForm && card && card.id) {
209
+ typingState.focused = {
210
+ kind: "reply",
211
+ commentId: card.id.replace("card-", ""),
212
+ value: active.value,
213
+ selectionStart: active.selectionStart,
214
+ selectionEnd: active.selectionEnd,
215
+ };
216
+ }
217
+ }
218
+
219
+ document.querySelectorAll(".reply-form.open").forEach((form) => {
220
+ const ta = form.querySelector(".reply-input") as HTMLTextAreaElement | null;
221
+ const card = form.closest(".comment-card");
222
+ if (!ta || !card || !card.id) return;
223
+ if (!ta.value) return;
224
+ typingState.drafts.push({
225
+ commentId: card.id.replace("card-", ""),
226
+ value: ta.value,
227
+ selectionStart: ta.selectionStart,
228
+ selectionEnd: ta.selectionEnd,
229
+ });
230
+ });
231
+
232
+ return typingState;
233
+ }
234
+
235
+ export function restoreTypingState(typingState: TypingState): void {
236
+ typingState.drafts.forEach((d) => {
237
+ const card = document.getElementById("card-" + d.commentId);
238
+ if (!card) return;
239
+ const form = card.querySelector(".reply-form");
240
+ if (!form) return;
241
+ const ta = form.querySelector(".reply-input") as HTMLTextAreaElement | null;
242
+ if (!ta) return;
243
+ form.classList.add("open");
244
+ ta.value = d.value;
245
+ });
246
+
247
+ const f = typingState.focused;
248
+ if (!f) return;
249
+ let target: HTMLTextAreaElement | null = null;
250
+ if (f.kind === "new") {
251
+ target = document.querySelector("#new-comment-form textarea");
252
+ } else if (f.kind === "reply") {
253
+ const card = document.getElementById("card-" + f.commentId);
254
+ if (card) {
255
+ const form = card.querySelector(".reply-form");
256
+ if (form) {
257
+ form.classList.add("open");
258
+ target = form.querySelector(".reply-input");
259
+ }
260
+ }
261
+ }
262
+ if (!target) return;
263
+ target.value = f.value;
264
+ try {
265
+ target.focus({ preventScroll: true });
266
+ } catch {
267
+ target.focus();
268
+ }
269
+ try {
270
+ target.setSelectionRange(f.selectionStart, f.selectionEnd);
271
+ } catch {}
272
+ }
273
+
274
+ export function toggleReplyForm(id: string): void {
275
+ const form = document.getElementById("reply-" + id);
276
+ const card = document.getElementById("card-" + id);
277
+ if (!form) return;
278
+ form.classList.toggle("open");
279
+ if (form.classList.contains("open")) {
280
+ (form.querySelector(".reply-input") as HTMLTextAreaElement)?.focus();
281
+ if (card) card.style.zIndex = "1";
282
+ } else {
283
+ if (card) card.style.zIndex = "";
284
+ }
285
+ cb().positionCards();
286
+ }
287
+
288
+ let _positionRafId = 0;
289
+ export function schedulePositionCards(): void {
290
+ if (_positionRafId) return;
291
+ _positionRafId = requestAnimationFrame(() => {
292
+ _positionRafId = 0;
293
+ cb().positionCards();
294
+ });
295
+ }
296
+
297
+ const _cardResizeObserver =
298
+ typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => schedulePositionCards()) : null;
299
+
300
+ export function observeCardSizes(): void {
301
+ if (!_cardResizeObserver) return;
302
+ _cardResizeObserver.disconnect();
303
+ document.querySelectorAll(".comment-card, #new-comment-form").forEach((el) => {
304
+ _cardResizeObserver.observe(el);
305
+ });
306
+ }
307
+
308
+ export function positionCards(): void {
309
+ const sidebarCol = document.querySelector(".sidebar-col");
310
+ if (!sidebarCol) return;
311
+ const sidebarRect = sidebarCol.getBoundingClientRect();
312
+
313
+ const items: { el: HTMLElement; ideal: number; active: boolean }[] = [];
314
+ let fallbackTop = 0;
315
+ state.comments.forEach((comment) => {
316
+ const card = document.getElementById("card-" + comment.id);
317
+ if (!card) return;
318
+ const mark = document.querySelector('[data-comment-id="' + comment.id + '"]');
319
+ let ideal: number;
320
+ if (mark) {
321
+ const markRect = mark.getBoundingClientRect();
322
+ ideal = Math.max(0, markRect.top - sidebarRect.top);
323
+ fallbackTop = Math.max(fallbackTop, ideal + card.offsetHeight + 14);
324
+ } else {
325
+ ideal = fallbackTop;
326
+ fallbackTop += card.offsetHeight + 14;
327
+ }
328
+ items.push({
329
+ el: card,
330
+ ideal,
331
+ active: card.classList.contains("active"),
332
+ });
333
+ });
334
+
335
+ const form = document.getElementById("new-comment-form");
336
+ if (form) {
337
+ const pendingMark = document.querySelector('[data-comment-id="pending"]');
338
+ let ideal: number;
339
+ if (pendingMark) {
340
+ ideal = Math.max(0, pendingMark.getBoundingClientRect().top - sidebarRect.top);
341
+ } else if (state.pendingSelection?._rectTop != null) {
342
+ ideal = Math.max(0, state.pendingSelection._rectTop - sidebarRect.top);
343
+ } else {
344
+ ideal = parseFloat(form.style.top) || 0;
345
+ }
346
+ items.forEach((item) => {
347
+ item.active = false;
348
+ });
349
+ items.push({ el: form, ideal, active: true });
350
+ }
351
+
352
+ if (items.length === 0) return;
353
+ items.sort((a, b) => a.ideal - b.ideal);
354
+
355
+ const activeIdx = items.findIndex((item) => item.active);
356
+
357
+ if (activeIdx === -1) {
358
+ let minTop = 0;
359
+ items.forEach(({ el, ideal }) => {
360
+ const top = Math.max(ideal, minTop);
361
+ el.style.top = top + "px";
362
+ minTop = top + el.offsetHeight + 14;
363
+ });
364
+ return;
365
+ }
366
+
367
+ const active = items[activeIdx]!;
368
+ active.el.style.top = active.ideal + "px";
369
+
370
+ let ceiling = active.ideal - 14;
371
+ for (let i = activeIdx - 1; i >= 0; i--) {
372
+ const { el, ideal } = items[i]!;
373
+ const top = Math.max(0, Math.min(ideal, ceiling - el.offsetHeight));
374
+ el.style.top = top + "px";
375
+ ceiling = top - 14;
376
+ }
377
+
378
+ let floor = active.ideal + active.el.offsetHeight + 14;
379
+ for (let i = activeIdx + 1; i < items.length; i++) {
380
+ const { el, ideal } = items[i]!;
381
+ const top = Math.max(ideal, floor);
382
+ el.style.top = top + "px";
383
+ floor = top + el.offsetHeight + 14;
384
+ }
385
+ }
@@ -0,0 +1,100 @@
1
+ import { apiFetch } from "./state";
2
+ import { state } from "./state";
3
+ import {
4
+ applyRoundState,
5
+ triggerRoundAction,
6
+ } from "./render";
7
+
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
+ }
25
+
26
+ async function showDiffOverlay(): Promise<void> {
27
+ const res = await fetch("/api/diff");
28
+ const data = await res.json();
29
+ if (!data.ok) return;
30
+ document.getElementById("diff-panel-body")!.innerHTML = data.html;
31
+ document.getElementById("diff-overlay")!.classList.add("open");
32
+ }
33
+
34
+ export function initDiffHandlers(): void {
35
+ document.getElementById("btn-compare")?.addEventListener("click", () => showDiffOverlay());
36
+
37
+ document.getElementById("diff-btn-accept")!.addEventListener("click", async () => {
38
+ document.getElementById("diff-overlay")!.classList.remove("open");
39
+ await apiFetch("/api/finish", { method: "POST" });
40
+ const btnAccept = document.getElementById("btn-accept") as HTMLButtonElement | null;
41
+ if (btnAccept) {
42
+ btnAccept.disabled = true;
43
+ btnAccept.textContent = "\u2713 Done";
44
+ }
45
+ const banner = document.getElementById("sidebar-status-banner");
46
+ if (banner) {
47
+ banner.classList.remove("revising");
48
+ banner.textContent = "Review complete. Document is ready.";
49
+ banner.style.display = "block";
50
+ }
51
+ });
52
+
53
+ document.getElementById("diff-btn-feedback")!.addEventListener("click", () => {
54
+ document.getElementById("diff-overlay")!.classList.remove("open");
55
+ });
56
+
57
+ document.getElementById("diff-btn-close")?.addEventListener("click", () => {
58
+ document.getElementById("diff-overlay")!.classList.remove("open");
59
+ });
60
+
61
+ // Round picker
62
+ const roundBadge = document.getElementById("round-badge");
63
+ const roundPicker = document.getElementById("round-picker");
64
+ if (roundBadge && roundPicker) {
65
+ roundBadge.addEventListener("click", (e) => {
66
+ e.stopPropagation();
67
+ roundPicker.style.display = roundPicker.style.display === "none" ? "block" : "none";
68
+ });
69
+ document.addEventListener("click", () => {
70
+ roundPicker.style.display = "none";
71
+ });
72
+ }
73
+
74
+ // Context banner
75
+ function dismissContextBanner(): void {
76
+ const banner = document.getElementById("context-banner");
77
+ if (banner) banner.remove();
78
+ try {
79
+ localStorage.setItem("rl-ctx-dismissed-" + window.__REDLINE__.contextTitle, "1");
80
+ } catch {}
81
+ }
82
+ // Expose globally for the inline onclick in the HTML template
83
+ window.dismissContextBanner = dismissContextBanner;
84
+
85
+ (function () {
86
+ const banner = document.getElementById("context-banner");
87
+ if (!banner) return;
88
+ try {
89
+ if (localStorage.getItem("rl-ctx-dismissed-" + window.__REDLINE__.contextTitle))
90
+ banner.remove();
91
+ } catch {}
92
+ })();
93
+
94
+ // Accept button
95
+ document.getElementById("btn-accept")?.addEventListener("click", () => {
96
+ const btnAccept = document.getElementById("btn-accept") as HTMLButtonElement | null;
97
+ if (btnAccept?.disabled) return;
98
+ triggerRoundAction(btnAccept!.dataset.mode!);
99
+ });
100
+ }
@@ -0,0 +1,26 @@
1
+ // First-run security banner toggle.
2
+ //
3
+ // The banner DOM is rendered hidden by server-page.ts on every non-readonly
4
+ // page load; this module decides whether to reveal it based on a localStorage
5
+ // ack key. Dismissal persists per-machine, not per-doc, so the warning
6
+ // doesn't follow the user across every project they review.
7
+ //
8
+ // Takes `document` and `window` injected so happy-dom tests can exercise
9
+ // the full flow without touching globals at module-load time.
10
+
11
+ const ACK_KEY = "redline-security-ack";
12
+
13
+ export function initFirstRunBanner(doc: Document, win: Window): void {
14
+ const banner = doc.getElementById("first-run-banner");
15
+ if (!banner) return;
16
+
17
+ let ack: string | null = null;
18
+ try { ack = win.localStorage.getItem(ACK_KEY); } catch { /* private browsing */ }
19
+ if (ack) return;
20
+
21
+ banner.removeAttribute("hidden");
22
+ doc.getElementById("first-run-dismiss")?.addEventListener("click", () => {
23
+ try { win.localStorage.setItem(ACK_KEY, new Date().toISOString()); } catch { /* private browsing */ }
24
+ banner.setAttribute("hidden", "");
25
+ });
26
+ }