thurview 0.1.4

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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/bin/thurview.js +8 -0
  4. package/dist/cli.js +1101 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/diff.js +65 -0
  7. package/dist/diff.js.map +1 -0
  8. package/dist/document/compile.js +418 -0
  9. package/dist/document/compile.js.map +1 -0
  10. package/dist/document/parse.js +139 -0
  11. package/dist/document/parse.js.map +1 -0
  12. package/dist/document/schema.js +129 -0
  13. package/dist/document/schema.js.map +1 -0
  14. package/dist/flags.js +66 -0
  15. package/dist/flags.js.map +1 -0
  16. package/dist/git.js +206 -0
  17. package/dist/git.js.map +1 -0
  18. package/dist/highlight-theme.js +93 -0
  19. package/dist/highlight-theme.js.map +1 -0
  20. package/dist/highlight.js +163 -0
  21. package/dist/highlight.js.map +1 -0
  22. package/dist/main.js +3 -0
  23. package/dist/main.js.map +1 -0
  24. package/dist/server/server.js +387 -0
  25. package/dist/server/server.js.map +1 -0
  26. package/dist/store.js +100 -0
  27. package/dist/store.js.map +1 -0
  28. package/dist/symbols.js +192 -0
  29. package/dist/symbols.js.map +1 -0
  30. package/dist/theme.js +301 -0
  31. package/dist/theme.js.map +1 -0
  32. package/dist/threads.js +87 -0
  33. package/dist/threads.js.map +1 -0
  34. package/dist/ui/app.css +1415 -0
  35. package/dist/ui/app.js +2303 -0
  36. package/dist/ui/app.js.map +7 -0
  37. package/dist/ui/assets/fonts/inter-400.woff2 +0 -0
  38. package/dist/ui/assets/fonts/jetbrains-mono-400.woff2 +0 -0
  39. package/dist/ui/assets/fonts/press-start-2p-400.woff2 +0 -0
  40. package/dist/ui/assets/inter-400.woff2 +0 -0
  41. package/dist/ui/assets/jetbrains-mono-400.woff2 +0 -0
  42. package/dist/ui/assets/press-start-2p-400.woff2 +0 -0
  43. package/dist/ui/index.html +13 -0
  44. package/dist/version.js +6 -0
  45. package/dist/version.js.map +1 -0
  46. package/package.json +70 -0
  47. package/skills/thurview/SKILL.md +219 -0
  48. package/skills/thurview/references/components.md +120 -0
  49. package/skills/thurview/references/document-authoring.md +107 -0
  50. package/skills/thurview/references/lifecycle.md +88 -0
  51. package/skills/thurview/references/software-map.md +42 -0
  52. package/skills/thurview/references/theme.md +90 -0
