semantic-code-review 0.24.2__py3-none-any.whl

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 (100) hide show
  1. semantic_code_review/__init__.py +5 -0
  2. semantic_code_review/augment/__init__.py +3 -0
  3. semantic_code_review/augment/agents.py +95 -0
  4. semantic_code_review/augment/console.py +440 -0
  5. semantic_code_review/augment/extra_review.py +237 -0
  6. semantic_code_review/augment/fold_summary.py +447 -0
  7. semantic_code_review/augment/hunks.py +264 -0
  8. semantic_code_review/augment/mcp_server.py +156 -0
  9. semantic_code_review/augment/overview.py +233 -0
  10. semantic_code_review/augment/pass_.py +171 -0
  11. semantic_code_review/augment/pipeline.py +571 -0
  12. semantic_code_review/augment/progress.py +223 -0
  13. semantic_code_review/augment/prompts.py +93 -0
  14. semantic_code_review/augment/schemas.py +408 -0
  15. semantic_code_review/augment/tools.py +475 -0
  16. semantic_code_review/augment/trace_adapter.py +369 -0
  17. semantic_code_review/backends/__init__.py +95 -0
  18. semantic_code_review/backends/_cli_driver.py +571 -0
  19. semantic_code_review/backends/anthropic_sdk.py +56 -0
  20. semantic_code_review/backends/base.py +104 -0
  21. semantic_code_review/backends/claude_cli.py +421 -0
  22. semantic_code_review/backends/google_sdk.py +73 -0
  23. semantic_code_review/backends/openai_compat.py +42 -0
  24. semantic_code_review/cache/__init__.py +3 -0
  25. semantic_code_review/cache/store.py +87 -0
  26. semantic_code_review/cli/__init__.py +70 -0
  27. semantic_code_review/cli/_shared.py +157 -0
  28. semantic_code_review/cli/augment.py +74 -0
  29. semantic_code_review/cli/config_cmd.py +266 -0
  30. semantic_code_review/cli/fetch.py +33 -0
  31. semantic_code_review/cli/lint.py +27 -0
  32. semantic_code_review/cli/pr.py +98 -0
  33. semantic_code_review/cli/review.py +104 -0
  34. semantic_code_review/cli/runs_cmd.py +17 -0
  35. semantic_code_review/cli/show.py +19 -0
  36. semantic_code_review/cli/strip.py +20 -0
  37. semantic_code_review/config.py +563 -0
  38. semantic_code_review/config_template.py +154 -0
  39. semantic_code_review/fetch/__init__.py +61 -0
  40. semantic_code_review/fetch/anchor.py +244 -0
  41. semantic_code_review/fetch/github.py +229 -0
  42. semantic_code_review/fetch/github_comments.py +404 -0
  43. semantic_code_review/fetch/local.py +443 -0
  44. semantic_code_review/fetch/run_source.py +70 -0
  45. semantic_code_review/format/__init__.py +3 -0
  46. semantic_code_review/format/emit.py +163 -0
  47. semantic_code_review/format/lint.py +74 -0
  48. semantic_code_review/format/parse.py +574 -0
  49. semantic_code_review/format/sidecar.py +19 -0
  50. semantic_code_review/format/strip.py +20 -0
  51. semantic_code_review/git_ops.py +252 -0
  52. semantic_code_review/paths.py +61 -0
  53. semantic_code_review/review/__init__.py +3 -0
  54. semantic_code_review/review/comments.py +176 -0
  55. semantic_code_review/review/github.py +284 -0
  56. semantic_code_review/review/github_graphql.py +433 -0
  57. semantic_code_review/review/pr_flow.py +299 -0
  58. semantic_code_review/review/runner.py +407 -0
  59. semantic_code_review/review/server.py +1043 -0
  60. semantic_code_review/structural/__init__.py +41 -0
  61. semantic_code_review/structural/diff.py +102 -0
  62. semantic_code_review/structural/parse.py +330 -0
  63. semantic_code_review/structural/symbols.py +43 -0
  64. semantic_code_review/viewer/__init__.py +3 -0
  65. semantic_code_review/viewer/assets/annotations.ts +547 -0
  66. semantic_code_review/viewer/assets/boot.ts +273 -0
  67. semantic_code_review/viewer/assets/comment_store.ts +121 -0
  68. semantic_code_review/viewer/assets/comments.ts +624 -0
  69. semantic_code_review/viewer/assets/console.ts +426 -0
  70. semantic_code_review/viewer/assets/console_render.ts +213 -0
  71. semantic_code_review/viewer/assets/console_selection.ts +102 -0
  72. semantic_code_review/viewer/assets/data_store.ts +156 -0
  73. semantic_code_review/viewer/assets/file_rows.ts +49 -0
  74. semantic_code_review/viewer/assets/folds.ts +560 -0
  75. semantic_code_review/viewer/assets/index.html +62 -0
  76. semantic_code_review/viewer/assets/post_modal.ts +383 -0
  77. semantic_code_review/viewer/assets/progress.ts +138 -0
  78. semantic_code_review/viewer/assets/render.ts +937 -0
  79. semantic_code_review/viewer/assets/sidebar.ts +429 -0
  80. semantic_code_review/viewer/assets/sse.ts +78 -0
  81. semantic_code_review/viewer/assets/text_highlight.ts +215 -0
  82. semantic_code_review/viewer/assets/types.d.ts +388 -0
  83. semantic_code_review/viewer/assets/vendor/LICENSE +29 -0
  84. semantic_code_review/viewer/assets/vendor/VENDOR.md +55 -0
  85. semantic_code_review/viewer/assets/vendor/github-dark.min.css +10 -0
  86. semantic_code_review/viewer/assets/vendor/github.min.css +10 -0
  87. semantic_code_review/viewer/assets/vendor/highlight.min.js +1244 -0
  88. semantic_code_review/viewer/assets/vendor/mermaid.LICENSE +21 -0
  89. semantic_code_review/viewer/assets/vendor/mermaid.min.js +3587 -0
  90. semantic_code_review/viewer/assets/vendor/refresh.sh +65 -0
  91. semantic_code_review/viewer/assets/viewer.css +1202 -0
  92. semantic_code_review/viewer/assets/viewer.js +10588 -0
  93. semantic_code_review/viewer/build_json.py +546 -0
  94. semantic_code_review/viewer/hunk_layout.py +471 -0
  95. semantic_code_review-0.24.2.dist-info/METADATA +348 -0
  96. semantic_code_review-0.24.2.dist-info/RECORD +100 -0
  97. semantic_code_review-0.24.2.dist-info/WHEEL +5 -0
  98. semantic_code_review-0.24.2.dist-info/entry_points.txt +2 -0
  99. semantic_code_review-0.24.2.dist-info/licenses/LICENSE +21 -0
  100. semantic_code_review-0.24.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,429 @@
