@levistudio/redline 0.2.0 → 0.4.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/AGENTS.md +227 -0
- package/CHANGELOG.md +22 -1
- package/CLAUDE.md +9 -0
- package/README.md +39 -21
- package/SECURITY.md +3 -3
- package/bin/redline.cjs +4 -1
- package/package.json +4 -1
- package/scripts/install-skill.sh +104 -39
- package/skills/redline-review/SKILL.md +9 -9
- package/src/agent.ts +19 -21
- package/src/agentProvider.ts +267 -0
- package/src/cli.ts +109 -36
- package/src/client/cards.ts +10 -1
- package/src/client/lib.ts +143 -78
- package/src/client/render.ts +17 -5
- package/src/client/selection.ts +1 -1
- package/src/client/sse.ts +34 -2
- package/src/client/state.ts +22 -0
- package/src/client/styles.css +27 -0
- package/src/parseReply.ts +9 -1
- package/src/pickModel.ts +6 -4
- package/src/promptEnvelope.ts +1 -1
- package/src/resolve.ts +134 -97
- package/src/reviewSummary.ts +93 -0
- package/src/server-page.ts +5 -3
- package/src/server.ts +50 -16
- package/src/sidecar.ts +5 -0
package/src/cli.ts
CHANGED
|
@@ -4,6 +4,10 @@ import { mkdir, writeFile } from "fs/promises";
|
|
|
4
4
|
import { spawnSync } from "child_process";
|
|
5
5
|
import { createRequire } from "module";
|
|
6
6
|
import path from "path";
|
|
7
|
+
// reviewSummary only `import type`s from ./sidecar, so it pulls in no
|
|
8
|
+
// third-party deps at runtime — safe to import statically ahead of the
|
|
9
|
+
// preflight. abandon() needs it synchronously (signal context).
|
|
10
|
+
import { collectEscalations, formatReviewSummary } from "./reviewSummary";
|
|
7
11
|
|
|
8
12
|
// Ensure redline's own dependencies are resolvable before any third-party
|
|
9
13
|
// imports load. `redline` is invoked from arbitrary projects: in a checkout
|
|
@@ -41,24 +45,25 @@ preflightDependencies();
|
|
|
41
45
|
// Dynamic imports so preflight runs before module resolution pulls in third-party deps.
|
|
42
46
|
const { createServer } = await import("./server");
|
|
43
47
|
const { resolve } = await import("./resolve");
|
|
48
|
+
const {
|
|
49
|
+
getAgentProvider,
|
|
50
|
+
invalidProviderMessage,
|
|
51
|
+
parseAgentProviderId,
|
|
52
|
+
resolveProviderId,
|
|
53
|
+
} = await import("./agentProvider");
|
|
44
54
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"\n[redline] Could not find the `claude` CLI on PATH.\n" +
|
|
58
|
-
"Redline shells out to Claude Code for agent replies and revisions.\n" +
|
|
59
|
-
"Install it from https://claude.com/claude-code and re-run.\n"
|
|
60
|
-
);
|
|
61
|
-
process.exit(1);
|
|
55
|
+
function argValue(args: string[], flag: string): string | undefined {
|
|
56
|
+
const idx = args.indexOf(flag);
|
|
57
|
+
return idx !== -1 ? args[idx + 1] : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function selectProvider(args: string[]) {
|
|
61
|
+
const raw = argValue(args, "--agent") ?? process.env.REDLINE_AGENT;
|
|
62
|
+
if (raw && !parseAgentProviderId(raw)) {
|
|
63
|
+
console.error(invalidProviderMessage(raw));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
return getAgentProvider(resolveProviderId(raw));
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
// Walk up from `start` looking for a git root (a `.git` directory or file —
|
|
@@ -96,11 +101,33 @@ function maybePrintGitignoreHint(filePath: string) {
|
|
|
96
101
|
|
|
97
102
|
const args = process.argv.slice(2);
|
|
98
103
|
|
|
104
|
+
if (args[0] === "--version" || args[0] === "-v") {
|
|
105
|
+
const pkgPath = path.resolve(import.meta.dir, "..", "package.json");
|
|
106
|
+
try {
|
|
107
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
|
|
108
|
+
console.log(pkg.version ?? "unknown");
|
|
109
|
+
} catch {
|
|
110
|
+
console.log("unknown");
|
|
111
|
+
}
|
|
112
|
+
process.exit(0);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// redline install-skill [--agent claude|codex|both]
|
|
116
|
+
if (args[0] === "install-skill") {
|
|
117
|
+
const script = path.resolve(import.meta.dir, "..", "scripts", "install-skill.sh");
|
|
118
|
+
if (!existsSync(script)) {
|
|
119
|
+
console.error(`Install script not found: ${script}`);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
const result = spawnSync("bash", [script, ...args.slice(1)], { stdio: "inherit" });
|
|
123
|
+
process.exit(result.status ?? 1);
|
|
124
|
+
}
|
|
125
|
+
|
|
99
126
|
// redline resolve <file> [--model <id>]
|
|
100
127
|
if (args[0] === "resolve") {
|
|
101
128
|
const filePath = args[1];
|
|
102
129
|
if (!filePath) {
|
|
103
|
-
console.error("Usage: redline resolve <file.md> [--model <model-id>]");
|
|
130
|
+
console.error("Usage: redline resolve <file.md> [--model <model-id>] [--agent claude|codex]");
|
|
104
131
|
process.exit(1);
|
|
105
132
|
}
|
|
106
133
|
const resolved = path.resolve(filePath);
|
|
@@ -108,15 +135,20 @@ if (args[0] === "resolve") {
|
|
|
108
135
|
console.error(`File not found: ${resolved}`);
|
|
109
136
|
process.exit(1);
|
|
110
137
|
}
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
138
|
+
const model = argValue(args, "--model");
|
|
139
|
+
const provider = selectProvider(args);
|
|
140
|
+
try {
|
|
141
|
+
provider.preflight();
|
|
142
|
+
} catch (e) {
|
|
143
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
resolve(resolved, { model, agentProvider: provider.id });
|
|
115
147
|
} else {
|
|
116
148
|
// redline <file> — open review reader
|
|
117
149
|
const filePath = args[0];
|
|
118
150
|
if (!filePath) {
|
|
119
|
-
console.error("Usage: redline <file.md>");
|
|
151
|
+
console.error("Usage: redline <file.md>\n redline resolve <file.md> [--model <model-id>] [--agent claude|codex]\n redline install-skill [--agent claude|codex|both]");
|
|
120
152
|
process.exit(1);
|
|
121
153
|
}
|
|
122
154
|
const resolved = path.resolve(filePath);
|
|
@@ -125,18 +157,26 @@ if (args[0] === "resolve") {
|
|
|
125
157
|
process.exit(1);
|
|
126
158
|
}
|
|
127
159
|
const noAgent = args.includes("--no-agent");
|
|
160
|
+
const provider = selectProvider(args);
|
|
128
161
|
|
|
129
162
|
// Manual annotation mode skips both the preflight and the agent spawn —
|
|
130
|
-
// the user just wants inline comments without
|
|
131
|
-
// requiring
|
|
132
|
-
if (!noAgent)
|
|
163
|
+
// the user just wants inline comments without an agent conversation, so
|
|
164
|
+
// requiring a provider CLI on PATH would be a hostile gate.
|
|
165
|
+
if (!noAgent) {
|
|
166
|
+
try {
|
|
167
|
+
provider.preflight();
|
|
168
|
+
} catch (e) {
|
|
169
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
133
173
|
|
|
134
|
-
const
|
|
135
|
-
const context = contextFlag !== -1 ? args[contextFlag + 1] : undefined;
|
|
174
|
+
const context = argValue(args, "--context");
|
|
136
175
|
const autoOpen = args.includes("--open");
|
|
137
176
|
|
|
138
177
|
const resultFile = path.join(path.dirname(resolved), ".review", path.basename(resolved) + ".result");
|
|
139
178
|
const startupFile = path.join(path.dirname(resolved), ".review", path.basename(resolved) + ".startup.json");
|
|
179
|
+
const sidecarFile = path.join(path.dirname(resolved), ".review", path.basename(resolved) + ".json");
|
|
140
180
|
|
|
141
181
|
// Clear stale state from a prior run so a polling agent can't be misled
|
|
142
182
|
// by a leftover .result or .startup.json file that predates this process.
|
|
@@ -159,7 +199,7 @@ if (args[0] === "resolve") {
|
|
|
159
199
|
// caller can pin the token if it needs to.
|
|
160
200
|
const csrfToken = process.env.REDLINE_TOKEN ?? crypto.randomUUID();
|
|
161
201
|
|
|
162
|
-
const app = createServer(resolved, { context, csrfToken, noAgent });
|
|
202
|
+
const app = createServer(resolved, { context, csrfToken, noAgent, agentName: provider.displayName });
|
|
163
203
|
const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: app.fetch, idleTimeout: 0 });
|
|
164
204
|
const url = `http://localhost:${server.port}`;
|
|
165
205
|
|
|
@@ -177,6 +217,7 @@ if (args[0] === "resolve") {
|
|
|
177
217
|
started_at: new Date().toISOString(),
|
|
178
218
|
pid: process.pid,
|
|
179
219
|
csrf_token: csrfToken,
|
|
220
|
+
agent_provider: provider.id,
|
|
180
221
|
}, null, 2));
|
|
181
222
|
} catch (e) {
|
|
182
223
|
console.error("[redline] Failed to write startup file:", e);
|
|
@@ -189,14 +230,14 @@ if (args[0] === "resolve") {
|
|
|
189
230
|
console.log(` URL: ${url}`);
|
|
190
231
|
console.log(` Result: ${resultFile}`);
|
|
191
232
|
console.log(`${bar}`);
|
|
192
|
-
if (noAgent) console.log(` Mode: manual annotation (--no-agent — no
|
|
233
|
+
if (noAgent) console.log(` Mode: manual annotation (--no-agent — no ${provider.displayName} replies, no revision pass)`);
|
|
193
234
|
if (!autoOpen) console.log(`\n → cmd-click the URL when you're ready to review\n`);
|
|
194
235
|
else console.log("");
|
|
195
236
|
|
|
196
237
|
maybePrintGitignoreHint(resolved);
|
|
197
238
|
|
|
198
239
|
// Auto-restart the agent if it dies unexpectedly (harness reaping, OOM,
|
|
199
|
-
// a transient
|
|
240
|
+
// a transient provider-CLI auth blip, etc). Capped to MAX_RESTARTS within
|
|
200
241
|
// RESTART_WINDOW_MS so a permanently-broken environment doesn't loop forever.
|
|
201
242
|
const RESTART_WINDOW_MS = 60_000;
|
|
202
243
|
// Cap is overrideable via env so integration tests can exercise the
|
|
@@ -211,7 +252,7 @@ if (args[0] === "resolve") {
|
|
|
211
252
|
[process.execPath, "run", path.join(import.meta.dir, "agent.ts"), resolved],
|
|
212
253
|
{
|
|
213
254
|
stdout: "inherit", stderr: "inherit", stdin: "ignore",
|
|
214
|
-
env: { ...process.env, REDLINE_PORT: String(server.port), REDLINE_TOKEN: csrfToken },
|
|
255
|
+
env: { ...process.env, REDLINE_PORT: String(server.port), REDLINE_TOKEN: csrfToken, REDLINE_AGENT: provider.id },
|
|
215
256
|
}
|
|
216
257
|
);
|
|
217
258
|
agentProc = proc;
|
|
@@ -274,19 +315,36 @@ if (args[0] === "resolve") {
|
|
|
274
315
|
const status = lastRevisionError ? "error" : "abandoned";
|
|
275
316
|
const payload: Record<string, unknown> = { status, file: resolved };
|
|
276
317
|
if (lastRevisionError) payload.reason = lastRevisionError;
|
|
318
|
+
// Carry escalations through the error/abandon path too — on an incomplete
|
|
319
|
+
// session they matter more, not less. Read the sidecar synchronously:
|
|
320
|
+
// abandon runs in signal context where async I/O may not complete.
|
|
321
|
+
let escalations: import("./reviewSummary").EscalationItem[] = [];
|
|
322
|
+
try {
|
|
323
|
+
if (existsSync(sidecarFile)) {
|
|
324
|
+
const raw = readFileSync(sidecarFile, "utf-8");
|
|
325
|
+
if (raw.trim()) escalations = collectEscalations(JSON.parse(raw));
|
|
326
|
+
}
|
|
327
|
+
} catch { /* best effort — never block shutdown on the summary */ }
|
|
328
|
+
payload.escalations = escalations;
|
|
277
329
|
// Synchronous write so the result file lands even if the runtime is
|
|
278
330
|
// terminating due to a signal (async I/O may not complete in that case).
|
|
279
331
|
try {
|
|
280
332
|
mkdirSync(path.dirname(resultFile), { recursive: true });
|
|
281
333
|
writeFileSync(resultFile, JSON.stringify(payload, null, 2));
|
|
282
334
|
} catch { /* best effort */ }
|
|
283
|
-
|
|
335
|
+
if (escalations.length) {
|
|
336
|
+
console.log(
|
|
337
|
+
`\n⚠ ${escalations.length} comment${escalations.length !== 1 ? "s" : ""} escalated to the launching agent — see ${path.basename(resultFile)}`
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
const escSuffix = escalations.length ? ` escalations=${escalations.length}` : "";
|
|
341
|
+
console.log(`\nREDLINE_RESULT: ${status}${lastRevisionError ? ` reason="${lastRevisionError}"` : ""}${escSuffix}`);
|
|
284
342
|
// Exit 3 = revision error; 2 = abandoned. Both still distinguish from 0 (approved).
|
|
285
343
|
process.exit(lastRevisionError ? 3 : 2);
|
|
286
344
|
};
|
|
287
345
|
|
|
288
346
|
// Happy-path finish: human clicked Done.
|
|
289
|
-
app.onFinished(({ totalRounds, totalComments }) => {
|
|
347
|
+
app.onFinished(async ({ totalRounds, totalComments }) => {
|
|
290
348
|
serverExiting = true;
|
|
291
349
|
killAgent().catch(() => { /* shutdown already in flight */ });
|
|
292
350
|
try { unlinkSync(startupFile); } catch { /* best effort */ }
|
|
@@ -296,10 +354,25 @@ if (args[0] === "resolve") {
|
|
|
296
354
|
console.log(` ${totalRounds} round${totalRounds !== 1 ? "s" : ""} · ${totalComments} comment${totalComments !== 1 ? "s" : ""} addressed`);
|
|
297
355
|
console.log(` Revised document: ${resolved}`);
|
|
298
356
|
console.log(`${line}`);
|
|
357
|
+
|
|
358
|
+
// Print the full comment threads so the launching agent — which has no
|
|
359
|
+
// live channel to the inline review agent — sees everything the reviewer
|
|
360
|
+
// said, including escalated feedback meant for it.
|
|
361
|
+
let escalations: import("./reviewSummary").EscalationItem[] = [];
|
|
362
|
+
try {
|
|
363
|
+
const { loadSidecar } = await import("./sidecar");
|
|
364
|
+
const sidecar = await loadSidecar(resolved);
|
|
365
|
+
escalations = collectEscalations(sidecar);
|
|
366
|
+
console.log(`\n${formatReviewSummary(sidecar)}`);
|
|
367
|
+
} catch (e) {
|
|
368
|
+
console.error("[redline] Failed to build review summary:", e);
|
|
369
|
+
}
|
|
370
|
+
|
|
299
371
|
// Machine-greppable result line for a calling agent. Keep this stable.
|
|
300
|
-
|
|
372
|
+
const escSuffix = escalations.length ? ` escalations=${escalations.length}` : "";
|
|
373
|
+
console.log(`\nREDLINE_RESULT: approved file=${resolved} rounds=${totalRounds} comments=${totalComments}${escSuffix}`);
|
|
301
374
|
console.log("");
|
|
302
|
-
writeResult({ status: "approved", file: resolved, rounds: totalRounds, comments: totalComments })
|
|
375
|
+
writeResult({ status: "approved", file: resolved, rounds: totalRounds, comments: totalComments, escalations })
|
|
303
376
|
.finally(() => process.exit(0));
|
|
304
377
|
});
|
|
305
378
|
|
package/src/client/cards.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { escapeHtml, latestVerdict, type ClientComment, type ThreadEntry } from "./lib";
|
|
1
|
+
import { escapeHtml, isEscalated, latestVerdict, type ClientComment, type ThreadEntry } from "./lib";
|
|
2
2
|
import { state } from "./state";
|
|
3
3
|
|
|
4
4
|
export type CardCallbacks = {
|
|
@@ -41,6 +41,12 @@ export function buildCommentCard(comment: ClientComment): HTMLDivElement {
|
|
|
41
41
|
quote.appendChild(vbadge);
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
if (isEscalated(comment)) {
|
|
45
|
+
const ebadge = document.createElement("span");
|
|
46
|
+
ebadge.className = "escalate-badge";
|
|
47
|
+
ebadge.textContent = "↑ Escalated";
|
|
48
|
+
quote.appendChild(ebadge);
|
|
49
|
+
}
|
|
44
50
|
quote.appendChild(document.createTextNode('"' + comment.quote + '"'));
|
|
45
51
|
card.appendChild(quote);
|
|
46
52
|
|
|
@@ -166,6 +172,9 @@ function buildThreadEntry(entry: ThreadEntry, isLatestVerdict: boolean): HTMLDiv
|
|
|
166
172
|
const reason = entry.revision_reason ? escapeHtml(entry.revision_reason) : "edit queued";
|
|
167
173
|
verdictHtml = `<div class="verdict revise"><span class="verdict-icon">\u270E</span><span>${reason}</span></div>`;
|
|
168
174
|
}
|
|
175
|
+
if (role === "agent" && entry.escalate === true) {
|
|
176
|
+
verdictHtml += `<div class="verdict escalate"><span class="verdict-icon">\u2191</span><span>Routed to the launching agent</span></div>`;
|
|
177
|
+
}
|
|
169
178
|
const messageHtml = entry.messageHtml ?? escapeHtml(entry.message);
|
|
170
179
|
div.innerHTML = `
|
|
171
180
|
<div class="thread-role ${role}">${escapeHtml(label)}</div>
|
package/src/client/lib.ts
CHANGED
|
@@ -22,6 +22,7 @@ export type ThreadEntry = {
|
|
|
22
22
|
messageHtml?: string;
|
|
23
23
|
requires_revision?: boolean;
|
|
24
24
|
revision_reason?: string;
|
|
25
|
+
escalate?: boolean;
|
|
25
26
|
};
|
|
26
27
|
|
|
27
28
|
export function escapeHtml(s: string): string {
|
|
@@ -44,6 +45,11 @@ export function latestVerdict(comment: ClientComment): "revise" | "accept" | nul
|
|
|
44
45
|
return null;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
// True when an agent reply flagged this comment for the launching agent.
|
|
49
|
+
export function isEscalated(comment: ClientComment): boolean {
|
|
50
|
+
return (comment.thread || []).some((e) => e.role === "agent" && e.escalate === true);
|
|
51
|
+
}
|
|
52
|
+
|
|
47
53
|
export function nearestCell(node: Node): HTMLElement | null {
|
|
48
54
|
const el = node.nodeType === Node.TEXT_NODE ? node.parentNode : node;
|
|
49
55
|
return el && (el as Element).closest ? ((el as Element).closest("td, th") as HTMLElement | null) : null;
|
|
@@ -86,63 +92,138 @@ export type Captured = {
|
|
|
86
92
|
context_after: string;
|
|
87
93
|
};
|
|
88
94
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
95
|
+
type FlatSegment = {
|
|
96
|
+
node: Text | HTMLImageElement;
|
|
97
|
+
start: number;
|
|
98
|
+
len: number;
|
|
99
|
+
isImg: boolean;
|
|
100
|
+
};
|
|
94
101
|
|
|
95
|
-
|
|
96
|
-
|
|
102
|
+
// An <img> contributes no characters of its own, so on its own it can't be
|
|
103
|
+
// anchored against. We give it a presence in the flat text as an
|
|
104
|
+
// `[image: alt]` token — the same shape the image-only comment path uses — so
|
|
105
|
+
// a selection can run text → image → text and still round-trip.
|
|
106
|
+
function imgToken(img: HTMLImageElement): string {
|
|
107
|
+
return "[image: " + (img.alt || "") + "]";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Walk a container into a flat string plus the segments that produced it.
|
|
111
|
+
// Text nodes contribute their value; <img> elements contribute an
|
|
112
|
+
// `[image: alt]` token. Segments are in document order. captureSelection and
|
|
113
|
+
// highlightText both build flat through here so their coordinates agree.
|
|
114
|
+
function buildFlat(container: Element): { flat: string; segments: FlatSegment[] } {
|
|
115
|
+
const doc = container.ownerDocument || document;
|
|
116
|
+
const walker = doc.createTreeWalker(container, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
|
|
117
|
+
acceptNode(n: Node) {
|
|
118
|
+
if (n.nodeType === Node.TEXT_NODE) return NodeFilter.FILTER_ACCEPT;
|
|
119
|
+
return (n as Element).tagName === "IMG" ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
const segments: FlatSegment[] = [];
|
|
97
123
|
let flat = "";
|
|
98
124
|
let node: Node | null;
|
|
99
125
|
while ((node = walker.nextNode())) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
break;
|
|
126
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
127
|
+
const v = (node as Text).nodeValue ?? "";
|
|
128
|
+
segments.push({ node: node as Text, start: flat.length, len: v.length, isImg: false });
|
|
129
|
+
flat += v;
|
|
130
|
+
} else {
|
|
131
|
+
const img = node as HTMLImageElement;
|
|
132
|
+
const tok = imgToken(img);
|
|
133
|
+
segments.push({ node: img, start: flat.length, len: tok.length, isImg: true });
|
|
134
|
+
flat += tok;
|
|
110
135
|
}
|
|
111
136
|
}
|
|
137
|
+
return { flat, segments };
|
|
138
|
+
}
|
|
112
139
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
140
|
+
// Map both range endpoints onto the flat text and return the quote with
|
|
141
|
+
// surrounding context. Returns null only when the selection can't be resolved
|
|
142
|
+
// against the flat text at all.
|
|
143
|
+
//
|
|
144
|
+
// The quote is sliced straight out of `flat` rather than reconstructed from
|
|
145
|
+
// `sel.toString()`. sel.toString() joins blocks with "\n"/"\n\n" separators
|
|
146
|
+
// that don't exist in the walker's output, and marked emits stray whitespace
|
|
147
|
+
// text nodes between block tags — so the two never line up across a block
|
|
148
|
+
// boundary. Working in flat coordinates sidesteps the mismatch: `flat` is the
|
|
149
|
+
// single source of truth, and highlightText re-finds the quote against the
|
|
150
|
+
// same flat string later. `text` is kept only as a last-resort fallback for
|
|
151
|
+
// the rare case where an endpoint can't be resolved.
|
|
152
|
+
export function captureSelection(prose: Element, sel: Selection, text: string): Captured | null {
|
|
153
|
+
const range = sel.getRangeAt(0);
|
|
154
|
+
const { flat, segments } = buildFlat(prose);
|
|
155
|
+
if (segments.length === 0) return null;
|
|
116
156
|
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
157
|
+
// Map a range boundary point onto an index into `flat`. Text-node boundaries
|
|
158
|
+
// are the common case; Chrome sometimes anchors a drag to an element node
|
|
159
|
+
// plus a child index, which we resolve to the nearest segment edge.
|
|
160
|
+
const pointToFlat = (container: Node, offset: number, side: "start" | "end"): number => {
|
|
161
|
+
for (const seg of segments) {
|
|
162
|
+
if (seg.node === container) {
|
|
163
|
+
if (seg.isImg) return offset > 0 ? seg.start + seg.len : seg.start;
|
|
164
|
+
return seg.start + offset;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (container.nodeType === Node.TEXT_NODE) return -1;
|
|
168
|
+
const kids = container.childNodes;
|
|
169
|
+
const segEnd = (seg: FlatSegment) => seg.start + seg.len;
|
|
170
|
+
if (side === "start") {
|
|
171
|
+
const ref = offset < kids.length ? kids[offset]! : null;
|
|
172
|
+
if (ref) {
|
|
173
|
+
for (const seg of segments) {
|
|
174
|
+
if (ref === seg.node || ref.contains(seg.node)) return seg.start;
|
|
175
|
+
if (ref.compareDocumentPosition(seg.node) & Node.DOCUMENT_POSITION_FOLLOWING)
|
|
176
|
+
return seg.start;
|
|
177
|
+
}
|
|
178
|
+
return flat.length;
|
|
179
|
+
}
|
|
180
|
+
let end = -1;
|
|
181
|
+
for (const seg of segments) if (container.contains(seg.node)) end = segEnd(seg);
|
|
182
|
+
return end === -1 ? flat.length : end;
|
|
183
|
+
}
|
|
184
|
+
const ref = offset > 0 ? kids[offset - 1]! : null;
|
|
185
|
+
if (ref) {
|
|
186
|
+
let end = -1;
|
|
187
|
+
for (const seg of segments) {
|
|
188
|
+
if (
|
|
189
|
+
ref === seg.node ||
|
|
190
|
+
ref.contains(seg.node) ||
|
|
191
|
+
ref.compareDocumentPosition(seg.node) & Node.DOCUMENT_POSITION_PRECEDING
|
|
192
|
+
) {
|
|
193
|
+
end = segEnd(seg);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return end === -1 ? 0 : end;
|
|
197
|
+
}
|
|
198
|
+
for (const seg of segments) if (container.contains(seg.node)) return seg.start;
|
|
199
|
+
return 0;
|
|
200
|
+
};
|
|
123
201
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
// normalized text before giving up.
|
|
129
|
-
const windowStart = Math.max(0, quoteStart - 64);
|
|
130
|
-
const windowEnd = Math.min(flat.length, quoteStart + normalized.length + 64);
|
|
131
|
-
const found = flat.indexOf(normalized, windowStart);
|
|
132
|
-
if (found === -1 || found >= windowEnd) return null;
|
|
133
|
-
quoteStart = found;
|
|
202
|
+
let flatStart = pointToFlat(range.startContainer, range.startOffset, "start");
|
|
203
|
+
let flatEnd = pointToFlat(range.endContainer, range.endOffset, "end");
|
|
204
|
+
if (flatStart === -1 || flatEnd === -1 || flatEnd <= flatStart) {
|
|
205
|
+
return text ? { quote: text, context_before: "", context_after: "" } : null;
|
|
134
206
|
}
|
|
135
207
|
|
|
208
|
+
// Trim whitespace overshoot: a drag that ends a hair past a block — or
|
|
209
|
+
// starts in the gap before one — shouldn't fail or carry stray newlines.
|
|
210
|
+
// This is the "clamp": a small overshoot anchors to what the user meant.
|
|
211
|
+
while (flatStart < flatEnd && /\s/.test(flat[flatStart]!)) flatStart++;
|
|
212
|
+
while (flatEnd > flatStart && /\s/.test(flat[flatEnd - 1]!)) flatEnd--;
|
|
213
|
+
if (flatEnd <= flatStart) return null;
|
|
214
|
+
|
|
136
215
|
return {
|
|
137
|
-
quote:
|
|
138
|
-
context_before: flat.slice(Math.max(0,
|
|
139
|
-
context_after: flat.slice(
|
|
216
|
+
quote: flat.slice(flatStart, flatEnd),
|
|
217
|
+
context_before: flat.slice(Math.max(0, flatStart - 32), flatStart),
|
|
218
|
+
context_after: flat.slice(flatEnd, flatEnd + 32),
|
|
140
219
|
};
|
|
141
220
|
}
|
|
142
221
|
|
|
143
222
|
// Wrap occurrences of `text` inside `container` with <mark> elements. Uses
|
|
144
|
-
// `contextBefore` to disambiguate when a quote appears multiple times.
|
|
145
|
-
//
|
|
223
|
+
// `contextBefore` to disambiguate when a quote appears multiple times. The
|
|
224
|
+
// quote is matched against the same flat text captureSelection produced, so
|
|
225
|
+
// `[image: alt]` tokens in the quote wrap the corresponding <img> — whether
|
|
226
|
+
// the quote is image-only or text mixed with an image.
|
|
146
227
|
// Returns the marks created (caller can attach event listeners).
|
|
147
228
|
export function highlightText(
|
|
148
229
|
container: Element,
|
|
@@ -154,35 +235,9 @@ export function highlightText(
|
|
|
154
235
|
const doc = container.ownerDocument || document;
|
|
155
236
|
const marks: HTMLElement[] = [];
|
|
156
237
|
|
|
157
|
-
const imgMatch = text.match(/^\[image:\s*(.*)\]$/);
|
|
158
|
-
if (imgMatch) {
|
|
159
|
-
const alt = imgMatch[1];
|
|
160
|
-
const imgs = container.querySelectorAll("img");
|
|
161
|
-
for (const img of imgs) {
|
|
162
|
-
if ((img.alt || "") === alt) {
|
|
163
|
-
const mark = doc.createElement("mark");
|
|
164
|
-
mark.className = "rl-highlight rl-img" + (resolved ? " resolved" : "");
|
|
165
|
-
mark.dataset.commentId = id;
|
|
166
|
-
img.parentNode!.insertBefore(mark, img);
|
|
167
|
-
mark.appendChild(img);
|
|
168
|
-
marks.push(mark);
|
|
169
|
-
return marks;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
return marks;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
238
|
(container as HTMLElement).normalize();
|
|
176
239
|
|
|
177
|
-
const
|
|
178
|
-
const segments: { node: Text; start: number }[] = [];
|
|
179
|
-
let flat = "";
|
|
180
|
-
let node: Node | null;
|
|
181
|
-
while ((node = walker.nextNode())) {
|
|
182
|
-
const tn = node as Text;
|
|
183
|
-
segments.push({ node: tn, start: flat.length });
|
|
184
|
-
flat += tn.nodeValue ?? "";
|
|
185
|
-
}
|
|
240
|
+
const { flat, segments } = buildFlat(container);
|
|
186
241
|
|
|
187
242
|
let quoteStart = -1;
|
|
188
243
|
if (contextBefore) {
|
|
@@ -194,18 +249,28 @@ export function highlightText(
|
|
|
194
249
|
|
|
195
250
|
const quoteEnd = quoteStart + text.length;
|
|
196
251
|
|
|
197
|
-
const toWrap: { node: Text; localStart: number; localEnd: number }[] = [];
|
|
198
252
|
for (const seg of segments) {
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
253
|
+
const segStart = seg.start;
|
|
254
|
+
const segEnd = seg.start + seg.len;
|
|
255
|
+
if (segEnd <= quoteStart || segStart >= quoteEnd) continue;
|
|
256
|
+
|
|
257
|
+
if (seg.isImg) {
|
|
258
|
+
// Image tokens are atomic — wrap the <img> only when the quote covers
|
|
259
|
+
// the whole token, never on a partial overlap.
|
|
260
|
+
if (segStart < quoteStart || segEnd > quoteEnd) continue;
|
|
261
|
+
const img = seg.node as HTMLImageElement;
|
|
262
|
+
const mark = doc.createElement("mark");
|
|
263
|
+
mark.className = "rl-highlight rl-img" + (resolved ? " resolved" : "");
|
|
264
|
+
mark.dataset.commentId = id;
|
|
265
|
+
img.parentNode!.insertBefore(mark, img);
|
|
266
|
+
mark.appendChild(img);
|
|
267
|
+
marks.push(mark);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
207
270
|
|
|
208
|
-
|
|
271
|
+
const tn = seg.node as Text;
|
|
272
|
+
const localStart = Math.max(0, quoteStart - segStart);
|
|
273
|
+
const localEnd = Math.min(seg.len, quoteEnd - segStart);
|
|
209
274
|
const mark = doc.createElement("mark");
|
|
210
275
|
mark.className = "rl-highlight" + (resolved ? " resolved" : "");
|
|
211
276
|
mark.dataset.commentId = id;
|
package/src/client/render.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/client/selection.ts
CHANGED
|
@@ -197,7 +197,7 @@ export function initSelectionHandlers(): void {
|
|
|
197
197
|
|
|
198
198
|
const captured = captureSelection(sel, text);
|
|
199
199
|
if (!captured) {
|
|
200
|
-
showError("
|
|
200
|
+
showError("Couldn't anchor that selection \u2014 try highlighting the passage again.");
|
|
201
201
|
sel.removeAllRanges();
|
|
202
202
|
return;
|
|
203
203
|
}
|