package/dist/ui/app.js ADDED
@@ -0,0 +1,2303 @@
1
+ "use strict";
2
+ (() => {
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __esm = (fn, res, err) => function __init() {
6
+ if (err) throw err[0];
7
+ try {
8
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
9
+ } catch (e) {
10
+ throw err = [e], e;
11
+ }
12
+ };
13
+ var __export = (target, all) => {
14
+ for (var name in all)
15
+ __defProp(target, name, { get: all[name], enumerable: true });
16
+ };
17
+
18
+ // src/ui/dom.ts
19
+ var dom_exports = {};
20
+ __export(dom_exports, {
21
+ append: () => append,
22
+ clear: () => clear,
23
+ closePopover: () => closePopover,
24
+ dialog: () => dialog,
25
+ escapeHtml: () => escapeHtml,
26
+ h: () => h,
27
+ popover: () => popover,
28
+ svg: () => svg,
29
+ timeAgo: () => timeAgo
30
+ });
31
+ function h(tag, attrs = null, ...children) {
32
+ const el = document.createElement(tag);
33
+ if (attrs) {
34
+ for (const [k, v] of Object.entries(attrs)) {
35
+ if (v === null || v === void 0 || v === false) continue;
36
+ if (k === "class") el.className = String(v);
37
+ else if (k === "html") el.innerHTML = String(v);
38
+ else if (k.startsWith("on") && typeof v === "function")
39
+ el.addEventListener(k.slice(2).toLowerCase(), v);
40
+ else if (k === "style" && typeof v === "object") Object.assign(el.style, v);
41
+ else if (k in el && typeof v !== "string" && k !== "title")
42
+ el[k] = v;
43
+ else el.setAttribute(k, String(v));
44
+ }
45
+ }
46
+ append(el, children);
47
+ return el;
48
+ }
49
+ function append(el, children) {
50
+ for (const c of children) {
51
+ if (c === null || c === void 0 || c === false) continue;
52
+ if (Array.isArray(c)) append(el, c);
53
+ else
54
+ el.appendChild(
55
+ typeof c === "string" || typeof c === "number" ? document.createTextNode(String(c)) : c
56
+ );
57
+ }
58
+ }
59
+ function svg(tag, attrs = {}, ...children) {
60
+ const el = document.createElementNS("http://www.w3.org/2000/svg", tag);
61
+ for (const [k, v] of Object.entries(attrs)) {
62
+ if (k.startsWith("on") && typeof v === "function")
63
+ el.addEventListener(k.slice(2).toLowerCase(), v);
64
+ else el.setAttribute(k, String(v));
65
+ }
66
+ for (const c of children) el.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
67
+ return el;
68
+ }
69
+ function clear(el) {
70
+ while (el.firstChild) el.removeChild(el.firstChild);
71
+ }
72
+ function escapeHtml(s) {
73
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
74
+ }
75
+ function timeAgo(iso) {
76
+ const d = (Date.now() - new Date(iso).getTime()) / 1e3;
77
+ if (d < 60) return "just now";
78
+ if (d < 3600) return `${Math.floor(d / 60)} min ago`;
79
+ if (d < 86400) return `${Math.floor(d / 3600)} h ago`;
80
+ return `${Math.floor(d / 86400)} d ago`;
81
+ }
82
+ function popover(el, at) {
83
+ closePopover();
84
+ el.style.position = "absolute";
85
+ document.body.appendChild(el);
86
+ const w = el.offsetWidth;
87
+ const hgt = el.offsetHeight;
88
+ const x = Math.min(at.x, window.innerWidth - w - 12);
89
+ const y = at.y + hgt > window.scrollY + window.innerHeight - 12 ? at.y - hgt - 8 : at.y;
90
+ el.style.left = `${Math.max(8, x)}px`;
91
+ el.style.top = `${Math.max(8, y)}px`;
92
+ openPopover = el;
93
+ setTimeout(() => {
94
+ document.addEventListener("mousedown", onDoc);
95
+ document.addEventListener("keydown", onKey);
96
+ });
97
+ return el;
98
+ }
99
+ function onDoc(e) {
100
+ if (openPopover && !openPopover.contains(e.target)) closePopover();
101
+ }
102
+ function onKey(e) {
103
+ if (e.key === "Escape") closePopover();
104
+ }
105
+ function closePopover() {
106
+ if (openPopover) openPopover.remove();
107
+ openPopover = null;
108
+ document.removeEventListener("mousedown", onDoc);
109
+ document.removeEventListener("keydown", onKey);
110
+ }
111
+ function dialog(content) {
112
+ const overlay = h("div", { class: "overlay" }, h("div", { class: "dialog" }, content));
113
+ overlay.addEventListener("mousedown", (e) => {
114
+ if (e.target === overlay) overlay.remove();
115
+ });
116
+ document.body.appendChild(overlay);
117
+ return { close: () => overlay.remove() };
118
+ }
119
+ var openPopover;
120
+ var init_dom = __esm({
121
+ "src/ui/dom.ts"() {
122
+ "use strict";
123
+ openPopover = null;
124
+ }
125
+ });
126
+
127
+ // src/ui/api.ts
128
+ async function j(url, init) {
129
+ const r = await fetch(url, init);
130
+ const body = await r.json();
131
+ if (!r.ok) throw new Error(body.error ?? r.statusText);
132
+ return body;
133
+ }
134
+ function post(url, body) {
135
+ return j(url, {
136
+ method: "POST",
137
+ headers: { "content-type": "application/json" },
138
+ body: JSON.stringify(body)
139
+ });
140
+ }
141
+ var api = {
142
+ reviews: () => j("/api/reviews"),
143
+ review: (id, revision) => j(`/api/reviews/${id}${revision ? `?revision=${revision}` : ""}`),
144
+ revisions: (id) => j(`/api/reviews/${id}/revisions`),
145
+ commits: (id) => j(`/api/reviews/${id}/commits`),
146
+ diff: (id, path) => j(`/api/reviews/${id}/diff?path=${encodeURIComponent(path)}`),
147
+ file: (id, path, graph, from, to) => j(
148
+ `/api/reviews/${id}/file?path=${encodeURIComponent(path)}&graph=${graph}${from ? `&from=${from}` : ""}${to ? `&to=${to}` : ""}`
149
+ ),
150
+ symbols: (id, name, graph) => j(`/api/reviews/${id}/symbols?name=${encodeURIComponent(name)}&graph=${graph}`),
151
+ createThread: (id, input) => post(`/api/reviews/${id}/threads`, input),
152
+ reply: (id, tid, body) => post(`/api/reviews/${id}/threads/${tid}/reply`, { body, role: "reviewer" }),
153
+ resolve: (id, tid) => post(`/api/reviews/${id}/threads/${tid}/resolve`, {}),
154
+ reopen: (id, tid) => post(`/api/reviews/${id}/threads/${tid}/reopen`, {}),
155
+ deleteThread: (id, tid) => post(`/api/reviews/${id}/threads/${tid}/delete`, {}),
156
+ submit: (id, decision, body) => post(`/api/reviews/${id}/submit`, { decision, body }),
157
+ dismiss: (id, dismissed) => post(`/api/reviews/${id}/dismiss`, { dismissed }),
158
+ remove: (id) => j(`/api/reviews/${id}`, { method: "DELETE" })
159
+ };
160
+
161
+ // src/ui/app.ts
162
+ init_dom();
163
+
164
+ // src/ui/state.ts
165
+ var state = {
166
+ id: "",
167
+ data: null,
168
+ view: "review",
169
+ params: new URLSearchParams(),
170
+ side: { kind: "none" },
171
+ viewingRevision: null,
172
+ latestRevision: null,
173
+ splitDiff: localStorage.getItem("thurview.split") !== "0"
174
+ };
175
+ var listeners = {};
176
+ function on(event, fn) {
177
+ (listeners[event] ??= []).push(fn);
178
+ }
179
+ function emit(event) {
180
+ for (const fn of listeners[event] ?? []) fn();
181
+ }
182
+ function threadsFor(target) {
183
+ return (state.data?.threads ?? []).filter((t) => target(t.target));
184
+ }
185
+ function describeTarget(t) {
186
+ if (t.type === "document") return "document";
187
+ if (t.type === "file") {
188
+ if (!t.line) return `${t.path} (file)`;
189
+ const range = t.endLine && t.endLine > t.line ? `${t.line}-${t.endLine}` : String(t.line);
190
+ return `${t.path}:${range}${t.side === "base" ? " (base)" : ""}`;
191
+ }
192
+ if (t.type === "map") return `map \xB7 ${t.node}`;
193
+ return "review";
194
+ }
195
+ function navigate(view, params = {}) {
196
+ const p = new URLSearchParams();
197
+ for (const [k, v] of Object.entries(params)) if (v !== void 0 && v !== "") p.set(k, String(v));
198
+ const q = p.toString();
199
+ location.hash = `#/${view}${q ? `?${q}` : ""}`;
200
+ }
201
+ function readHash() {
202
+ const m2 = /^#\/(\w+)(?:\?(.*))?$/.exec(location.hash);
203
+ const view = m2?.[1] ?? "review";
204
+ state.view = ["review", "commits", "files", "map"].includes(view) ? view : "review";
205
+ state.params = new URLSearchParams(m2?.[2] ?? "");
206
+ }
207
+ function isTerminal() {
208
+ const s = state.data?.review.status;
209
+ return s === "accepted" || s === "rejected";
210
+ }
211
+ function readOnly() {
212
+ return isTerminal() || state.viewingRevision !== null;
213
+ }
214
+
215
+ // src/ui/code.ts
216
+ init_dom();
217
+
218
+ // src/ui/threads.ts
219
+ init_dom();
220
+ async function reload() {
221
+ state.data = await api.review(state.id, state.viewingRevision ?? void 0);
222
+ emit("threads");
223
+ }
224
+ function commentPopover(target, quote) {
225
+ const ta = h("textarea", { placeholder: "Comment, or a question for the agent\u2026" });
226
+ let mode = "review";
227
+ const modeBtns = h("div", { class: "row" });
228
+ const b1 = h("button", { class: "small primary", onclick: () => set("review") }, "Add to review");
229
+ const b2 = h("button", { class: "small", onclick: () => set("ask") }, "Ask now");
230
+ const set = (m2) => {
231
+ mode = m2;
232
+ b1.className = `small ${m2 === "review" ? "primary" : ""}`;
233
+ b2.className = `small ${m2 === "ask" ? "primary" : ""}`;
234
+ hint.textContent = m2 === "review" ? "Held until you submit the review." : "Sent to the agent at once; it answers in this thread.";
235
+ };
236
+ const hint = h(
237
+ "span",
238
+ { class: "muted", style: { fontSize: "12px" } },
239
+ "Held until you submit the review."
240
+ );
241
+ modeBtns.append(b1, b2, hint);
242
+ const submit = async () => {
243
+ const body = ta.value.trim();
244
+ if (!body) return;
245
+ await api.createThread(state.id, {
246
+ kind: mode === "ask" ? "question" : "comment",
247
+ mode,
248
+ target: quote ? { ...target, quote } : target,
249
+ body
250
+ });
251
+ closePopover();
252
+ await reload();
253
+ };
254
+ ta.addEventListener("keydown", (e) => {
255
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") void submit();
256
+ });
257
+ const el = h(
258
+ "div",
259
+ { class: "comment-popover" },
260
+ quote ? h("div", { class: "quote" }, quote) : null,
261
+ h(
262
+ "div",
263
+ { class: "muted", style: { fontSize: "12px", marginBottom: "4px" } },
264
+ describeTarget(target)
265
+ ),
266
+ ta,
267
+ modeBtns,
268
+ h(
269
+ "div",
270
+ { class: "row", style: { justifyContent: "flex-end" } },
271
+ h("button", { class: "small ghost", onclick: () => closePopover() }, "Cancel"),
272
+ h("button", { class: "small ok", onclick: submit }, "Save ", h("kbd", null, "\u2318\u21B5"))
273
+ )
274
+ );
275
+ setTimeout(() => ta.focus());
276
+ return el;
277
+ }
278
+ function threadPinRow(t) {
279
+ const first = t.messages[0];
280
+ const last = t.messages[t.messages.length - 1];
281
+ return h(
282
+ "div",
283
+ {
284
+ class: `thread-pin ${t.status === "resolved" ? "resolved" : ""}`,
285
+ onclick: () => focusThread(t.id)
286
+ },
287
+ h(
288
+ "span",
289
+ { class: `badge ${t.kind === "question" ? "accent" : ""}` },
290
+ t.kind === "question" ? "Q" : t.submitted ? "C" : "draft"
291
+ ),
292
+ h("span", { class: "who" }, first?.role === "agent" ? "agent" : "you"),
293
+ h(
294
+ "span",
295
+ {
296
+ style: {
297
+ flex: "1",
298
+ minWidth: "0",
299
+ overflow: "hidden",
300
+ textOverflow: "ellipsis",
301
+ whiteSpace: "nowrap"
302
+ }
303
+ },
304
+ first?.body ?? ""
305
+ ),
306
+ t.messages.length > 1 ? h(
307
+ "span",
308
+ { class: "muted" },
309
+ `${t.messages.length - 1} repl${t.messages.length > 2 ? "ies" : "y"} \xB7 ${last?.role}`
310
+ ) : null,
311
+ t.status === "resolved" ? h("span", { class: "badge ok" }, "resolved") : null
312
+ );
313
+ }
314
+ function focusThread(id) {
315
+ state.side = { kind: "threads", activeThread: id };
316
+ emit("side");
317
+ }
318
+ function goToTarget(t) {
319
+ const tg = t.target;
320
+ if (tg.type === "document") {
321
+ navigate("review", { block: tg.blockId });
322
+ } else if (tg.type === "file") {
323
+ navigate("files", { path: tg.path, line: tg.line, side: tg.side });
324
+ } else if (tg.type === "map") {
325
+ navigate("map", { node: tg.node });
326
+ }
327
+ }
328
+ function renderThreadsPanel(container) {
329
+ const threads = state.data?.threads ?? [];
330
+ const pending = threads.filter((t) => !t.submitted);
331
+ let showResolved = false;
332
+ const head = h(
333
+ "div",
334
+ { class: "side-head" },
335
+ h(
336
+ "div",
337
+ { style: { flex: "1" } },
338
+ h("b", null, "Threads"),
339
+ " ",
340
+ h("span", { class: "muted" }, `${threads.filter((t) => t.status === "open").length} open`)
341
+ ),
342
+ h(
343
+ "label",
344
+ { style: { fontSize: "12px" } },
345
+ h("input", {
346
+ type: "checkbox",
347
+ onchange: (e) => {
348
+ showResolved = e.target.checked;
349
+ draw();
350
+ }
351
+ }),
352
+ " resolved"
353
+ ),
354
+ h(
355
+ "button",
356
+ {
357
+ class: "small ghost",
358
+ onclick: () => {
359
+ state.side = { kind: "none" };
360
+ emit("side");
361
+ }
362
+ },
363
+ "\u2715"
364
+ )
365
+ );
366
+ const body = h("div", { class: "side-body" });
367
+ const draw = () => {
368
+ body.innerHTML = "";
369
+ if (pending.length && !readOnly()) {
370
+ body.appendChild(
371
+ h(
372
+ "div",
373
+ { class: "banner" },
374
+ `${pending.length} comment${pending.length > 1 ? "s" : ""} pending`,
375
+ h("span", { style: { flex: "1" } }),
376
+ h("button", { class: "small primary", onclick: () => submitDialog() }, "Submit review")
377
+ )
378
+ );
379
+ }
380
+ const list = h("div", { class: "threads" });
381
+ const shown = threads.filter(
382
+ (t) => showResolved || t.status === "open" || t.id === state.side.activeThread
383
+ );
384
+ if (!shown.length)
385
+ list.appendChild(
386
+ h(
387
+ "div",
388
+ { class: "empty-state" },
389
+ "No threads yet. Select text, click a line number, or use the + next to a paragraph."
390
+ )
391
+ );
392
+ for (const t of shown.slice().sort((a, b) => a.updatedAt < b.updatedAt ? 1 : -1))
393
+ list.appendChild(threadCard(t));
394
+ body.appendChild(list);
395
+ };
396
+ draw();
397
+ container.append(head, body);
398
+ if (state.side.activeThread)
399
+ setTimeout(() => body.querySelector(".thread.active")?.scrollIntoView({ block: "center" }));
400
+ }
401
+ function threadCard(t) {
402
+ const ta = h("textarea", { placeholder: "Reply\u2026", rows: 2 });
403
+ const send = async () => {
404
+ const v = ta.value.trim();
405
+ if (!v) return;
406
+ await api.reply(state.id, t.id, v);
407
+ await reload();
408
+ };
409
+ ta.addEventListener("keydown", (e) => {
410
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") void send();
411
+ });
412
+ const quote = t.target.type === "document" || t.target.type === "file" ? t.target.quote : void 0;
413
+ return h(
414
+ "div",
415
+ { class: `thread ${state.side.activeThread === t.id ? "active" : ""}` },
416
+ h(
417
+ "div",
418
+ { class: "thead" },
419
+ h(
420
+ "span",
421
+ { class: `badge ${t.kind === "question" ? "accent" : ""}` },
422
+ t.kind === "question" ? "question" : t.submitted ? "comment" : "pending"
423
+ ),
424
+ h("span", { class: "target", onclick: () => goToTarget(t) }, describeTarget(t.target)),
425
+ h("span", { class: `badge ${t.status === "resolved" ? "ok" : ""}` }, t.status)
426
+ ),
427
+ quote ? h("div", { class: "quote" }, `\u201C${quote.length > 160 ? quote.slice(0, 160) + "\u2026" : quote}\u201D`) : null,
428
+ t.messages.map(
429
+ (m2) => h(
430
+ "div",
431
+ { class: `msg ${m2.role}` },
432
+ h("span", { class: "who" }, m2.role === "agent" ? "Agent" : "You"),
433
+ h("span", { class: "muted", style: { fontSize: "11px" } }, timeAgo(m2.at)),
434
+ h("div", null, m2.body)
435
+ )
436
+ ),
437
+ readOnly() ? null : h(
438
+ "div",
439
+ { class: "reply" },
440
+ ta,
441
+ h(
442
+ "div",
443
+ { class: "row" },
444
+ !t.submitted ? h(
445
+ "button",
446
+ {
447
+ class: "small ghost",
448
+ onclick: async () => {
449
+ await api.deleteThread(state.id, t.id);
450
+ await reload();
451
+ }
452
+ },
453
+ "Delete"
454
+ ) : null,
455
+ t.status === "open" ? h(
456
+ "button",
457
+ {
458
+ class: "small",
459
+ onclick: async () => {
460
+ await api.resolve(state.id, t.id);
461
+ await reload();
462
+ }
463
+ },
464
+ "Resolve"
465
+ ) : h(
466
+ "button",
467
+ {
468
+ class: "small",
469
+ onclick: async () => {
470
+ await api.reopen(state.id, t.id);
471
+ await reload();
472
+ }
473
+ },
474
+ "Reopen"
475
+ ),
476
+ h("button", { class: "small primary", onclick: send }, "Reply")
477
+ )
478
+ )
479
+ );
480
+ }
481
+ function submitDialog() {
482
+ const pending = (state.data?.threads ?? []).filter((t) => !t.submitted).length;
483
+ const ta = h("textarea", { rows: 4, placeholder: "Summary for the agent (optional)" });
484
+ const d = dialog(
485
+ h(
486
+ "div",
487
+ null,
488
+ h("h3", null, "Submit review"),
489
+ h(
490
+ "p",
491
+ { class: "muted" },
492
+ pending ? `${pending} pending comment${pending > 1 ? "s" : ""} will be sent to the agent.` : "No pending comments."
493
+ ),
494
+ ta,
495
+ h(
496
+ "div",
497
+ { class: "row" },
498
+ h("button", { class: "ghost", onclick: () => d.close() }, "Cancel"),
499
+ h("button", { onclick: () => decide("request-changes") }, "Request changes"),
500
+ h("button", { class: "ok", onclick: () => decide("approve") }, "Approve")
501
+ )
502
+ )
503
+ );
504
+ const decide = async (decision) => {
505
+ await api.submit(state.id, decision, ta.value.trim());
506
+ d.close();
507
+ await reload();
508
+ emit("data");
509
+ };
510
+ }
511
+
512
+ // src/ui/code.ts
513
+ function codeTable(o) {
514
+ const table = h("table");
515
+ const threads = o.commentable ? threadsFor((t) => t.type === "file" && t.path === o.path && t.side === o.graph) : [];
516
+ const endOf = (t) => t.target.type === "file" ? t.target.endLine ?? t.target.line : 0;
517
+ o.lines.forEach((html, i) => {
518
+ const n = o.startLine + i;
519
+ const hl = o.highlight && n >= o.highlight[0] && n <= o.highlight[1];
520
+ const lineThreads = threads.filter((t) => endOf(t) === n);
521
+ const tr = h(
522
+ "tr",
523
+ { class: `${hl ? "hl" : ""} ${lineThreads.length ? "commented" : ""}`, "data-line": n },
524
+ h(
525
+ "td",
526
+ {
527
+ class: "ln",
528
+ title: o.commentable ? "Comment on this line (shift-click for a range)" : "",
529
+ onclick: (e) => o.commentable && lineClick(e, o.path, o.graph, n, table)
530
+ },
531
+ String(n)
532
+ ),
533
+ h("td", { class: "src", html: html || " " })
534
+ );
535
+ table.appendChild(tr);
536
+ for (const t of lineThreads)
537
+ table.appendChild(h("tr", { class: "thread-row" }, h("td", { colspan: 2 }, threadPinRow(t))));
538
+ });
539
+ attachDefinitions(table, o.graph);
540
+ return table;
541
+ }
542
+ function startLineComment(e, target, quote) {
543
+ if (state.viewingRevision !== null) return;
544
+ const el = commentPopover(target, quote?.trim() ? quote.trim() : void 0);
545
+ popover(el, { x: e.pageX + 8, y: e.pageY + 8 });
546
+ }
547
+ var rangeStart = null;
548
+ function lineClick(e, path, side2, line, table) {
549
+ if (state.viewingRevision !== null) return;
550
+ if (e.shiftKey && rangeStart && rangeStart.path === path && rangeStart.side === side2) {
551
+ const from = Math.min(rangeStart.line, line);
552
+ const to = Math.max(rangeStart.line, line);
553
+ const quote = rowsText(table, side2, from, to);
554
+ clearRangeMarks(table);
555
+ for (let n = from; n <= to; n++)
556
+ table.querySelector(`tr[data-${side2}="${n}"]`)?.classList.add("range-sel");
557
+ rangeStart = null;
558
+ startLineComment(e, { type: "file", path, side: side2, line: from, endLine: to }, quote);
559
+ return;
560
+ }
561
+ rangeStart = { path, side: side2, line, table };
562
+ clearRangeMarks(table);
563
+ table.querySelector(`tr[data-${side2}="${line}"]`)?.classList.add("range-sel");
564
+ startLineComment(e, { type: "file", path, side: side2, line }, rowsText(table, side2, line, line));
565
+ }
566
+ function clearRangeMarks(table) {
567
+ table.querySelectorAll("tr.range-sel").forEach((tr) => tr.classList.remove("range-sel"));
568
+ }
569
+ function rowsText(table, side2, from, to) {
570
+ const parts = [];
571
+ for (let n = from; n <= to; n++) {
572
+ const td = table.querySelector(`tr[data-${side2}="${n}"] td.src`);
573
+ if (td) parts.push((td.textContent ?? "").replace(/^[+\- ]/, ""));
574
+ }
575
+ return parts.join("\n").slice(0, 400);
576
+ }
577
+ function codeSelectionHandler(root) {
578
+ let btn = null;
579
+ const remove = () => {
580
+ btn?.remove();
581
+ btn = null;
582
+ };
583
+ const onChange = () => {
584
+ const sel = window.getSelection();
585
+ if (!sel || sel.isCollapsed || !sel.rangeCount || !root.isConnected) return remove();
586
+ const range = sel.getRangeAt(0);
587
+ const rowOf = (node) => (node instanceof Element ? node : node.parentElement)?.closest(
588
+ "tr[data-head], tr[data-base]"
589
+ );
590
+ const a = rowOf(range.startContainer);
591
+ const b = rowOf(range.endContainer);
592
+ if (!a || !b || !root.contains(a) || !root.contains(b) || state.viewingRevision !== null)
593
+ return remove();
594
+ const table = a.closest("table");
595
+ if (!table || table !== b.closest("table")) return remove();
596
+ const path = root.closest("[data-path]")?.dataset["path"] ?? "";
597
+ const side2 = a.dataset["head"] && b.dataset["head"] ? "head" : "base";
598
+ const la = Number(a.dataset[side2]);
599
+ const lb = Number(b.dataset[side2]);
600
+ if (!la || !lb) return remove();
601
+ const from = Math.min(la, lb);
602
+ const to = Math.max(la, lb);
603
+ const quote = sel.toString().trim().slice(0, 400);
604
+ remove();
605
+ const rect = range.getBoundingClientRect();
606
+ btn = h(
607
+ "button",
608
+ {
609
+ class: "small primary selection-btn",
610
+ onmousedown: (e) => e.preventDefault(),
611
+ onclick: (e) => {
612
+ remove();
613
+ window.getSelection()?.removeAllRanges();
614
+ startLineComment(
615
+ e,
616
+ { type: "file", path, side: side2, line: from, ...to > from ? { endLine: to } : {} },
617
+ quote
618
+ );
619
+ }
620
+ },
621
+ to > from ? `Comment on lines ${from}-${to}` : `Comment on line ${from}`
622
+ );
623
+ btn.style.left = `${rect.left + window.scrollX}px`;
624
+ btn.style.top = `${rect.bottom + window.scrollY + 6}px`;
625
+ document.body.appendChild(btn);
626
+ };
627
+ document.addEventListener("selectionchange", onChange);
628
+ }
629
+ function attachDefinitions(root, graph) {
630
+ root.addEventListener("click", async (e) => {
631
+ const td = e.target.closest("td.src");
632
+ if (!td) return;
633
+ const sel = window.getSelection();
634
+ if (sel && !sel.isCollapsed) return;
635
+ const word = wordAt(e);
636
+ if (!word) return;
637
+ const defs = await api.symbols(state.id, word, graph);
638
+ if (e.ctrlKey || e.metaKey) {
639
+ const d = defs[0];
640
+ if (d) openPeekAt(d.path, graph, d.line, `${d.kind} ${d.name}`);
641
+ return;
642
+ }
643
+ const box = h("div", { class: "def-popover" });
644
+ if (!defs.length)
645
+ box.appendChild(
646
+ h(
647
+ "div",
648
+ { class: "empty" },
649
+ `No definition of `,
650
+ h("code", null, word),
651
+ ` found at ${graph}`
652
+ )
653
+ );
654
+ for (const d of defs) {
655
+ box.appendChild(
656
+ h(
657
+ "div",
658
+ {
659
+ class: "item",
660
+ onclick: () => {
661
+ closePopover();
662
+ openPeekAt(d.path, graph, d.line, `${d.kind} ${d.name}`);
663
+ }
664
+ },
665
+ h("span", { class: "kind" }, d.kind),
666
+ h("span", { class: "mono" }, `${d.path}:${d.line}`)
667
+ )
668
+ );
669
+ }
670
+ popover(box, { x: e.pageX + 6, y: e.pageY + 10 });
671
+ });
672
+ }
673
+ function wordAt(e) {
674
+ const doc = document;
675
+ let node = null;
676
+ let offset = 0;
677
+ if (doc.caretPositionFromPoint) {
678
+ const p = doc.caretPositionFromPoint(e.clientX, e.clientY);
679
+ if (p) {
680
+ node = p.offsetNode;
681
+ offset = p.offset;
682
+ }
683
+ } else if (document.caretRangeFromPoint) {
684
+ const r = document.caretRangeFromPoint(e.clientX, e.clientY);
685
+ if (r) {
686
+ node = r.startContainer;
687
+ offset = r.startOffset;
688
+ }
689
+ }
690
+ if (!node || node.nodeType !== Node.TEXT_NODE) return null;
691
+ const text = node.textContent ?? "";
692
+ const re = /[A-Za-z_$][\w$]*/g;
693
+ let m2;
694
+ while (m2 = re.exec(text)) if (m2.index <= offset && offset <= m2.index + m2[0].length) return m2[0];
695
+ return null;
696
+ }
697
+ var current = null;
698
+ function openAnchorPeek(a) {
699
+ if (!a.peek) return;
700
+ current = {
701
+ title: a.title,
702
+ detail: a.detail,
703
+ path: a.peek.file,
704
+ graph: a.peek.graph,
705
+ from: a.peek.from,
706
+ to: a.peek.to,
707
+ lines: a.peek.lines,
708
+ total: a.peek.total,
709
+ anchorId: a.id
710
+ };
711
+ state.side = { kind: "peek", anchor: a.id };
712
+ emit("side");
713
+ location.hash = location.hash.replace(/&?anchor=[^&]*/, "");
714
+ }
715
+ async function openPeekAt(path, graph, line, title) {
716
+ const from = Math.max(1, line - 5);
717
+ const f = await api.file(state.id, path, graph, from, line + 12);
718
+ current = {
719
+ title: title ?? path,
720
+ path,
721
+ graph,
722
+ from: f.from,
723
+ to: f.to,
724
+ lines: f.lines,
725
+ total: f.total
726
+ };
727
+ current.highlightLine = line;
728
+ state.side = { kind: "peek" };
729
+ emit("side");
730
+ }
731
+ function renderPeek(container) {
732
+ const p = current;
733
+ if (!p) return;
734
+ const inChanges = !!state.data?.changes.some((c) => c.path === p.path);
735
+ const head = h(
736
+ "div",
737
+ { class: "side-head" },
738
+ h(
739
+ "div",
740
+ { style: { flex: "1", minWidth: "0" } },
741
+ h("div", { style: { fontWeight: "600" } }, p.title),
742
+ h(
743
+ "div",
744
+ {
745
+ class: "path",
746
+ title: p.path,
747
+ onclick: () => navigate("files", { path: p.path, line: p.highlightLine ?? p.from, side: p.graph })
748
+ },
749
+ `${p.path}:${p.from}-${p.to}`,
750
+ " ",
751
+ h("span", { class: `badge ${p.graph === "base" ? "del" : ""}` }, p.graph)
752
+ )
753
+ ),
754
+ h(
755
+ "button",
756
+ {
757
+ class: "small",
758
+ onclick: () => navigate("files", { path: p.path, line: p.highlightLine ?? p.from, side: p.graph })
759
+ },
760
+ inChanges ? "Open in Files" : "Open file"
761
+ ),
762
+ h(
763
+ "button",
764
+ {
765
+ class: "small ghost",
766
+ onclick: () => {
767
+ state.side = { kind: "none" };
768
+ emit("side");
769
+ }
770
+ },
771
+ "\u2715"
772
+ )
773
+ );
774
+ const body = h("div", { class: "side-body" });
775
+ if (p.detail)
776
+ body.appendChild(
777
+ h("div", { class: "muted", style: { padding: "8px 12px", fontSize: "13px" } }, p.detail)
778
+ );
779
+ const frame = h("div", { class: "code" });
780
+ const draw = () => {
781
+ frame.innerHTML = "";
782
+ const bar = (dir) => {
783
+ const can = dir === "up" ? p.from > 1 : p.to < p.total;
784
+ return h(
785
+ "div",
786
+ { class: "expand-bar" },
787
+ can ? h(
788
+ "button",
789
+ { class: "small ghost", onclick: () => expand(dir) },
790
+ dir === "up" ? "\u2191 20 more lines" : "\u2193 20 more lines"
791
+ ) : h("span", { class: "muted" }, dir === "up" ? "start of file" : "end of file")
792
+ );
793
+ };
794
+ frame.appendChild(bar("up"));
795
+ frame.appendChild(
796
+ codeTable({
797
+ path: p.path,
798
+ graph: p.graph,
799
+ startLine: p.from,
800
+ lines: p.lines,
801
+ highlight: p.highlightLine ? [p.highlightLine, p.highlightLine] : p.anchorId ? [p.from, p.to] : void 0,
802
+ commentable: true
803
+ })
804
+ );
805
+ frame.appendChild(bar("down"));
806
+ };
807
+ const expand = async (dir) => {
808
+ if (dir === "up") {
809
+ const from = Math.max(1, p.from - 20);
810
+ const f = await api.file(state.id, p.path, p.graph, from, p.from - 1);
811
+ p.lines = [...f.lines, ...p.lines];
812
+ p.from = from;
813
+ } else {
814
+ const to = Math.min(p.total, p.to + 20);
815
+ const f = await api.file(state.id, p.path, p.graph, p.to + 1, to);
816
+ p.lines = [...p.lines, ...f.lines];
817
+ p.to = to;
818
+ }
819
+ if (p.anchorId) {
820
+ const a = state.data?.document?.anchors[p.anchorId];
821
+ if (a?.peek) p.highlightRange = [a.peek.from, a.peek.to];
822
+ }
823
+ draw();
824
+ };
825
+ draw();
826
+ body.appendChild(frame);
827
+ container.appendChild(head);
828
+ container.appendChild(body);
829
+ }
830
+
831
+ // src/ui/views/review.ts
832
+ init_dom();
833
+
834
+ // src/ui/diagrams.ts
835
+ init_dom();
836
+ init_dom();
837
+ function openAnchor(id) {
838
+ const a = state.data?.document?.anchors[id];
839
+ if (a) openAnchorPeek(a);
840
+ }
841
+ function sequenceDiagram(b) {
842
+ const colW = 170;
843
+ const top = 44;
844
+ const rowH = 34;
845
+ const n = b.actors.length;
846
+ const width = Math.max(colW * n, 320);
847
+ const height = top + rowH * (b.messages.length + 1) + 10;
848
+ const el = svg("svg", { viewBox: `0 0 ${width} ${height}`, class: "seq" });
849
+ el.appendChild(
850
+ svg(
851
+ "defs",
852
+ {},
853
+ svg(
854
+ "marker",
855
+ {
856
+ id: "arr",
857
+ viewBox: "0 0 10 10",
858
+ refX: "9",
859
+ refY: "5",
860
+ markerWidth: "7",
861
+ markerHeight: "7",
862
+ orient: "auto"
863
+ },
864
+ svg("path", { d: "M0,0 L10,5 L0,10 z", fill: "currentColor" })
865
+ )
866
+ )
867
+ );
868
+ const x = (id) => b.actors.findIndex((a) => a.id === id) * colW + colW / 2;
869
+ b.actors.forEach((a, i) => {
870
+ const cx = i * colW + colW / 2;
871
+ const g = svg("g", { class: "actor" });
872
+ g.appendChild(svg("rect", { x: cx - 70, y: 6, width: 140, height: 28, rx: 6 }));
873
+ g.appendChild(svg("text", { x: cx, y: 25, "text-anchor": "middle" }, a.label));
874
+ g.appendChild(svg("line", { class: "life", x1: cx, y1: 34, x2: cx, y2: height - 6 }));
875
+ el.appendChild(g);
876
+ });
877
+ b.messages.forEach((m2, i) => {
878
+ const y = top + rowH * (i + 1);
879
+ const x1 = x(m2.from);
880
+ const x2 = x(m2.to);
881
+ const g = svg("g", { class: "msg" });
882
+ const self = x1 === x2;
883
+ if (self) {
884
+ g.appendChild(
885
+ svg("path", {
886
+ d: `M${x1},${y - 10} h30 v20 h-30`,
887
+ fill: "none",
888
+ stroke: "currentColor",
889
+ "marker-end": "url(#arr)"
890
+ })
891
+ );
892
+ } else {
893
+ g.appendChild(svg("line", { x1, y1: y, x2: x2 + (x2 > x1 ? -4 : 4), y2: y }));
894
+ }
895
+ const label = svg(
896
+ "text",
897
+ { x: self ? x1 + 36 : (x1 + x2) / 2, y: y - 6, "text-anchor": self ? "start" : "middle" },
898
+ `${i + 1}. ${m2.label}`
899
+ );
900
+ label.addEventListener("click", (e) => {
901
+ if (m2.anchor) openAnchor(m2.anchor);
902
+ else if (m2.code)
903
+ popover(
904
+ h(
905
+ "div",
906
+ { class: "def-popover" },
907
+ h("pre", { style: { margin: "0", border: "none" } }, m2.code.text)
908
+ ),
909
+ { x: e.pageX, y: e.pageY + 8 }
910
+ );
911
+ });
912
+ g.appendChild(label);
913
+ el.appendChild(g);
914
+ });
915
+ return h("div", { class: "diagram" }, h("div", { class: "dhead" }, b.label), el);
916
+ }
917
+ function callstackDiff(b, doc) {
918
+ const rows = b.rows.map((r, i) => {
919
+ const a = doc.anchors[r.anchor];
920
+ return h(
921
+ "div",
922
+ { class: `row ${r.kind}`, title: r.reason ?? "", onclick: () => openAnchor(r.anchor) },
923
+ h("span", { class: "sign" }, r.kind === "add" ? "+" : r.kind === "del" ? "\u2212" : " "),
924
+ h("span", { style: { width: `${i * 14}px`, display: "inline-block" } }),
925
+ r.calls ? h("span", { class: "calls" }, "\u2248 ") : null,
926
+ h("span", null, a?.title ?? r.anchor),
927
+ a?.peek ? h(
928
+ "span",
929
+ { class: "muted", style: { marginLeft: "auto" } },
930
+ `${a.peek.file}:${a.peek.from}`
931
+ ) : null
932
+ );
933
+ });
934
+ return h(
935
+ "div",
936
+ { class: "diagram callstack" },
937
+ h("div", { class: "dhead" }, b.title ?? "Call stack"),
938
+ h("div", { style: { padding: "6px 0" } }, rows)
939
+ );
940
+ }
941
+ function databaseLens(b, doc) {
942
+ let active = 0;
943
+ const tabs = h("div", { class: "uc-tabs" });
944
+ const body = h("div", { class: "body" });
945
+ const draw = () => {
946
+ tabs.innerHTML = "";
947
+ b.usecases.forEach(
948
+ (uc2, i) => tabs.appendChild(
949
+ h(
950
+ "button",
951
+ {
952
+ class: `small ${i === active ? "active" : ""}`,
953
+ onclick: () => {
954
+ active = i;
955
+ draw();
956
+ }
957
+ },
958
+ uc2.label
959
+ )
960
+ )
961
+ );
962
+ const uc = b.usecases[active];
963
+ body.innerHTML = "";
964
+ const touched = /* @__PURE__ */ new Map();
965
+ for (const op of uc.ops) {
966
+ const [s, c, f] = op.store.split(".");
967
+ const key = `${s}.${c}`;
968
+ if (!touched.has(key)) touched.set(key, /* @__PURE__ */ new Set());
969
+ if (f) touched.get(key).add(f);
970
+ }
971
+ const actorIds = [...new Set(uc.ops.map((o) => o.actor))];
972
+ body.appendChild(
973
+ h(
974
+ "div",
975
+ { class: "actors" },
976
+ actorIds.map((id) => h("div", { class: "actor" }, doc.actors[id]?.label ?? id))
977
+ )
978
+ );
979
+ const stores = h("div", null);
980
+ for (const sid of b.stores) {
981
+ const s = doc.stores[sid];
982
+ if (!s) continue;
983
+ const colls = s.tables ?? s.documents ?? {};
984
+ stores.appendChild(
985
+ h(
986
+ "div",
987
+ { class: "store" },
988
+ h("div", { class: "sname" }, s.label, " ", h("span", { class: "badge" }, s.kind)),
989
+ Object.entries(colls).map(([cid, c]) => {
990
+ const hit = touched.get(`${sid}.${cid}`);
991
+ return h(
992
+ "div",
993
+ { class: `table ${hit ? "hit" : ""}` },
994
+ h("div", { class: "tname" }, c.label ?? cid),
995
+ h(
996
+ "div",
997
+ { class: "cols" },
998
+ Object.entries(c.schema).map(
999
+ ([f, def]) => h(
1000
+ "span",
1001
+ { class: hit?.has(f) ? "hit" : "" },
1002
+ `${f}${def.pk ? "*" : ""}:${def.type} `
1003
+ )
1004
+ )
1005
+ )
1006
+ );
1007
+ })
1008
+ )
1009
+ );
1010
+ }
1011
+ body.appendChild(stores);
1012
+ };
1013
+ draw();
1014
+ const ops = h("div", { class: "ops" });
1015
+ const drawOps = () => {
1016
+ ops.innerHTML = "";
1017
+ const uc = b.usecases[active];
1018
+ if (uc.summary) ops.appendChild(h("div", { class: "muted" }, uc.summary));
1019
+ for (const op of uc.ops) {
1020
+ ops.appendChild(
1021
+ h(
1022
+ "div",
1023
+ { class: `op ${op.op}`, onclick: () => openAnchor(op.anchor) },
1024
+ h(
1025
+ "span",
1026
+ { class: "dir" },
1027
+ op.op === "read" ? `${op.store} \u2192 ${doc.actors[op.actor]?.label ?? op.actor}` : `${doc.actors[op.actor]?.label ?? op.actor} \u2192 ${op.store}`
1028
+ ),
1029
+ h("span", null, op.label)
1030
+ )
1031
+ );
1032
+ }
1033
+ };
1034
+ drawOps();
1035
+ tabs.addEventListener("click", drawOps);
1036
+ return h(
1037
+ "div",
1038
+ { class: "diagram dblens" },
1039
+ h("div", { class: "dhead" }, b.title ?? "Storage"),
1040
+ tabs,
1041
+ body,
1042
+ ops
1043
+ );
1044
+ }
1045
+
1046
+ // src/ui/views/review.ts
1047
+ function renderReview(root) {
1048
+ const doc = state.data?.document;
1049
+ if (!doc) {
1050
+ root.appendChild(
1051
+ h(
1052
+ "div",
1053
+ { class: "empty-state" },
1054
+ "Nothing published yet. The agent publishes a revision with ",
1055
+ h("code", null, "thurview publish"),
1056
+ "."
1057
+ )
1058
+ );
1059
+ return;
1060
+ }
1061
+ const layout = h("div", { class: "doc-layout" });
1062
+ const toc = h(
1063
+ "nav",
1064
+ { class: "toc" },
1065
+ doc.toc.map(
1066
+ (t) => h(
1067
+ "a",
1068
+ {
1069
+ href: `#/review?block=${t.id}`,
1070
+ class: `l${t.level}`,
1071
+ "data-block": t.id,
1072
+ onclick: (e) => {
1073
+ e.preventDefault();
1074
+ scrollToBlock(t.id);
1075
+ }
1076
+ },
1077
+ t.text
1078
+ )
1079
+ )
1080
+ );
1081
+ const docEl = h("article", { class: "doc" });
1082
+ let section = null;
1083
+ const sectionLevel = 2;
1084
+ for (const b of doc.blocks) {
1085
+ const el = renderBlock(b);
1086
+ if (b.type === "heading" && b.level === sectionLevel) {
1087
+ section = h("div", { class: `section ${b.collapsed ? "section-collapsed" : ""}` });
1088
+ docEl.appendChild(section);
1089
+ const toggle = h("span", { class: "heading-toggle" }, b.collapsed ? "show" : "hide");
1090
+ toggle.addEventListener("click", () => {
1091
+ const s = toggle.closest(".section");
1092
+ s.classList.toggle("section-collapsed");
1093
+ toggle.textContent = s.classList.contains("section-collapsed") ? "show" : "hide";
1094
+ });
1095
+ el.querySelector("h2,h1,h3")?.appendChild(toggle);
1096
+ el.classList.add("heading");
1097
+ section.appendChild(el);
1098
+ } else (section ?? docEl).appendChild(el);
1099
+ }
1100
+ layout.append(toc, docEl);
1101
+ root.appendChild(layout);
1102
+ selectionHandler(docEl);
1103
+ docEl.addEventListener("click", (e) => {
1104
+ const a = e.target.closest("a.anchor-link");
1105
+ if (!a) return;
1106
+ e.preventDefault();
1107
+ const id = a.dataset["anchor"];
1108
+ const anchor = doc.anchors[id];
1109
+ if (anchor) {
1110
+ docEl.querySelectorAll("a.anchor-link.active").forEach((x) => x.classList.remove("active"));
1111
+ a.classList.add("active");
1112
+ openAnchorPeek(anchor);
1113
+ }
1114
+ });
1115
+ const target = state.params.get("block");
1116
+ if (target) setTimeout(() => scrollToBlock(target), 50);
1117
+ const anchorParam = state.params.get("anchor");
1118
+ if (anchorParam && doc.anchors[anchorParam] && state.side.kind !== "peek")
1119
+ openAnchorPeek(doc.anchors[anchorParam]);
1120
+ const io = new IntersectionObserver(
1121
+ (entries) => {
1122
+ for (const en of entries) {
1123
+ if (!en.isIntersecting) continue;
1124
+ const id = en.target.dataset["block"];
1125
+ toc.querySelectorAll("a").forEach((a) => a.classList.toggle("active", a.dataset["block"] === id));
1126
+ }
1127
+ },
1128
+ { rootMargin: "-10% 0px -80% 0px" }
1129
+ );
1130
+ docEl.querySelectorAll(".block.heading").forEach((el) => io.observe(el));
1131
+ }
1132
+ function scrollToBlock(id) {
1133
+ const el = document.querySelector(`[data-block="${id}"]`);
1134
+ if (!el) return;
1135
+ el.closest(".section")?.classList.remove("section-collapsed");
1136
+ el.scrollIntoView({ block: "start", behavior: "smooth" });
1137
+ el.style.transition = "background .8s";
1138
+ el.style.background = "var(--sel)";
1139
+ setTimeout(() => el.style.background = "", 1200);
1140
+ }
1141
+ function renderBlock(b) {
1142
+ const doc = state.data.document;
1143
+ const wrap = h("div", { class: "block", "data-block": b.id });
1144
+ const threads = threadsFor((t) => t.type === "document" && t.blockId === b.id);
1145
+ if (threads.length) wrap.classList.add("has-threads");
1146
+ const actions = h(
1147
+ "div",
1148
+ { class: "block-actions" },
1149
+ h(
1150
+ "button",
1151
+ {
1152
+ class: threads.length ? "count" : "",
1153
+ title: "Comment on this block",
1154
+ onclick: (e) => {
1155
+ if (state.viewingRevision !== null) return;
1156
+ popover(commentPopover({ type: "document", blockId: b.id }), {
1157
+ x: e.pageX + 10,
1158
+ y: e.pageY
1159
+ });
1160
+ }
1161
+ },
1162
+ threads.length ? String(threads.length) : "+"
1163
+ )
1164
+ );
1165
+ wrap.appendChild(actions);
1166
+ switch (b.type) {
1167
+ case "html":
1168
+ case "heading":
1169
+ wrap.appendChild(h("div", { html: b.html }));
1170
+ break;
1171
+ case "peek": {
1172
+ const a = doc.anchors[b.anchor];
1173
+ if (a?.peek) {
1174
+ wrap.appendChild(
1175
+ h(
1176
+ "div",
1177
+ { class: "code-frame" },
1178
+ h(
1179
+ "div",
1180
+ { class: "frame-head" },
1181
+ h("span", { class: "title" }, a.title),
1182
+ h(
1183
+ "span",
1184
+ {
1185
+ class: "path",
1186
+ title: "Open in Files",
1187
+ onclick: () => navigate("files", {
1188
+ path: a.peek.file,
1189
+ line: a.peek.from,
1190
+ side: a.peek.graph
1191
+ })
1192
+ },
1193
+ `${a.peek.file}:${a.peek.from}-${a.peek.to}`
1194
+ ),
1195
+ h("span", { class: `badge ${a.peek.graph === "base" ? "del" : ""}` }, a.peek.graph),
1196
+ h("button", { class: "small ghost", onclick: () => openAnchorPeek(a) }, "peek \u25B8")
1197
+ ),
1198
+ a.detail ? h(
1199
+ "div",
1200
+ { class: "muted", style: { padding: "4px 10px", fontSize: "12.5px" } },
1201
+ a.detail
1202
+ ) : null,
1203
+ h(
1204
+ "div",
1205
+ { class: "code" },
1206
+ codeTable({
1207
+ path: a.peek.file,
1208
+ graph: a.peek.graph,
1209
+ startLine: a.peek.from,
1210
+ lines: a.peek.lines,
1211
+ commentable: true
1212
+ })
1213
+ )
1214
+ )
1215
+ );
1216
+ }
1217
+ break;
1218
+ }
1219
+ case "sequence":
1220
+ wrap.appendChild(sequenceDiagram(b));
1221
+ break;
1222
+ case "callstack":
1223
+ wrap.appendChild(callstackDiff(b, doc));
1224
+ break;
1225
+ case "database":
1226
+ wrap.appendChild(databaseLens(b, doc));
1227
+ break;
1228
+ }
1229
+ if (threads.length)
1230
+ wrap.appendChild(h("div", { class: "thread-pins" }, threads.map(threadPinRow)));
1231
+ return wrap;
1232
+ }
1233
+ function selectionHandler(docEl) {
1234
+ let btn = null;
1235
+ const remove = () => {
1236
+ btn?.remove();
1237
+ btn = null;
1238
+ };
1239
+ document.addEventListener("selectionchange", () => {
1240
+ const sel = window.getSelection();
1241
+ if (!sel || sel.isCollapsed || !sel.rangeCount) return remove();
1242
+ const range = sel.getRangeAt(0);
1243
+ const block = (range.commonAncestorContainer instanceof Element ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement)?.closest(".block");
1244
+ if (!block || !docEl.contains(block) || state.viewingRevision !== null) return remove();
1245
+ const quote = sel.toString().trim();
1246
+ if (!quote) return remove();
1247
+ const rect = range.getBoundingClientRect();
1248
+ remove();
1249
+ btn = h(
1250
+ "button",
1251
+ {
1252
+ class: "small primary selection-btn",
1253
+ onmousedown: (e) => e.preventDefault(),
1254
+ onclick: (e) => {
1255
+ const id = block.dataset["block"];
1256
+ remove();
1257
+ popover(commentPopover({ type: "document", blockId: id }, quote), {
1258
+ x: e.pageX,
1259
+ y: e.pageY + 8
1260
+ });
1261
+ window.getSelection()?.removeAllRanges();
1262
+ }
1263
+ },
1264
+ "Comment / Ask"
1265
+ );
1266
+ btn.style.left = `${rect.left + window.scrollX}px`;
1267
+ btn.style.top = `${rect.bottom + window.scrollY + 6}px`;
1268
+ document.body.appendChild(btn);
1269
+ });
1270
+ }
1271
+
1272
+ // src/ui/views/files.ts
1273
+ init_dom();
1274
+ function renderFiles(root) {
1275
+ const changes = state.data?.changes ?? [];
1276
+ const path = state.params.get("path") ?? changes[0]?.path ?? "";
1277
+ const list = h("div", { class: "file-list" });
1278
+ for (const c of changes) {
1279
+ const n = (state.data?.threads ?? []).filter(
1280
+ (t) => t.target.type === "file" && t.target.path === c.path && t.status === "open"
1281
+ ).length;
1282
+ list.appendChild(
1283
+ h(
1284
+ "div",
1285
+ {
1286
+ class: `item ${c.path === path ? "active" : ""}`,
1287
+ onclick: () => navigate("files", { path: c.path })
1288
+ },
1289
+ h(
1290
+ "span",
1291
+ {
1292
+ class: `badge ${c.status === "A" ? "ok" : c.status === "D" ? "del" : ""}`,
1293
+ style: { minWidth: "20px", textAlign: "center" }
1294
+ },
1295
+ c.status
1296
+ ),
1297
+ h("span", { class: "name", title: c.path }, c.path),
1298
+ n ? h("span", { class: "badge accent" }, String(n)) : null,
1299
+ h(
1300
+ "span",
1301
+ { class: "stat" },
1302
+ h("span", { class: "a" }, `+${c.additions}`),
1303
+ " ",
1304
+ h("span", { class: "d" }, `\u2212${c.deletions}`)
1305
+ )
1306
+ )
1307
+ );
1308
+ }
1309
+ if (!changes.length)
1310
+ list.appendChild(
1311
+ h("div", { class: "empty-state" }, "No changed files: base and head are the same commit.")
1312
+ );
1313
+ const view = h("div", { class: "file-view", "data-path": path });
1314
+ root.appendChild(h("div", { class: "files-layout" }, list, view));
1315
+ if (path) void renderDiff(view, path);
1316
+ }
1317
+ async function renderDiff(view, path) {
1318
+ const inChanges = state.data?.changes.some((c) => c.path === path);
1319
+ const line = Number(state.params.get("line") ?? 0);
1320
+ const side2 = state.params.get("side") === "base" ? "base" : "head";
1321
+ view.appendChild(h("div", { class: "muted", style: { padding: "12px" } }, "Loading\u2026"));
1322
+ let d;
1323
+ try {
1324
+ if (inChanges) d = await api.diff(state.id, path);
1325
+ else {
1326
+ const f = await api.file(state.id, path, side2);
1327
+ d = {
1328
+ path,
1329
+ lang: f.lang,
1330
+ binary: false,
1331
+ oldTotal: 0,
1332
+ newTotal: f.total,
1333
+ hunks: [
1334
+ {
1335
+ oldStart: 1,
1336
+ oldLines: 0,
1337
+ newStart: 1,
1338
+ newLines: f.total,
1339
+ rows: f.lines.map((html, i) => ({
1340
+ type: "context",
1341
+ newLine: i + 1,
1342
+ oldLine: i + 1,
1343
+ html
1344
+ }))
1345
+ }
1346
+ ]
1347
+ };
1348
+ }
1349
+ } catch (e) {
1350
+ view.innerHTML = "";
1351
+ view.appendChild(h("div", { class: "empty-state" }, e.message));
1352
+ return;
1353
+ }
1354
+ view.innerHTML = "";
1355
+ const toggle = h(
1356
+ "button",
1357
+ {
1358
+ class: "small",
1359
+ onclick: () => {
1360
+ state.splitDiff = !state.splitDiff;
1361
+ localStorage.setItem("thurview.split", state.splitDiff ? "1" : "0");
1362
+ view.innerHTML = "";
1363
+ void renderDiff(view, path);
1364
+ }
1365
+ },
1366
+ state.splitDiff ? "Unified" : "Split"
1367
+ );
1368
+ const fileThreads = (state.data?.threads ?? []).filter(
1369
+ (t) => t.target.type === "file" && t.target.path === path && t.target.line === 0
1370
+ );
1371
+ view.appendChild(
1372
+ h(
1373
+ "div",
1374
+ { class: "file-head" },
1375
+ h("span", { class: "path" }, d.oldPath ? `${d.oldPath} \u2192 ${path}` : path),
1376
+ h(
1377
+ "span",
1378
+ { class: "muted" },
1379
+ `${d.hunks.reduce((n, hk) => n + hk.rows.filter((r) => r.type === "add").length, 0)} added \xB7 ${d.hunks.reduce((n, hk) => n + hk.rows.filter((r) => r.type === "del").length, 0)} deleted`
1380
+ ),
1381
+ h(
1382
+ "span",
1383
+ { class: "muted hint" },
1384
+ "click a line number to comment \xB7 shift-click or select text for a range"
1385
+ ),
1386
+ state.viewingRevision === null ? h(
1387
+ "button",
1388
+ {
1389
+ class: "small",
1390
+ onclick: (e) => startLineComment(e, { type: "file", path, side: "head", line: 0 })
1391
+ },
1392
+ fileThreads.length ? `File comments (${fileThreads.length})` : "Comment on file"
1393
+ ) : null,
1394
+ inChanges ? toggle : h("span", { class: "badge" }, `full file at ${side2}`)
1395
+ )
1396
+ );
1397
+ if (fileThreads.length)
1398
+ view.appendChild(
1399
+ h(
1400
+ "div",
1401
+ { class: "thread-pins", style: { margin: "8px 14px" } },
1402
+ fileThreads.map(threadPinRow)
1403
+ )
1404
+ );
1405
+ if (d.binary) {
1406
+ view.appendChild(h("div", { class: "empty-state" }, "Binary file"));
1407
+ return;
1408
+ }
1409
+ const threads = (state.data?.threads ?? []).filter(
1410
+ (t) => t.target.type === "file" && t.target.path === path
1411
+ );
1412
+ const el = state.splitDiff && inChanges ? splitView(d, threads) : unifiedView(d, threads);
1413
+ view.appendChild(el);
1414
+ codeSelectionHandler(view);
1415
+ if (line) {
1416
+ const tr = el.querySelector(`tr[data-${side2}="${line}"]`);
1417
+ if (tr) {
1418
+ tr.classList.add("hl");
1419
+ tr.scrollIntoView({ block: "center" });
1420
+ }
1421
+ }
1422
+ }
1423
+ function lineCell(path, side2, n, _text) {
1424
+ return h(
1425
+ "td",
1426
+ {
1427
+ class: "ln",
1428
+ title: n ? "Comment on this line (shift-click for a range)" : "",
1429
+ onclick: (e) => {
1430
+ const table = e.currentTarget.closest("table");
1431
+ if (n && table) lineClick(e, path, side2, n, table);
1432
+ }
1433
+ },
1434
+ n ? String(n) : ""
1435
+ );
1436
+ }
1437
+ function threadRows(threads, side2, n, colspan) {
1438
+ if (!n) return [];
1439
+ return threads.filter((t) => t.target.side === side2 && (t.target.endLine ?? t.target.line) === n).map((t) => h("tr", { class: "thread-row" }, h("td", { colspan }, threadPinRow(t))));
1440
+ }
1441
+ function gapRow(d, from, to, colspan, onExpand) {
1442
+ if (to < from) return null;
1443
+ const count = to - from + 1;
1444
+ return h(
1445
+ "tr",
1446
+ { class: "gap" },
1447
+ h(
1448
+ "td",
1449
+ {
1450
+ colspan,
1451
+ onclick: async () => {
1452
+ const f = await api.file(state.id, d.path, "head", from, to);
1453
+ onExpand(
1454
+ f.lines.map((html, i) => ({
1455
+ type: "context",
1456
+ newLine: from + i,
1457
+ oldLine: from + i - (gapOffset(d, from) ?? 0),
1458
+ html
1459
+ }))
1460
+ );
1461
+ }
1462
+ },
1463
+ `\u22EF ${count} unchanged line${count > 1 ? "s" : ""}`
1464
+ )
1465
+ );
1466
+ }
1467
+ function gapOffset(d, newLine) {
1468
+ let off = 0;
1469
+ for (const hk of d.hunks) {
1470
+ if (hk.newStart > newLine) break;
1471
+ off = hk.newStart + hk.newLines - (hk.oldStart + hk.oldLines);
1472
+ }
1473
+ return off;
1474
+ }
1475
+ function unifiedView(d, threads) {
1476
+ const table = h("table");
1477
+ const text = (html) => {
1478
+ const x = document.createElement("div");
1479
+ x.innerHTML = html;
1480
+ return x.textContent ?? "";
1481
+ };
1482
+ const rowEl = (r) => {
1483
+ const tr = h(
1484
+ "tr",
1485
+ { class: r.type, "data-head": r.newLine ?? "", "data-base": r.oldLine ?? "" },
1486
+ lineCell(d.path, "base", r.oldLine, text(r.html)),
1487
+ lineCell(d.path, "head", r.newLine, text(r.html)),
1488
+ h("td", {
1489
+ class: "src",
1490
+ html: (r.type === "add" ? "+" : r.type === "del" ? "-" : " ") + (r.html || " ")
1491
+ })
1492
+ );
1493
+ return [
1494
+ tr,
1495
+ ...threadRows(
1496
+ threads,
1497
+ r.type === "del" ? "base" : "head",
1498
+ r.type === "del" ? r.oldLine : r.newLine,
1499
+ 3
1500
+ )
1501
+ ];
1502
+ };
1503
+ let prevEnd = 0;
1504
+ d.hunks.forEach((hk, i) => {
1505
+ const gap = gapRow(d, prevEnd + 1, hk.newStart - 1, 3, (rows) => {
1506
+ const g = table.querySelector(`tr.gap[data-i="${i}"]`);
1507
+ if (!g) return;
1508
+ rows.flatMap(rowEl).forEach((r) => table.insertBefore(r, g));
1509
+ g.remove();
1510
+ });
1511
+ if (gap) {
1512
+ gap.dataset["i"] = String(i);
1513
+ table.appendChild(gap);
1514
+ }
1515
+ for (const r of hk.rows) rowEl(r).forEach((x) => table.appendChild(x));
1516
+ prevEnd = hk.newStart + hk.newLines - 1;
1517
+ });
1518
+ if (d.newTotal > prevEnd) {
1519
+ const gap = gapRow(d, prevEnd + 1, d.newTotal, 3, (rows) => {
1520
+ const g = table.querySelector("tr.gap.tail");
1521
+ if (!g) return;
1522
+ rows.flatMap(rowEl).forEach((r) => table.insertBefore(r, g));
1523
+ g.remove();
1524
+ });
1525
+ if (gap) {
1526
+ gap.classList.add("tail");
1527
+ table.appendChild(gap);
1528
+ }
1529
+ }
1530
+ const wrap = h("div", { class: "code" }, table);
1531
+ attachDefinitions(wrap, "head");
1532
+ return wrap;
1533
+ }
1534
+ function splitView(d, threads) {
1535
+ const left = h("table");
1536
+ const right = h("table");
1537
+ const text = (html) => {
1538
+ const x = document.createElement("div");
1539
+ x.innerHTML = html;
1540
+ return x.textContent ?? "";
1541
+ };
1542
+ const pair = (l2, r2) => {
1543
+ const ltr = l2 ? h(
1544
+ "tr",
1545
+ { class: l2.type === "del" ? "del" : "", "data-base": l2.oldLine ?? "" },
1546
+ lineCell(d.path, "base", l2.oldLine, text(l2.html)),
1547
+ h("td", { class: "src", html: l2.html || " " })
1548
+ ) : h("tr", null, h("td", { class: "ln empty" }), h("td", { class: "src empty" }, " "));
1549
+ const rtr = r2 ? h(
1550
+ "tr",
1551
+ { class: r2.type === "add" ? "add" : "", "data-head": r2.newLine ?? "" },
1552
+ lineCell(d.path, "head", r2.newLine, text(r2.html)),
1553
+ h("td", { class: "src", html: r2.html || " " })
1554
+ ) : h("tr", null, h("td", { class: "ln empty" }), h("td", { class: "src empty" }, " "));
1555
+ left.appendChild(ltr);
1556
+ right.appendChild(rtr);
1557
+ const lt = threadRows(threads, "base", l2?.oldLine, 2);
1558
+ const rt = threadRows(threads, "head", r2?.newLine, 2);
1559
+ const n = Math.max(lt.length, rt.length);
1560
+ for (let i = 0; i < n; i++) {
1561
+ left.appendChild(lt[i] ?? h("tr", { class: "thread-row" }, h("td", { colspan: 2 }, " ")));
1562
+ right.appendChild(rt[i] ?? h("tr", { class: "thread-row" }, h("td", { colspan: 2 }, " ")));
1563
+ }
1564
+ };
1565
+ const emitRows = (rows) => {
1566
+ let i = 0;
1567
+ while (i < rows.length) {
1568
+ const r2 = rows[i];
1569
+ if (r2.type === "context") {
1570
+ pair(r2, r2);
1571
+ i++;
1572
+ continue;
1573
+ }
1574
+ const dels = [];
1575
+ const adds = [];
1576
+ while (i < rows.length && rows[i].type === "del") dels.push(rows[i++]);
1577
+ while (i < rows.length && rows[i].type === "add") adds.push(rows[i++]);
1578
+ const n = Math.max(dels.length, adds.length);
1579
+ for (let k = 0; k < n; k++) pair(dels[k] ?? null, adds[k] ?? null);
1580
+ }
1581
+ };
1582
+ let prevEnd = 0;
1583
+ const gapBoth = (from, to) => {
1584
+ if (to < from) return;
1585
+ const count = to - from + 1;
1586
+ const lg = h("tr", { class: "gap" }, h("td", { colspan: 2 }, `\u22EF ${count} unchanged`));
1587
+ const rg = h("tr", { class: "gap" }, h("td", { colspan: 2 }, `\u22EF ${count} unchanged`));
1588
+ const expand = async () => {
1589
+ const f = await api.file(state.id, d.path, "head", from, to);
1590
+ const off = gapOffset(d, from) ?? 0;
1591
+ const rows = f.lines.map((html, k) => ({
1592
+ type: "context",
1593
+ newLine: from + k,
1594
+ oldLine: from + k - off,
1595
+ html
1596
+ }));
1597
+ const lAnchor = lg.nextSibling;
1598
+ const rAnchor = rg.nextSibling;
1599
+ lg.remove();
1600
+ rg.remove();
1601
+ const tmpL = left;
1602
+ const tmpR = right;
1603
+ const fragL = document.createDocumentFragment();
1604
+ const fragR = document.createDocumentFragment();
1605
+ for (const r2 of rows) {
1606
+ fragL.appendChild(
1607
+ h(
1608
+ "tr",
1609
+ { "data-base": r2.oldLine ?? "" },
1610
+ lineCell(d.path, "base", r2.oldLine, text(r2.html)),
1611
+ h("td", { class: "src", html: r2.html || " " })
1612
+ )
1613
+ );
1614
+ fragR.appendChild(
1615
+ h(
1616
+ "tr",
1617
+ { "data-head": r2.newLine ?? "" },
1618
+ lineCell(d.path, "head", r2.newLine, text(r2.html)),
1619
+ h("td", { class: "src", html: r2.html || " " })
1620
+ )
1621
+ );
1622
+ }
1623
+ tmpL.insertBefore(fragL, lAnchor);
1624
+ tmpR.insertBefore(fragR, rAnchor);
1625
+ };
1626
+ lg.addEventListener("click", expand);
1627
+ rg.addEventListener("click", expand);
1628
+ left.appendChild(lg);
1629
+ right.appendChild(rg);
1630
+ };
1631
+ for (const hk of d.hunks) {
1632
+ gapBoth(prevEnd + 1, hk.newStart - 1);
1633
+ emitRows(hk.rows);
1634
+ prevEnd = hk.newStart + hk.newLines - 1;
1635
+ }
1636
+ gapBoth(prevEnd + 1, d.newTotal);
1637
+ const l = h("div", { class: "code" }, left);
1638
+ const r = h("div", { class: "code" }, right);
1639
+ attachDefinitions(l, "base");
1640
+ attachDefinitions(r, "head");
1641
+ l.addEventListener("scroll", () => r.scrollLeft = l.scrollLeft);
1642
+ r.addEventListener("scroll", () => l.scrollLeft = r.scrollLeft);
1643
+ return h("div", { class: "split" }, l, r);
1644
+ }
1645
+
1646
+ // src/ui/views/commits.ts
1647
+ init_dom();
1648
+ async function renderCommits(root) {
1649
+ const el = h("div", { class: "commits" }, h("div", { class: "muted" }, "Loading\u2026"));
1650
+ root.appendChild(el);
1651
+ const commits = await api.commits(state.id);
1652
+ el.innerHTML = "";
1653
+ const r = state.data.review;
1654
+ el.appendChild(
1655
+ h(
1656
+ "p",
1657
+ { class: "muted" },
1658
+ `${commits.length} commit${commits.length === 1 ? "" : "s"} from `,
1659
+ h("code", null, r.pins.base.slice(0, 12)),
1660
+ " to ",
1661
+ h("code", null, r.pins.head.slice(0, 12))
1662
+ )
1663
+ );
1664
+ if (!commits.length)
1665
+ el.appendChild(h("div", { class: "empty-state" }, "No commits between base and head."));
1666
+ for (const c of commits) {
1667
+ el.appendChild(
1668
+ h(
1669
+ "div",
1670
+ { class: "commit" },
1671
+ h(
1672
+ "div",
1673
+ null,
1674
+ h("span", { class: "subject" }, c.subject),
1675
+ " ",
1676
+ h("code", null, c.shortSha),
1677
+ " ",
1678
+ h("span", { class: "muted" }, `${c.author} \xB7 ${new Date(c.date).toLocaleString()}`)
1679
+ ),
1680
+ c.body ? h("pre", null, c.body) : null,
1681
+ h(
1682
+ "div",
1683
+ { class: "files" },
1684
+ c.files.map((f) => h("span", { onclick: () => navigate("files", { path: f }) }, f))
1685
+ )
1686
+ )
1687
+ );
1688
+ }
1689
+ }
1690
+
1691
+ // src/ui/views/map.ts
1692
+ init_dom();
1693
+ function renderMap(root) {
1694
+ const map = state.data?.map;
1695
+ if (!map) {
1696
+ root.appendChild(
1697
+ h(
1698
+ "div",
1699
+ { class: "empty-state" },
1700
+ "No software map in this revision. The agent authors ",
1701
+ h("code", null, "map.yaml"),
1702
+ " and publishes again."
1703
+ )
1704
+ );
1705
+ return;
1706
+ }
1707
+ const headNodes = new Map(map.head.nodes.map((n) => [n.id, n]));
1708
+ const baseNodes = new Map((map.base?.nodes ?? []).map((n) => [n.id, n]));
1709
+ const all = /* @__PURE__ */ new Map();
1710
+ for (const n of map.head.nodes)
1711
+ all.set(n.id, {
1712
+ ...n,
1713
+ status: map.diff.added.includes(n.id) ? "added" : map.diff.changed.includes(n.id) ? "changed" : ""
1714
+ });
1715
+ for (const id of map.diff.removed) {
1716
+ const n = baseNodes.get(id);
1717
+ if (n) all.set(id, { ...n, status: "removed" });
1718
+ }
1719
+ const edges = [...map.head.edges];
1720
+ for (const e of map.base?.edges ?? [])
1721
+ if (!edges.some((x) => x.from === e.from && x.to === e.to)) edges.push(e);
1722
+ const selected = state.params.get("node") ?? "";
1723
+ const parentOf = (id) => id.includes(".") ? id.slice(0, id.lastIndexOf(".")) : "";
1724
+ let level = selected && all.has(selected) ? childrenOf(selected).length ? selected : parentOf(selected) : "";
1725
+ function childrenOf(p) {
1726
+ return [...all.values()].filter((n) => parentOf(n.id) === p);
1727
+ }
1728
+ function descendantsChanged(id) {
1729
+ return [...all.values()].filter((n) => n.id.startsWith(id + ".") && n.status).length;
1730
+ }
1731
+ const canvas = h("div", { class: "map-canvas" });
1732
+ const side2 = h("div", { class: "map-side" });
1733
+ root.appendChild(h("div", { class: "map-layout" }, canvas, side2));
1734
+ const draw = () => {
1735
+ canvas.innerHTML = "";
1736
+ const crumbs = h("div", { class: "crumbs" });
1737
+ const parts = level ? level.split(".") : [];
1738
+ crumbs.appendChild(
1739
+ h(
1740
+ "span",
1741
+ {
1742
+ onclick: () => {
1743
+ level = "";
1744
+ draw();
1745
+ }
1746
+ },
1747
+ "System"
1748
+ )
1749
+ );
1750
+ parts.forEach((_, i) => {
1751
+ const id = parts.slice(0, i + 1).join(".");
1752
+ crumbs.appendChild(document.createTextNode(" \u203A "));
1753
+ crumbs.appendChild(
1754
+ h(
1755
+ "span",
1756
+ {
1757
+ onclick: () => {
1758
+ level = id;
1759
+ draw();
1760
+ }
1761
+ },
1762
+ all.get(id)?.label ?? id
1763
+ )
1764
+ );
1765
+ });
1766
+ canvas.appendChild(crumbs);
1767
+ const kids = childrenOf(level);
1768
+ const legend = h(
1769
+ "div",
1770
+ { class: "muted", style: { fontSize: "12px", marginBottom: "10px" } },
1771
+ h("span", { class: "badge ok" }, `${map.diff.added.length} added`),
1772
+ " ",
1773
+ h("span", { class: "badge del" }, `${map.diff.removed.length} removed`),
1774
+ " ",
1775
+ h("span", { class: "badge warn" }, `${map.diff.changed.length} changed`),
1776
+ map.base ? "" : " (no base map: changed = touched by the diff)"
1777
+ );
1778
+ canvas.appendChild(legend);
1779
+ const grid = h("div", { class: "map-grid" });
1780
+ for (const n of kids) {
1781
+ const nk = childrenOf(n.id).length;
1782
+ const files = map.filesByNode[n.id]?.length ?? 0;
1783
+ grid.appendChild(
1784
+ h(
1785
+ "div",
1786
+ {
1787
+ class: `map-node ${n.status} ${n.id === selected ? "selected" : ""}`,
1788
+ onclick: () => {
1789
+ navigate("map", { node: n.id });
1790
+ },
1791
+ ondblclick: () => {
1792
+ if (nk) {
1793
+ level = n.id;
1794
+ draw();
1795
+ }
1796
+ }
1797
+ },
1798
+ n.status ? h(
1799
+ "span",
1800
+ {
1801
+ class: `status badge ${n.status === "added" ? "ok" : n.status === "removed" ? "del" : "warn"}`
1802
+ },
1803
+ n.status
1804
+ ) : null,
1805
+ h("div", { class: "kind" }, n.kind),
1806
+ h("div", { class: "label" }, n.label),
1807
+ n.description ? h("div", { class: "desc" }, n.description) : null,
1808
+ h(
1809
+ "div",
1810
+ { class: "children" },
1811
+ nk ? `${nk} inside \xB7 dbl-click to open` : "",
1812
+ nk && descendantsChanged(n.id) ? ` \xB7 ${descendantsChanged(n.id)} changed` : "",
1813
+ files ? ` \xB7 ${files} changed file${files > 1 ? "s" : ""}` : ""
1814
+ )
1815
+ )
1816
+ );
1817
+ }
1818
+ if (!kids.length)
1819
+ grid.appendChild(h("div", { class: "empty-state" }, "Nothing at this level."));
1820
+ canvas.appendChild(grid);
1821
+ const here = new Set(kids.map((k) => k.id));
1822
+ const top = (id) => {
1823
+ let x = id;
1824
+ while (x && !here.has(x)) x = parentOf(x);
1825
+ return x;
1826
+ };
1827
+ const shown = edges.filter((e) => here.has(top(e.from)) || here.has(top(e.to)));
1828
+ if (shown.length) {
1829
+ canvas.appendChild(
1830
+ h(
1831
+ "div",
1832
+ { class: "map-edges" },
1833
+ h("div", { class: "muted", style: { marginBottom: "4px" } }, "Relationships"),
1834
+ shown.map(
1835
+ (e) => h(
1836
+ "div",
1837
+ { class: "edge" },
1838
+ h(
1839
+ "b",
1840
+ { onclick: () => navigate("map", { node: e.from }) },
1841
+ all.get(e.from)?.label ?? e.from
1842
+ ),
1843
+ "\u2192",
1844
+ h(
1845
+ "b",
1846
+ { onclick: () => navigate("map", { node: e.to }) },
1847
+ all.get(e.to)?.label ?? e.to
1848
+ ),
1849
+ h("span", { class: "muted" }, e.label ?? "")
1850
+ )
1851
+ )
1852
+ )
1853
+ );
1854
+ }
1855
+ drawSide();
1856
+ };
1857
+ const drawSide = () => {
1858
+ side2.innerHTML = "";
1859
+ const n = selected ? all.get(selected) : null;
1860
+ if (!n) {
1861
+ side2.appendChild(
1862
+ h(
1863
+ "div",
1864
+ { class: "muted" },
1865
+ "Select a node to see its files, code and relationships. Double-click to drill in."
1866
+ )
1867
+ );
1868
+ return;
1869
+ }
1870
+ const anchor = n.anchor ? state.data?.document?.anchors[n.anchor] : null;
1871
+ const files = map.filesByNode[n.id] ?? [];
1872
+ const threads = threadsFor((t) => t.type === "map" && t.node === n.id);
1873
+ append(side2, [
1874
+ h(
1875
+ "div",
1876
+ { class: "kind muted", style: { fontSize: "11px", textTransform: "uppercase" } },
1877
+ n.kind,
1878
+ " ",
1879
+ n.status ? h(
1880
+ "span",
1881
+ {
1882
+ class: `badge ${n.status === "added" ? "ok" : n.status === "removed" ? "del" : "warn"}`
1883
+ },
1884
+ n.status
1885
+ ) : ""
1886
+ ),
1887
+ h("h3", { style: { margin: "2px 0 6px" } }, n.label),
1888
+ h("div", { class: "mono muted", style: { fontSize: "11px" } }, n.id),
1889
+ n.description ? h("p", null, n.description) : null,
1890
+ anchor?.peek ? h(
1891
+ "p",
1892
+ null,
1893
+ h("button", { class: "small", onclick: () => openAnchorPeek(anchor) }, "Peek code \u25B8"),
1894
+ " ",
1895
+ h("span", { class: "mono muted" }, `${anchor.peek.file}:${anchor.peek.from}`)
1896
+ ) : null,
1897
+ n.files?.length ? h(
1898
+ "div",
1899
+ { class: "muted", style: { marginTop: "8px" } },
1900
+ "Owns: ",
1901
+ n.files.map((g) => h("code", null, g + " "))
1902
+ ) : null,
1903
+ files.length ? h(
1904
+ "div",
1905
+ { style: { marginTop: "8px" } },
1906
+ h("b", null, "Changed files"),
1907
+ files.map(
1908
+ (f) => h("span", { class: "file", onclick: () => navigate("files", { path: f }) }, f)
1909
+ )
1910
+ ) : null,
1911
+ h(
1912
+ "div",
1913
+ { style: { marginTop: "12px" } },
1914
+ h(
1915
+ "button",
1916
+ {
1917
+ class: "small",
1918
+ onclick: (e) => popover(commentPopover({ type: "map", node: n.id }), { x: e.pageX, y: e.pageY + 8 })
1919
+ },
1920
+ "Comment on this node"
1921
+ )
1922
+ ),
1923
+ threads.length ? h(
1924
+ "div",
1925
+ { class: "thread-pins", style: { marginTop: "10px" } },
1926
+ threads.map(threadPinRow)
1927
+ ) : null
1928
+ ]);
1929
+ if (n.status === "changed" && baseNodes.has(n.id) && headNodes.has(n.id)) {
1930
+ const b = baseNodes.get(n.id);
1931
+ const hh = headNodes.get(n.id);
1932
+ const diffs = [];
1933
+ if (b.label !== hh.label) diffs.push(`label: ${b.label} \u2192 ${hh.label}`);
1934
+ if (b.description !== hh.description) diffs.push(`description changed`);
1935
+ if (JSON.stringify(b.files) !== JSON.stringify(hh.files))
1936
+ diffs.push(`files: ${(b.files ?? []).join(", ")} \u2192 ${(hh.files ?? []).join(", ")}`);
1937
+ if (diffs.length)
1938
+ side2.appendChild(
1939
+ h(
1940
+ "div",
1941
+ { style: { marginTop: "10px" } },
1942
+ h("b", null, "Base \u2192 head"),
1943
+ diffs.map((d) => h("div", { class: "muted" }, d))
1944
+ )
1945
+ );
1946
+ }
1947
+ };
1948
+ draw();
1949
+ }
1950
+
1951
+ // src/ui/app.ts
1952
+ var app = document.getElementById("app");
1953
+ async function home() {
1954
+ clear(app);
1955
+ const reviews = await api.reviews();
1956
+ const el = h("div", { class: "home" }, h("h2", null, "Reviews"));
1957
+ const active = reviews.filter((r) => !r.dismissed);
1958
+ const dismissed = reviews.filter((r) => r.dismissed);
1959
+ const item = (r) => h(
1960
+ "div",
1961
+ { class: "item", onclick: () => location.href = `/review/${r.id}` },
1962
+ h("span", { class: "t" }, r.title),
1963
+ h("span", { class: `badge ${statusClass(r.status)}` }, r.status),
1964
+ r.openThreads ? h("span", { class: "badge accent" }, `${r.openThreads} open`) : null,
1965
+ h(
1966
+ "span",
1967
+ { class: "muted mono", style: { fontSize: "12px" } },
1968
+ r.binding.kind === "pr" ? `PR #${r.binding.name}` : r.binding.name
1969
+ ),
1970
+ h("span", { class: "muted", style: { fontSize: "12px" } }, timeAgo(r.updatedAt))
1971
+ );
1972
+ if (!active.length)
1973
+ el.appendChild(
1974
+ h("div", { class: "empty-state" }, "No reviews. Ask your agent to scaffold and publish one.")
1975
+ );
1976
+ active.forEach((r) => el.appendChild(item(r)));
1977
+ if (dismissed.length) {
1978
+ el.appendChild(h("h3", { class: "muted" }, "Dismissed"));
1979
+ dismissed.forEach((r) => el.appendChild(item(r)));
1980
+ }
1981
+ app.appendChild(el);
1982
+ }
1983
+ function statusClass(s) {
1984
+ return s === "accepted" ? "ok" : s === "awaiting-agent-updates" ? "warn" : s === "rejected" ? "del" : s === "awaiting-review" ? "accent" : "";
1985
+ }
1986
+ var center;
1987
+ var side;
1988
+ var topbar;
1989
+ var banner;
1990
+ function shell() {
1991
+ clear(app);
1992
+ topbar = h("div", { class: "topbar" });
1993
+ banner = h("div", { class: "banner", hidden: true });
1994
+ center = h("div", { class: "center" });
1995
+ side = h("div", { class: "side hidden" });
1996
+ const resizer = h("div", { class: "resizer" });
1997
+ let drag = false;
1998
+ resizer.addEventListener("mousedown", () => drag = true);
1999
+ window.addEventListener("mouseup", () => drag = false);
2000
+ window.addEventListener("mousemove", (e) => {
2001
+ if (!drag) return;
2002
+ const w = Math.max(320, window.innerWidth - e.clientX);
2003
+ document.documentElement.style.setProperty("--side-w", `${w}px`);
2004
+ });
2005
+ app.append(topbar, banner, h("div", { class: "main" }, center, resizer, side));
2006
+ }
2007
+ function applyTheme() {
2008
+ let style = document.getElementById("review-theme");
2009
+ if (!style) {
2010
+ style = document.createElement("style");
2011
+ style.id = "review-theme";
2012
+ document.head.appendChild(style);
2013
+ }
2014
+ style.textContent = state.data?.theme?.css ?? "";
2015
+ }
2016
+ function renderTopbar() {
2017
+ clear(topbar);
2018
+ const d = state.data;
2019
+ const r = d.review;
2020
+ const pending = d.threads.filter((t) => !t.submitted).length;
2021
+ const open = d.threads.filter((t) => t.status === "open").length;
2022
+ const tabs = [
2023
+ ["review", "Review"],
2024
+ ["commits", "Commits"],
2025
+ ["files", `Files${d.changes.length ? ` (${d.changes.length})` : ""}`],
2026
+ ["map", "Map"]
2027
+ ];
2028
+ const revSel = h("select", {
2029
+ class: "small",
2030
+ style: { font: "inherit", fontSize: "12px" },
2031
+ onchange: async (e) => {
2032
+ const n = Number(e.target.value);
2033
+ state.viewingRevision = n === r.revision ? null : n;
2034
+ state.data = await api.review(state.id, n);
2035
+ emit("data");
2036
+ }
2037
+ });
2038
+ for (let n = r.revision; n >= 1; n--)
2039
+ revSel.appendChild(
2040
+ h(
2041
+ "option",
2042
+ { value: n, selected: (state.viewingRevision ?? r.revision) === n },
2043
+ `rev ${n}${n === r.revision ? " (current)" : ""}`
2044
+ )
2045
+ );
2046
+ append(topbar, [
2047
+ h("a", { href: "/", class: "brand", title: "All reviews" }, "thurview"),
2048
+ h("span", { class: "title" }, r.title),
2049
+ h("span", { class: `badge ${statusClass(r.status)}` }, r.status),
2050
+ r.revision > 1 ? revSel : h("span", { class: "badge" }, `rev ${r.revision}`),
2051
+ h(
2052
+ "span",
2053
+ {
2054
+ class: "muted mono",
2055
+ style: { fontSize: "12px" },
2056
+ title: `${r.pins.base} \u2192 ${r.pins.head}`
2057
+ },
2058
+ r.binding.kind === "pr" ? `PR #${r.binding.name}` : r.binding.name
2059
+ ),
2060
+ h(
2061
+ "div",
2062
+ { class: "tabs" },
2063
+ tabs.map(
2064
+ ([v, label]) => h("button", { class: v === state.view ? "active" : "", onclick: () => navigate(v) }, label)
2065
+ )
2066
+ ),
2067
+ h("span", { class: "spacer" }),
2068
+ h(
2069
+ "button",
2070
+ {
2071
+ class: state.side.kind === "threads" ? "primary" : "",
2072
+ onclick: () => {
2073
+ state.side = state.side.kind === "threads" ? { kind: "none" } : { kind: "threads" };
2074
+ emit("side");
2075
+ }
2076
+ },
2077
+ `Threads${open ? ` \xB7 ${open}` : ""}`
2078
+ ),
2079
+ isTerminal() || state.viewingRevision !== null ? null : h(
2080
+ "button",
2081
+ { class: pending ? "primary" : "ok", onclick: () => submitDialog() },
2082
+ pending ? `Submit review (${pending})` : "Approve / Request changes"
2083
+ ),
2084
+ h("button", { class: "ghost", title: "More", onclick: (e) => moreMenu(e) }, "\u22EF")
2085
+ ]);
2086
+ }
2087
+ function moreMenu(e) {
2088
+ const r = state.data.review;
2089
+ const box = h(
2090
+ "div",
2091
+ { class: "def-popover" },
2092
+ h(
2093
+ "div",
2094
+ {
2095
+ class: "item",
2096
+ onclick: async () => {
2097
+ await api.dismiss(state.id, !r.dismissed);
2098
+ await reload();
2099
+ emit("data");
2100
+ }
2101
+ },
2102
+ r.dismissed ? "Restore review" : "Dismiss review"
2103
+ ),
2104
+ h(
2105
+ "div",
2106
+ {
2107
+ class: "item",
2108
+ onclick: () => {
2109
+ const d = dialog(
2110
+ h(
2111
+ "div",
2112
+ null,
2113
+ h("h3", null, "Delete this review?"),
2114
+ h(
2115
+ "p",
2116
+ { class: "muted" },
2117
+ "Removes the document, revisions and threads. The code is untouched."
2118
+ ),
2119
+ h(
2120
+ "div",
2121
+ { class: "row" },
2122
+ h("button", { class: "ghost", onclick: () => d.close() }, "Cancel"),
2123
+ h(
2124
+ "button",
2125
+ {
2126
+ style: { background: "var(--del)", color: "#fff" },
2127
+ onclick: async () => {
2128
+ await api.remove(state.id);
2129
+ location.href = "/";
2130
+ }
2131
+ },
2132
+ "Delete"
2133
+ )
2134
+ )
2135
+ )
2136
+ );
2137
+ }
2138
+ },
2139
+ "Delete review"
2140
+ ),
2141
+ h(
2142
+ "div",
2143
+ { class: "item muted" },
2144
+ `base ${r.pins.base.slice(0, 12)} \xB7 head ${r.pins.head.slice(0, 12)}`
2145
+ ),
2146
+ h(
2147
+ "div",
2148
+ { class: "item muted" },
2149
+ `theme: ${state.data.theme?.name ?? "default"}${state.data.theme?.source ? ` (${state.data.theme.source})` : ""}`
2150
+ )
2151
+ );
2152
+ Promise.resolve().then(() => (init_dom(), dom_exports)).then(({ popover: popover2 }) => popover2(box, { x: e.pageX - 200, y: e.pageY + 10 }));
2153
+ }
2154
+ function renderCenter() {
2155
+ const scroll = center.scrollTop;
2156
+ clear(center);
2157
+ switch (state.view) {
2158
+ case "review":
2159
+ renderReview(center);
2160
+ break;
2161
+ case "files":
2162
+ renderFiles(center);
2163
+ break;
2164
+ case "commits":
2165
+ void renderCommits(center);
2166
+ break;
2167
+ case "map":
2168
+ renderMap(center);
2169
+ break;
2170
+ }
2171
+ center.scrollTop = scroll;
2172
+ }
2173
+ function renderSide() {
2174
+ clear(side);
2175
+ side.classList.toggle("hidden", state.side.kind === "none");
2176
+ if (state.side.kind === "peek") renderPeek(side);
2177
+ else if (state.side.kind === "threads") renderThreadsPanel(side);
2178
+ }
2179
+ function renderBanner() {
2180
+ banner.hidden = true;
2181
+ clear(banner);
2182
+ const d = state.data;
2183
+ if (state.viewingRevision !== null) {
2184
+ banner.hidden = false;
2185
+ banner.append(
2186
+ `Viewing revision ${state.viewingRevision} of ${d.review.revision}. Read only.`,
2187
+ h("span", { style: { flex: "1" } }),
2188
+ h(
2189
+ "button",
2190
+ {
2191
+ class: "small",
2192
+ onclick: async () => {
2193
+ state.viewingRevision = null;
2194
+ state.data = await api.review(state.id);
2195
+ emit("data");
2196
+ }
2197
+ },
2198
+ "Back to current"
2199
+ )
2200
+ );
2201
+ } else if (state.latestRevision !== null && state.latestRevision > d.review.revision) {
2202
+ banner.hidden = false;
2203
+ banner.append(
2204
+ `Revision ${state.latestRevision} was published.`,
2205
+ h("span", { style: { flex: "1" } }),
2206
+ h(
2207
+ "button",
2208
+ {
2209
+ class: "small primary",
2210
+ onclick: async () => {
2211
+ state.latestRevision = null;
2212
+ state.data = await api.review(state.id);
2213
+ emit("data");
2214
+ }
2215
+ },
2216
+ "Load it"
2217
+ )
2218
+ );
2219
+ } else if (d.review.status === "awaiting-agent-updates") {
2220
+ banner.hidden = false;
2221
+ banner.append(
2222
+ "Changes requested. The agent is working on your comments; a new revision will appear here."
2223
+ );
2224
+ } else if (d.review.status === "accepted") {
2225
+ banner.hidden = false;
2226
+ banner.append("Approved. This review is complete.");
2227
+ }
2228
+ }
2229
+ function connectEvents() {
2230
+ const es = new EventSource(`/api/reviews/${state.id}/events`);
2231
+ es.onmessage = async (ev) => {
2232
+ const msg = JSON.parse(ev.data);
2233
+ if (msg.type !== "change") return;
2234
+ const fresh = await api.review(state.id, state.viewingRevision ?? void 0);
2235
+ const cur = state.data;
2236
+ if (fresh.review.revision > cur.review.revision && state.viewingRevision === null) {
2237
+ state.latestRevision = fresh.review.revision;
2238
+ state.data = {
2239
+ ...cur,
2240
+ threads: fresh.threads,
2241
+ decisions: fresh.decisions,
2242
+ review: { ...fresh.review, revision: cur.review.revision }
2243
+ };
2244
+ } else {
2245
+ state.data = fresh;
2246
+ }
2247
+ emit("threads");
2248
+ };
2249
+ }
2250
+ async function reviewPage(id) {
2251
+ state.id = id;
2252
+ shell();
2253
+ readHash();
2254
+ try {
2255
+ state.data = await api.review(id);
2256
+ } catch (e) {
2257
+ clear(app);
2258
+ app.appendChild(
2259
+ h(
2260
+ "div",
2261
+ { class: "empty-state" },
2262
+ e.message,
2263
+ " ",
2264
+ h("a", { href: "/" }, "All reviews")
2265
+ )
2266
+ );
2267
+ return;
2268
+ }
2269
+ document.title = `${state.data.review.title} \xB7 thurview`;
2270
+ if (state.params.get("side") === "threads") state.side = { kind: "threads" };
2271
+ on("data", () => {
2272
+ applyTheme();
2273
+ renderTopbar();
2274
+ renderBanner();
2275
+ renderCenter();
2276
+ renderSide();
2277
+ });
2278
+ on("threads", () => {
2279
+ renderTopbar();
2280
+ renderBanner();
2281
+ renderCenter();
2282
+ renderSide();
2283
+ });
2284
+ on("view", () => {
2285
+ renderTopbar();
2286
+ renderCenter();
2287
+ });
2288
+ on("side", () => {
2289
+ renderTopbar();
2290
+ renderSide();
2291
+ });
2292
+ window.addEventListener("hashchange", () => {
2293
+ readHash();
2294
+ emit("view");
2295
+ });
2296
+ emit("data");
2297
+ connectEvents();
2298
+ }
2299
+ var m = /^\/review\/([^/]+)/.exec(location.pathname);
2300
+ if (m) void reviewPage(m[1]);
2301
+ else void home();
2302
+ })();
2303
+ //# sourceMappingURL=app.js.map