1
+ // Sidebar / grouping axes.
2
+ //
3
+ // Owns the #group-sidebar DOM tree and the multi-axis state behind
4
+ // it: a Themes axis populated by the overview LLM pass (refreshed
5
+ // in place when the `overview` SSE event arrives) and a Files axis
6
+ // derived deterministically from DATA.files.
7
+ //
8
+ // Filter semantics: one active pill at a time across all axes;
9
+ // clicking a pill toggles visibility on the per-file body via
10
+ // applyGroupFilter. The "ungrouped" visual tell is anchored to the
11
+ // themes axis (every hunk lives in a file, so the files axis has
12
+ // no useful "ungrouped" signal). Active pill is persisted in
13
+ // localStorage as `<axis>:<id>`.
14
+
15
+ import { Annotations } from "./annotations";
16
+ import { Comments } from "./comments";
17
+
18
+ type AxisId = "themes" | "files" | "symbols";
19
+
20
+ interface SidebarAxis {
21
+ id: AxisId;
22
+ label: string;
23
+ groups: GroupBlock[];
24
+ byId: Record<string, GroupBlock>;
25
+ hunkCount: Record<string, number>;
26
+ }
27
+
28
+ interface ActivePill {
29
+ axis: AxisId;
30
+ id: string;
31
+ }
32
+
33
+ const THEMES_AXIS: SidebarAxis = {
34
+ id: "themes", label: "Themes",
35
+ groups: [], byId: Object.create(null), hunkCount: Object.create(null),
36
+ };
37
+ const FILES_AXIS: SidebarAxis = {
38
+ id: "files", label: "Files",
39
+ groups: [], byId: Object.create(null), hunkCount: Object.create(null),
40
+ };
41
+ // Deterministic tree-sitter symbol delta (ADR 0001). Like the Files
42
+ // axis it's structural — populated from DATA at boot, never refreshed
43
+ // by an SSE pass. Rendered as a nested class ▸ method tree (slice 5).
44
+ const SYMBOLS_AXIS: SidebarAxis = {
45
+ id: "symbols", label: "Symbols",
46
+ groups: [], byId: Object.create(null), hunkCount: Object.create(null),
47
+ };
48
+ const AXES: SidebarAxis[] = [THEMES_AXIS, FILES_AXIS, SYMBOLS_AXIS];
49
+
50
+ let _data: ViewerData | null = null;
51
+ let _activePill: ActivePill | null = null;
52
+ let _lsKey = "scr-active-group:local";
53
+ // Notified with the focused symbol's name (or null) whenever the active
54
+ // pill changes — boot points this at Render.setSymbolSearch. Kept as an
55
+ // injected callback rather than a direct import so the sidebar doesn't
56
+ // take a cyclic dependency on render.
57
+ let _onActivePillChange: ((symbolName: string | null) => void) | null = null;
58
+ // Collapsed symbol-tree nodes, by pill id. In-memory only (the tree is
59
+ // expanded by default; collapse is a transient view preference that
60
+ // survives re-renders within a session but not a reload).
61
+ const _collapsedSymbols = new Set<string>();
62
+
63
+ /** Populate axes from the initial viewer data + restore any active
64
+ * pill from localStorage. Idempotent (call again after DATA mutates
65
+ * in a way the in-place refreshers don't cover). */
66
+ function init(
67
+ data: ViewerData,
68
+ opts?: { onActivePillChange?: (symbolName: string | null) => void },
69
+ ): void {
70
+ _data = data;
71
+ if (opts && opts.onActivePillChange) _onActivePillChange = opts.onActivePillChange;
72
+ _lsKey =
73
+ "scr-active-group:"
74
+ + (data.pr && data.pr.head_sha ? data.pr.head_sha : "local");
75
+
76
+ // Themes axis: refresh in place so any references the rest of
77
+ // the viewer holds to THEMES_AXIS.* arrays stay live.
78
+ refreshThemes(data.groups || []);
79
+ rebuildFilesAxis();
80
+ rebuildSymbolsAxis();
81
+
82
+ // Restore the active pill across axes. Legacy entries are bare
83
+ // ids (themes axis); new entries are `<axis>:<id>`.
84
+ try {
85
+ const saved = localStorage.getItem(_lsKey);
86
+ if (saved) {
87
+ let axisId: ActivePill["axis"] = "themes";
88
+ let pillId = saved;
89
+ if (saved.includes(":")) {
90
+ const parts = saved.split(":", 2);
91
+ axisId = parts[0] as ActivePill["axis"];
92
+ pillId = parts[1];
93
+ }
94
+ const axis = AXES.find((a) => a.id === axisId);
95
+ if (axis && axis.byId[pillId]) _activePill = { axis: axisId, id: pillId };
96
+ }
97
+ } catch (_) { /* localStorage may be unavailable */ }
98
+ // Seed the symbol search now (init runs before Render.init in boot):
99
+ // the emit's repaint finds no cells yet, but it sets `_symbolSearch` in
100
+ // render so Render.init's first paint highlights a restored Symbols pill.
101
+ _emitActivePill();
102
+ }
103
+
104
+ /** Rebuild the themes axis from a fresh list of groups. Mutates the
105
+ * THEMES_AXIS arrays in place so existing live references survive
106
+ * (the original sidebar bug from commit e800632 was forgetting to
107
+ * do exactly this). */
108
+ function refreshThemes(groups: GroupBlock[]): void {
109
+ THEMES_AXIS.groups.length = 0;
110
+ for (const k of Object.keys(THEMES_AXIS.byId)) delete THEMES_AXIS.byId[k];
111
+ for (const k of Object.keys(THEMES_AXIS.hunkCount)) delete THEMES_AXIS.hunkCount[k];
112
+ for (const g of groups) {
113
+ THEMES_AXIS.groups.push(g);
114
+ THEMES_AXIS.byId[g.id] = g;
115
+ for (const hid of g.hunk_ids || []) {
116
+ THEMES_AXIS.hunkCount[hid] = (THEMES_AXIS.hunkCount[hid] || 0) + 1;
117
+ }
118
+ }
119
+ }
120
+
121
+ /** Derive the by-file axis from DATA.files. One pill per file with
122
+ * hunks; pill ids `BF<fi>` (distinct ID space from themes' `G<i>`).
123
+ * Skipped files (zero hunks) get no pill. */
124
+ function rebuildFilesAxis(): void {
125
+ if (!_data) return;
126
+ FILES_AXIS.groups.length = 0;
127
+ for (const k of Object.keys(FILES_AXIS.byId)) delete FILES_AXIS.byId[k];
128
+ for (const k of Object.keys(FILES_AXIS.hunkCount)) delete FILES_AXIS.hunkCount[k];
129
+ for (let fi = 0; fi < (_data.files || []).length; fi++) {
130
+ const f = _data.files[fi];
131
+ if (!f.hunks || f.hunks.length === 0) continue;
132
+ const hunk_ids = f.hunks.map((h) => h.id);
133
+ const g: GroupBlock = {
134
+ id: `BF${fi}`,
135
+ title: _shortenPath(f.path),
136
+ rationale: f.path,
137
+ hunk_ids,
138
+ };
139
+ FILES_AXIS.groups.push(g);
140
+ FILES_AXIS.byId[g.id] = g;
141
+ for (const hid of hunk_ids) {
142
+ FILES_AXIS.hunkCount[hid] = (FILES_AXIS.hunkCount[hid] || 0) + 1;
143
+ }
144
+ }
145
+ }
146
+
147
+ /** Load the symbols axis from DATA.symbols — pre-built server-side as a
148
+ * forest of GroupBlock nodes (class ▸ method; a parent's hunk_ids is its
149
+ * subtree union). `groups` holds the roots for the tree render; `byId`
150
+ * flattens every node (roots and descendants alike) so active-pill
151
+ * lookup and restore resolve any node by id. Pill ids `SY<i>` are a
152
+ * distinct ID space from themes/files. The delta is deterministic, so
153
+ * unlike themes there's no in-place refresh: what boot ships is final. */
154
+ function rebuildSymbolsAxis(): void {
155
+ SYMBOLS_AXIS.groups.length = 0;
156
+ for (const k of Object.keys(SYMBOLS_AXIS.byId)) delete SYMBOLS_AXIS.byId[k];
157
+ for (const k of Object.keys(SYMBOLS_AXIS.hunkCount)) delete SYMBOLS_AXIS.hunkCount[k];
158
+ const register = (g: GroupBlock): void => {
159
+ SYMBOLS_AXIS.byId[g.id] = g;
160
+ for (const hid of g.hunk_ids || []) {
161
+ SYMBOLS_AXIS.hunkCount[hid] = (SYMBOLS_AXIS.hunkCount[hid] || 0) + 1;
162
+ }
163
+ for (const c of g.children || []) register(c);
164
+ };
165
+ for (const g of (_data && _data.symbols) || []) {
166
+ SYMBOLS_AXIS.groups.push(g);
167
+ register(g);
168
+ }
169
+ }
170
+
171
+ /** Render the sidebar's pill rows. One section per populated axis,
172
+ * a "Show all" button above them that clears any active pill. */
173
+ function render(): void {
174
+ const sidebar = document.getElementById("group-sidebar");
175
+ if (!sidebar) return;
176
+ sidebar.innerHTML = "";
177
+ const populated = AXES.filter((a) => a.groups.length > 0);
178
+ if (populated.length === 0) {
179
+ sidebar.classList.add("empty");
180
+ return;
181
+ }
182
+ sidebar.classList.remove("empty");
183
+
184
+ const showAll = _el("button", "group-btn group-btn-all", "Show all");
185
+ showAll.title = "Clear filter — show every hunk";
186
+ if (_activePill === null) showAll.classList.add("active");
187
+ showAll.addEventListener("click", () => setActivePill(null));
188
+ sidebar.appendChild(showAll);
189
+
190
+ for (const axis of populated) {
191
+ const section = _el("div", "group-axis");
192
+ section.dataset.axis = axis.id;
193
+ const header = _el("div", "group-axis-header");
194
+ header.appendChild(_el("h3", null, axis.label));
195
+ section.appendChild(header);
196
+ if (axis.id === "symbols") {
197
+ // Nested class ▸ method tree (ADR 0001 slice 5).
198
+ for (const g of axis.groups) section.appendChild(_symbolNode(axis, g));
199
+ } else {
200
+ const commentCounts = axis.id === "files" ? _commentCountsByFilePath() : null;
201
+ for (const g of axis.groups) section.appendChild(_pillButton(axis, g, commentCounts));
202
+ }
203
+ sidebar.appendChild(section);
204
+ }
205
+ }
206
+
207
+ /** One pill button for a group. Shared by the flat axes and the symbols
208
+ * tree's per-node row. `commentCounts` (Files axis only) appends the
209
+ * unresolved/total badge. */
210
+ function _pillButton(
211
+ axis: SidebarAxis,
212
+ g: GroupBlock,
213
+ commentCounts: Record<string, CommentCounts> | null,
214
+ ): HTMLElement {
215
+ const btn = _el("button", "group-btn");
216
+ btn.dataset.axis = axis.id;
217
+ btn.dataset.pillId = g.id;
218
+ btn.appendChild(_el("span", "group-btn-label", g.title));
219
+ btn.appendChild(_el("span", "group-btn-count", String((g.hunk_ids || []).length)));
220
+ if (commentCounts) {
221
+ // Files axis only — Themes axis is keyed by hunks, not paths,
222
+ // so there's no single "comments per pill" mapping to surface.
223
+ const cc = commentCounts[g.rationale] || { total: 0, unresolved: 0 };
224
+ const badge = _renderCommentCountBadge(cc);
225
+ if (badge) btn.appendChild(badge);
226
+ }
227
+ if (g.rationale) btn.title = g.rationale;
228
+ if (_isActivePill(axis.id, g.id)) btn.classList.add("active");
229
+ btn.addEventListener("click", () => {
230
+ setActivePill(
231
+ _isActivePill(axis.id, g.id) ? null : { axis: axis.id, id: g.id },
232
+ );
233
+ });
234
+ return btn;
235
+ }
236
+
237
+ /** Render one symbol-tree node: an optional expand/collapse toggle, the
238
+ * node's own filtering pill, and (recursively) its children indented
239
+ * beneath it. Ancestors are shown for context even when unchanged;
240
+ * clicking the pill filters to the subtree union, clicking a leaf to
241
+ * just that symbol's hunks. */
242
+ function _symbolNode(axis: SidebarAxis, g: GroupBlock): HTMLElement {
243
+ const node = _el("div", "group-tree-node");
244
+ const row = _el("div", "group-tree-row");
245
+ const kids = g.children || [];
246
+ let childWrap: HTMLElement | null = null;
247
+ if (kids.length > 0) {
248
+ const collapsed = _collapsedSymbols.has(g.id);
249
+ const toggle = _el("button", "group-tree-toggle", collapsed ? "▸" : "▾");
250
+ toggle.title = collapsed ? "Expand" : "Collapse";
251
+ toggle.addEventListener("click", (e) => {
252
+ e.stopPropagation();
253
+ const nowCollapsed = !_collapsedSymbols.has(g.id);
254
+ if (nowCollapsed) _collapsedSymbols.add(g.id);
255
+ else _collapsedSymbols.delete(g.id);
256
+ toggle.textContent = nowCollapsed ? "▸" : "▾";
257
+ toggle.title = nowCollapsed ? "Expand" : "Collapse";
258
+ if (childWrap) childWrap.style.display = nowCollapsed ? "none" : "";
259
+ });
260
+ row.appendChild(toggle);
261
+ } else {
262
+ // Keep leaves aligned with parents' pills under the toggle column.
263
+ row.appendChild(_el("span", "group-tree-toggle group-tree-toggle-leaf"));
264
+ }
265
+ row.appendChild(_pillButton(axis, g, null));
266
+ node.appendChild(row);
267
+ if (kids.length > 0) {
268
+ childWrap = _el("div", "group-tree-children");
269
+ if (_collapsedSymbols.has(g.id)) childWrap.style.display = "none";
270
+ for (const c of kids) childWrap.appendChild(_symbolNode(axis, c));
271
+ node.appendChild(childWrap);
272
+ }
273
+ return node;
274
+ }
275
+
276
+ function _isActivePill(axisId: string, pillId: string): boolean {
277
+ return _activePill !== null
278
+ && _activePill.axis === axisId
279
+ && _activePill.id === pillId;
280
+ }
281
+
282
+ function setActivePill(pill: ActivePill | null): void {
283
+ _activePill = pill;
284
+ try {
285
+ if (pill === null) localStorage.removeItem(_lsKey);
286
+ else localStorage.setItem(_lsKey, `${pill.axis}:${pill.id}`);
287
+ } catch (_) { /* ignore */ }
288
+ document.querySelectorAll(".group-btn").forEach(
289
+ (b) => b.classList.remove("active"),
290
+ );
291
+ if (pill === null) {
292
+ const all = document.querySelector(".group-btn-all");
293
+ if (all) all.classList.add("active");
294
+ } else {
295
+ const sel = `.group-btn[data-axis="${pill.axis}"][data-pill-id="${pill.id}"]`;
296
+ const btn = document.querySelector(sel);
297
+ if (btn) btn.classList.add("active");
298
+ }
299
+ applyFilter();
300
+ _emitActivePill();
301
+ Annotations.reflowAll();
302
+ }
303
+
304
+ /** Name to search-highlight across the diff for the active pill: the
305
+ * focused symbol's bare name, or null for any non-Symbols pill / no
306
+ * pill. Wired to Render.setSymbolSearch by boot. */
307
+ function _activeSymbolName(): string | null {
308
+ if (_activePill === null || _activePill.axis !== "symbols") return null;
309
+ const g = SYMBOLS_AXIS.byId[_activePill.id];
310
+ return g ? g.title : null;
311
+ }
312
+
313
+ function _emitActivePill(): void {
314
+ _onActivePillChange?.(_activeSymbolName());
315
+ }
316
+
317
+ function _activePillHunkIds(): Set<string> | null {
318
+ if (_activePill === null) return null;
319
+ const axis = AXES.find((a) => a.id === _activePill!.axis);
320
+ if (!axis) return null;
321
+ const g = axis.byId[_activePill.id];
322
+ return g ? new Set(g.hunk_ids || []) : new Set<string>();
323
+ }
324
+
325
+ /** Walk every .hunk in the file body, tag `.ungrouped` for hunks no
326
+ * themes-axis group claims, and toggle visibility based on the
327
+ * active pill. Files with no visible hunks hide too — keeps the
328
+ * filtered view tidy. */
329
+ function applyFilter(): void {
330
+ const activeIds = _activePillHunkIds();
331
+ document.querySelectorAll(".file").forEach((fileEl) => {
332
+ let visible = 0;
333
+ fileEl.querySelectorAll(".hunk").forEach((hunkEl) => {
334
+ const hid = (hunkEl as HTMLElement).dataset.id || "";
335
+ const inAnyGroup = (THEMES_AXIS.hunkCount[hid] || 0) > 0;
336
+ hunkEl.classList.toggle("ungrouped", !inAnyGroup);
337
+ const show = activeIds === null ? true : activeIds.has(hid);
338
+ (hunkEl as HTMLElement).style.display = show ? "" : "none";
339
+ if (show) visible++;
340
+ });
341
+ (fileEl as HTMLElement).style.display =
342
+ visible === 0 && activeIds !== null ? "none" : "";
343
+ });
344
+ }
345
+
346
+ interface CommentCounts { total: number; unresolved: number }
347
+
348
+ /** Tally comment threads per file path.
349
+ *
350
+ * "Threads" are counted as the root comments — i.e. those whose
351
+ * ``in_reply_to_id`` is null/absent. A local reply to an ingested
352
+ * root carries the parent's id; the parent (the ingested root) is
353
+ * what gets counted. This matches how the discussion is grouped in
354
+ * the diff view: one annotation block per thread.
355
+ *
356
+ * "Unresolved" mirrors the resolved-thread fold: a thread is
357
+ * resolved iff its root's ``thread_resolved`` is true. Pure-local
358
+ * threads (no upstream) default to unresolved — the reviewer hasn't
359
+ * told us otherwise. */
360
+ function _commentCountsByFilePath(): Record<string, CommentCounts> {
361
+ const out: Record<string, CommentCounts> = Object.create(null);
362
+ for (const c of Comments.getAll()) {
363
+ if (c.in_reply_to_id) continue; // only thread roots
364
+ if (!c.file) continue;
365
+ const bucket = (out[c.file] ||= { total: 0, unresolved: 0 });
366
+ bucket.total += 1;
367
+ if (!c.thread_resolved) bucket.unresolved += 1;
368
+ }
369
+ return out;
370
+ }
371
+
372
+ function _renderCommentCountBadge(cc: CommentCounts): HTMLElement | null {
373
+ if (cc.total === 0) return null;
374
+ const badge = _el(
375
+ "span",
376
+ "group-btn-comments" + (cc.unresolved > 0 ? " has-unresolved" : ""),
377
+ `${cc.unresolved}/${cc.total}`,
378
+ );
379
+ badge.title = cc.unresolved > 0
380
+ ? `${cc.unresolved} unresolved of ${cc.total} thread${cc.total === 1 ? "" : "s"}`
381
+ : `${cc.total} thread${cc.total === 1 ? "" : "s"} — all resolved`;
382
+ return badge;
383
+ }
384
+
385
+ /** Re-paint just the comment-count badges on existing Files-axis pills.
386
+ * Boot wires this to Comments' onChange so the badges stay in sync
387
+ * with the store without re-rendering the whole sidebar. */
388
+ function refreshFileCommentCounts(): void {
389
+ const sidebar = document.getElementById("group-sidebar");
390
+ if (!sidebar) return;
391
+ const filesSection = sidebar.querySelector('[data-axis="files"]');
392
+ if (!filesSection) return;
393
+ const counts = _commentCountsByFilePath();
394
+ filesSection.querySelectorAll<HTMLElement>(".group-btn").forEach((btn) => {
395
+ const pillId = btn.dataset.pillId || "";
396
+ const group = FILES_AXIS.byId[pillId];
397
+ if (!group) return;
398
+ const cc = counts[group.rationale] || { total: 0, unresolved: 0 };
399
+ const existing = btn.querySelector(".group-btn-comments");
400
+ if (existing) existing.remove();
401
+ const fresh = _renderCommentCountBadge(cc);
402
+ if (fresh) btn.appendChild(fresh);
403
+ });
404
+ }
405
+
406
+ function _shortenPath(path: string): string {
407
+ if (!path) return "";
408
+ if (path.length <= 28) return path;
409
+ const idx = path.lastIndexOf("/");
410
+ return idx >= 0 ? path.slice(idx + 1) : path;
411
+ }
412
+
413
+ function _el(tag: string, className: string | null, text?: string): HTMLElement {
414
+ const n = document.createElement(tag);
415
+ if (className) n.className = className;
416
+ if (text !== undefined) n.textContent = text;
417
+ return n;
418
+ }
419
+
420
+ export const Sidebar = {
421
+ init,
422
+ render,
423
+ refreshThemes,
424
+ rebuildFilesAxis,
425
+ rebuildSymbolsAxis,
426
+ refreshFileCommentCounts,
427
+ setActivePill,
428
+ applyFilter,
429
+ };
@@ -0,0 +1,78 @@
1
+ // Server-Sent Events wiring.
2
+ //
3
+ // Opens the /events stream on the review server, parses each
4
+ // per-type JSON payload, and dispatches to typed handlers the
5
+ // caller registers. The wire format is one EventSource frame per
6
+ // pipeline phase (overview-start, overview, overview-failed,
7
+ // hunk-start, hunk, fold-summary, done); payload shapes live in
8
+ // `types.d.ts`. Replay on reconnect is handled by the browser's
9
+ // EventSource implementation (it sends Last-Event-ID automatically;
10
+ // the server replays from its buffer).
11
+
12
+ interface SseHandlers {
13
+ overviewStart?: () => void;
14
+ overviewFailed?: () => void;
15
+ overview?: (payload: SseOverviewEvent) => void;
16
+ hunkStart?: (payload: SseHunkStartEvent) => void;
17
+ hunk?: (payload: SseHunkEvent) => void;
18
+ foldSummary?: (payload: SseFoldSummaryEvent) => void;
19
+ done?: (payload: SseDoneEvent) => void;
20
+ consoleDelta?: (payload: SseConsoleDeltaEvent) => void;
21
+ consoleTool?: (payload: SseConsoleToolEvent) => void;
22
+ consoleDone?: (payload: SseConsoleDoneEvent) => void;
23
+ consoleError?: (payload: SseConsoleErrorEvent) => void;
24
+ }
25
+
26
+ /** Subscribe to `<endpoint>/events`. Returns the EventSource so
27
+ * callers can close it explicitly if needed (rare — the connection
28
+ * stays open for the review session). Returns `null` if the browser
29
+ * doesn't support EventSource or the connection couldn't be opened.
30
+ */
31
+ function connect(endpoint: string, handlers: SseHandlers): EventSource | null {
32
+ // Empty `endpoint` is valid — it means "same origin" and the
33
+ // resulting `/events` URL is relative to the page. Only the
34
+ // browser's missing EventSource (jsdom without our stub) bails.
35
+ if (typeof EventSource === "undefined") return null;
36
+ let es: EventSource;
37
+ try {
38
+ es = new EventSource(endpoint + "/events");
39
+ } catch (_) {
40
+ return null;
41
+ }
42
+
43
+ // Frames without a JSON body (the server sends no `data:` for
44
+ // overview-start / overview-failed) just fire the handler.
45
+ const wireBare = (type: string, fn: () => void): void => {
46
+ es.addEventListener(type, fn);
47
+ };
48
+ // Frames with a JSON body: parse + dispatch with the typed
49
+ // payload. JSON.parse errors are swallowed so a corrupt frame
50
+ // doesn't take down the whole channel.
51
+ const wireJson = <T>(type: string, fn: (payload: T) => void): void => {
52
+ es.addEventListener(type, (e: MessageEvent) => {
53
+ let parsed: T;
54
+ try {
55
+ parsed = JSON.parse(e.data) as T;
56
+ } catch (_) {
57
+ return;
58
+ }
59
+ fn(parsed);
60
+ });
61
+ };
62
+
63
+ if (handlers.overviewStart) wireBare("overview-start", handlers.overviewStart);
64
+ if (handlers.overviewFailed) wireBare("overview-failed", handlers.overviewFailed);
65
+ if (handlers.overview) wireJson<SseOverviewEvent>("overview", handlers.overview);
66
+ if (handlers.hunkStart) wireJson<SseHunkStartEvent>("hunk-start", handlers.hunkStart);
67
+ if (handlers.hunk) wireJson<SseHunkEvent>("hunk", handlers.hunk);
68
+ if (handlers.foldSummary) wireJson<SseFoldSummaryEvent>("fold-summary", handlers.foldSummary);
69
+ if (handlers.done) wireJson<SseDoneEvent>("done", handlers.done);
70
+ if (handlers.consoleDelta) wireJson<SseConsoleDeltaEvent>("console-delta", handlers.consoleDelta);
71
+ if (handlers.consoleTool) wireJson<SseConsoleToolEvent>("console-tool", handlers.consoleTool);
72
+ if (handlers.consoleDone) wireJson<SseConsoleDoneEvent>("console-done", handlers.consoleDone);
73
+ if (handlers.consoleError) wireJson<SseConsoleErrorEvent>("console-error", handlers.consoleError);
74
+ return es;
75
+ }
76
+
77
+ export const Sse = { connect };
78
+ export type { SseHandlers };