From 0beee9d2f8d2d67f9ca05b40b3dc4729d3d43a80 Mon Sep 17 00:00:00 2001 From: amh1k Date: Fri, 7 Aug 2026 22:54:43 +0500 Subject: [PATCH 1/5] feat(viewer): add review comment tag filters --- internal/viewer/server.go | 111 +++++++++++++++++---- internal/viewer/server_startserver_test.go | 31 +++++- internal/viewer/static/session.js | 69 +++++++++++++ internal/viewer/static/style.css | 51 ++++++++++ internal/viewer/templates/session.html | 28 ++++-- 5 files changed, 263 insertions(+), 27 deletions(-) diff --git a/internal/viewer/server.go b/internal/viewer/server.go index 2f8aa076d..182beea52 100644 --- a/internal/viewer/server.go +++ b/internal/viewer/server.go @@ -95,6 +95,83 @@ type SeverityCount struct { Low int } +// CategoryCount holds counts for each review comment category. +type CategoryCount struct { + Bug int + Security int + Performance int + Maintainability int + Test int + Style int + Documentation int + Other int +} + +var knownCommentCategories = map[string]struct{}{ + "bug": {}, + "security": {}, + "performance": {}, + "maintainability": {}, + "test": {}, + "style": {}, + "documentation": {}, + "other": {}, +} + +func normalizedCommentCategory(category string) string { + category = strings.ToLower(strings.TrimSpace(category)) + if _, ok := knownCommentCategories[category]; ok { + return category + } + return "other" +} + +func normalizedCommentSeverity(severity string) string { + return strings.ToLower(strings.TrimSpace(severity)) +} + +func categoryCounts(comments []*ReviewComment) CategoryCount { + var counts CategoryCount + for _, comment := range comments { + switch normalizedCommentCategory(comment.Category) { + case "bug": + counts.Bug++ + case "security": + counts.Security++ + case "performance": + counts.Performance++ + case "maintainability": + counts.Maintainability++ + case "test": + counts.Test++ + case "style": + counts.Style++ + case "documentation": + counts.Documentation++ + default: + counts.Other++ + } + } + return counts +} + +func severityCounts(comments []*ReviewComment) SeverityCount { + var counts SeverityCount + for _, comment := range comments { + switch strings.ToLower(strings.TrimSpace(comment.Severity)) { + case "critical": + counts.Critical++ + case "high": + counts.High++ + case "medium": + counts.Medium++ + case "low": + counts.Low++ + } + } + return counts +} + func parseTemplate(name string) (*template.Template, error) { funcMap := template.FuncMap{ "formatDuration": formatDuration, @@ -164,24 +241,12 @@ func parseTemplate(name string) (*template.Template, error) { } return groups }, - "severityCounts": func(comments []*ReviewComment) SeverityCount { - var sc SeverityCount - for _, c := range comments { - switch c.Severity { - case "critical": - sc.Critical++ - case "high": - sc.High++ - case "medium": - sc.Medium++ - case "low": - sc.Low++ - } - } - return sc - }, + "severityCounts": severityCounts, + "categoryCounts": categoryCounts, + "commentCategory": normalizedCommentCategory, + "commentSeverity": normalizedCommentSeverity, "severityClass": func(s string) string { - switch s { + switch normalizedCommentSeverity(s) { case "critical": return "severity-critical" case "high": @@ -195,13 +260,23 @@ func parseTemplate(name string) (*template.Template, error) { } }, "categoryClass": func(s string) string { - switch s { + switch normalizedCommentCategory(s) { case "bug": return "cat-bug" case "security": return "cat-security" case "performance": return "cat-performance" + case "maintainability": + return "cat-maintainability" + case "test": + return "cat-test" + case "style": + return "cat-style" + case "documentation": + return "cat-documentation" + case "other": + return "cat-other" default: return "cat-default" } diff --git a/internal/viewer/server_startserver_test.go b/internal/viewer/server_startserver_test.go index 1d94ef754..b79e0df48 100644 --- a/internal/viewer/server_startserver_test.go +++ b/internal/viewer/server_startserver_test.go @@ -40,8 +40,9 @@ func TestStartServer_AddrInUse(t *testing.T) { } // TestParseTemplate_SessionWithComments renders session.html with review -// comments spanning every severity and category so the funcMap closures -// (severityCounts, severityClass, categoryClass, groupCommentsByFile) execute. +// comments spanning several severities and categories so the template helpers +// (severityCounts, categoryCounts, severityClass, categoryClass, +// groupCommentsByFile, and the normalization helpers) execute. func TestParseTemplate_SessionWithComments(t *testing.T) { tmpl, err := parseTemplate("session.html") if err != nil { @@ -71,4 +72,30 @@ func TestParseTemplate_SessionWithComments(t *testing.T) { if !strings.Contains(rr.Body.String(), "Review Comments") { t.Error("rendered page missing Review Comments section") } + body := rr.Body.String() + for _, want := range []string{ + `data-filter-kind="all"`, + `data-filter-kind="severity" data-filter-value="critical"`, + `data-filter-kind="category" data-filter-value="bug"`, + `data-filter-kind="category" data-filter-value="other"`, + `data-comment-card data-category="bug" data-severity="critical"`, + `data-comment-card data-category="other" data-severity="low"`, + `data-comment-filter-empty`, + } { + if !strings.Contains(body, want) { + t.Errorf("rendered page missing %q", want) + } + } +} + +func TestCategoryCounts_NormalizesUnknownCategories(t *testing.T) { + counts := categoryCounts([]*ReviewComment{ + {Category: "bug"}, + {Category: "MAINTAINABILITY"}, + {Category: ""}, + {Category: "not-a-category"}, + }) + if counts.Bug != 1 || counts.Maintainability != 1 || counts.Other != 2 { + t.Fatalf("unexpected category counts: %+v", counts) + } } diff --git a/internal/viewer/static/session.js b/internal/viewer/static/session.js index bbc970d8d..6710b7cef 100644 --- a/internal/viewer/static/session.js +++ b/internal/viewer/static/session.js @@ -28,3 +28,72 @@ document.querySelectorAll('.response-text').forEach(function(el) { }); el.innerHTML = html; }); + +(function() { + const filters = Array.from(document.querySelectorAll('.comment-filter-chip[data-filter-kind]')); + const groups = Array.from(document.querySelectorAll('.comment-file-group')); + const emptyState = document.querySelector('[data-comment-filter-empty]'); + + if (filters.length === 0 || groups.length === 0) { + return; + } + + let activeKind = 'all'; + let activeValue = ''; + + function cardMatches(card) { + if (activeKind === 'all') { + return true; + } + return card.dataset[activeKind] === activeValue; + } + + function updateFilterState() { + filters.forEach(function(filter) { + const isActive = activeKind === filter.dataset.filterKind && + activeValue === filter.dataset.filterValue; + filter.classList.toggle('is-active', isActive); + filter.setAttribute('aria-pressed', String(isActive)); + }); + + let visibleCount = 0; + groups.forEach(function(group) { + const cards = Array.from(group.querySelectorAll('[data-comment-card]')); + let groupVisibleCount = 0; + cards.forEach(function(card) { + const visible = cardMatches(card); + card.hidden = !visible; + if (visible) { + groupVisibleCount++; + visibleCount++; + } + }); + group.hidden = groupVisibleCount === 0; + const count = group.querySelector('[data-comment-count]'); + if (count) { + count.textContent = groupVisibleCount + ' comment' + (groupVisibleCount === 1 ? '' : 's'); + } + }); + + if (emptyState) { + emptyState.hidden = visibleCount !== 0; + } + } + + filters.forEach(function(filter) { + filter.addEventListener('click', function() { + const kind = filter.dataset.filterKind; + const value = filter.dataset.filterValue || ''; + if (kind === 'all' || (activeKind === kind && activeValue === value)) { + activeKind = 'all'; + activeValue = ''; + } else { + activeKind = kind; + activeValue = value; + } + updateFilterState(); + }); + }); + + updateFilterState(); +})(); diff --git a/internal/viewer/static/style.css b/internal/viewer/static/style.css index 1fab6efcc..9bae9bb72 100644 --- a/internal/viewer/static/style.css +++ b/internal/viewer/static/style.css @@ -885,6 +885,40 @@ p { gap: 0.5rem; margin-bottom: 1.25rem; } +.comment-filter-bar { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.65rem; +} +.category-filters { + margin-bottom: 1.25rem; +} +.comment-filter-chip { + appearance: none; + border: 1px solid transparent; + border-radius: 20px; + padding: 0.3em 0.75em; + font: inherit; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.01em; + line-height: 1.35; + cursor: pointer; + transition: opacity var(--transition), box-shadow var(--transition), border-color var(--transition); +} +.comment-filter-chip:not(.is-active) { opacity: 0.72; } +.comment-filter-chip:hover, +.comment-filter-chip.is-active { opacity: 1; } +.comment-filter-chip.is-active { + border-color: currentColor; + box-shadow: 0 0 0 2px var(--surface), 0 0 0 3px currentColor; +} +.comment-filter-chip:focus-visible { + outline: 2px solid var(--link); + outline-offset: 2px; +} +.filter-all { background: var(--badge-neutral-bg); color: var(--badge-neutral-fg); } .severity-badge { padding: 0.3em 0.75em; border-radius: 20px; @@ -970,6 +1004,11 @@ p { .cat-bug { background: #fef2f2; color: #dc2626; } .cat-security { background: #fdf2f8; color: #be185d; } .cat-performance { background: #fff7ed; color: #ea580c; } +.cat-maintainability { background: #eff6ff; color: #2563eb; } +.cat-test { background: #f0fdf4; color: #15803d; } +.cat-style { background: #f5f3ff; color: #7c3aed; } +.cat-documentation { background: #ecfeff; color: #0e7490; } +.cat-other { background: var(--badge-neutral-bg); color: var(--badge-neutral-fg); } .cat-default { background: var(--badge-neutral-bg); color: var(--badge-neutral-fg); } .severity-default { background: var(--badge-neutral-bg); color: var(--badge-neutral-fg); } @@ -977,9 +1016,21 @@ p { .cat-bug { background: rgba(220, 38, 38, 0.12); color: #fca5a5; } .cat-security { background: rgba(190, 24, 93, 0.12); color: #f9a8d4; } .cat-performance { background: rgba(234, 88, 12, 0.12); color: #fdba74; } + .cat-maintainability { background: rgba(37, 99, 235, 0.12); color: #93c5fd; } + .cat-test { background: rgba(21, 128, 61, 0.12); color: #86efac; } + .cat-style { background: rgba(124, 58, 237, 0.12); color: #c4b5fd; } + .cat-documentation { background: rgba(14, 116, 144, 0.12); color: #67e8f9; } + .cat-other { background: var(--badge-neutral-bg); color: var(--badge-neutral-fg); } .cat-default { background: var(--badge-neutral-bg); color: var(--badge-neutral-fg); } } +.comment-filter-empty { + margin: 1.25rem 0 0; + color: var(--text-muted); + text-align: center; + font-size: 0.85rem; +} + .comment-lines { font-family: var(--mono); font-size: 0.72rem; diff --git a/internal/viewer/templates/session.html b/internal/viewer/templates/session.html index a6adfbce1..52a752730 100644 --- a/internal/viewer/templates/session.html +++ b/internal/viewer/templates/session.html @@ -110,11 +110,24 @@

Token Usage

Review Comments ({{len .Session.Comments}} findings)

{{with severityCounts .Session.Comments}} -
- {{if .Critical}}Critical: {{.Critical}}{{end}} - {{if .High}}High: {{.High}}{{end}} - {{if .Medium}}Medium: {{.Medium}}{{end}} - {{if .Low}}Low: {{.Low}}{{end}} +
+ + {{if .Critical}}{{end}} + {{if .High}}{{end}} + {{if .Medium}}{{end}} + {{if .Low}}{{end}} +
+ {{end}} + {{with categoryCounts .Session.Comments}} +
+ {{if .Bug}}{{end}} + {{if .Security}}{{end}} + {{if .Performance}}{{end}} + {{if .Maintainability}}{{end}} + {{if .Test}}{{end}} + {{if .Style}}{{end}} + {{if .Documentation}}{{end}} + {{if .Other}}{{end}}
{{end}}
@@ -123,11 +136,11 @@

Review Comments ({{len .Session.Comments}} findings)

{{.FilePath}} - {{len .Comments}} comments + {{len .Comments}} comments
{{range .Comments}} -
+
{{if .Category}}{{.Category}}{{end}} {{if .Severity}}{{.Severity}}{{end}} @@ -156,6 +169,7 @@

Review Comments ({{len .Session.Comments}} findings)

{{end}}
+
{{end}} From 2dc29b2e14a60fea9df378a9ff5f007b94ee0626 Mon Sep 17 00:00:00 2001 From: Abdul Moiz Hussain Date: Fri, 7 Aug 2026 23:08:41 +0500 Subject: [PATCH 2/5] fix(viewer): normalize filter chip state values Use the same empty-string fallback when updating filter-chip active state as when handling clicks, preventing filters without a value attribute from appearing inactive after selection. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- internal/viewer/static/session.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/viewer/static/session.js b/internal/viewer/static/session.js index 6710b7cef..33b34fcec 100644 --- a/internal/viewer/static/session.js +++ b/internal/viewer/static/session.js @@ -51,7 +51,7 @@ document.querySelectorAll('.response-text').forEach(function(el) { function updateFilterState() { filters.forEach(function(filter) { const isActive = activeKind === filter.dataset.filterKind && - activeValue === filter.dataset.filterValue; + activeValue === (filter.dataset.filterValue || ''); filter.classList.toggle('is-active', isActive); filter.setAttribute('aria-pressed', String(isActive)); }); From 07efe7d5cc54c6d0a1f9ac35e87ca0734e4649bd Mon Sep 17 00:00:00 2001 From: amh1k Date: Sun, 9 Aug 2026 17:56:08 +0500 Subject: [PATCH 3/5] fix(viewer): improve comment tag filter controls --- internal/viewer/server_startserver_test.go | 3 +++ internal/viewer/static/style.css | 6 ------ internal/viewer/templates/session.html | 1 + 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/internal/viewer/server_startserver_test.go b/internal/viewer/server_startserver_test.go index b79e0df48..a4d7c805d 100644 --- a/internal/viewer/server_startserver_test.go +++ b/internal/viewer/server_startserver_test.go @@ -73,6 +73,9 @@ func TestParseTemplate_SessionWithComments(t *testing.T) { t.Error("rendered page missing Review Comments section") } body := rr.Body.String() + if strings.Count(body, `data-filter-kind="all"`) != 2 { + t.Errorf("All filter count = %d, want 2 (severity and category)", strings.Count(body, `data-filter-kind="all"`)) + } for _, want := range []string{ `data-filter-kind="all"`, `data-filter-kind="severity" data-filter-value="critical"`, diff --git a/internal/viewer/static/style.css b/internal/viewer/static/style.css index 9bae9bb72..42d6163c6 100644 --- a/internal/viewer/static/style.css +++ b/internal/viewer/static/style.css @@ -879,12 +879,6 @@ p { font-size: 1rem; } -.severity-bar { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - margin-bottom: 1.25rem; -} .comment-filter-bar { display: flex; flex-wrap: wrap; diff --git a/internal/viewer/templates/session.html b/internal/viewer/templates/session.html index 52a752730..c691f8cfe 100644 --- a/internal/viewer/templates/session.html +++ b/internal/viewer/templates/session.html @@ -120,6 +120,7 @@

Review Comments ({{len .Session.Comments}} findings)

{{end}} {{with categoryCounts .Session.Comments}}
+ {{if .Bug}}{{end}} {{if .Security}}{{end}} {{if .Performance}}{{end}} From 222fa9cd23599144d1ba75e7507be0bc9467d3d9 Mon Sep 17 00:00:00 2001 From: amh1k Date: Mon, 10 Aug 2026 09:16:04 +0500 Subject: [PATCH 4/5] fix(viewer): support combined comment filters --- internal/viewer/server_startserver_test.go | 8 +++---- internal/viewer/static/session.js | 25 ++++++++++------------ internal/viewer/static/style.css | 7 ++++++ internal/viewer/templates/session.html | 6 ++++-- 4 files changed, 26 insertions(+), 20 deletions(-) diff --git a/internal/viewer/server_startserver_test.go b/internal/viewer/server_startserver_test.go index a4d7c805d..e93959a4a 100644 --- a/internal/viewer/server_startserver_test.go +++ b/internal/viewer/server_startserver_test.go @@ -73,11 +73,11 @@ func TestParseTemplate_SessionWithComments(t *testing.T) { t.Error("rendered page missing Review Comments section") } body := rr.Body.String() - if strings.Count(body, `data-filter-kind="all"`) != 2 { - t.Errorf("All filter count = %d, want 2 (severity and category)", strings.Count(body, `data-filter-kind="all"`)) - } for _, want := range []string{ - `data-filter-kind="all"`, + `Severity:`, + `Category:`, + `data-filter-kind="severity" data-filter-value="all"`, + `data-filter-kind="category" data-filter-value="all"`, `data-filter-kind="severity" data-filter-value="critical"`, `data-filter-kind="category" data-filter-value="bug"`, `data-filter-kind="category" data-filter-value="other"`, diff --git a/internal/viewer/static/session.js b/internal/viewer/static/session.js index 33b34fcec..5c5d60867 100644 --- a/internal/viewer/static/session.js +++ b/internal/viewer/static/session.js @@ -38,20 +38,19 @@ document.querySelectorAll('.response-text').forEach(function(el) { return; } - let activeKind = 'all'; - let activeValue = ''; + let activeSeverity = 'all'; + let activeCategory = 'all'; function cardMatches(card) { - if (activeKind === 'all') { - return true; - } - return card.dataset[activeKind] === activeValue; + return (activeSeverity === 'all' || card.dataset.severity === activeSeverity) && + (activeCategory === 'all' || card.dataset.category === activeCategory); } function updateFilterState() { filters.forEach(function(filter) { - const isActive = activeKind === filter.dataset.filterKind && - activeValue === (filter.dataset.filterValue || ''); + const kind = filter.dataset.filterKind; + const activeValue = kind === 'severity' ? activeSeverity : activeCategory; + const isActive = activeValue === filter.dataset.filterValue; filter.classList.toggle('is-active', isActive); filter.setAttribute('aria-pressed', String(isActive)); }); @@ -83,13 +82,11 @@ document.querySelectorAll('.response-text').forEach(function(el) { filters.forEach(function(filter) { filter.addEventListener('click', function() { const kind = filter.dataset.filterKind; - const value = filter.dataset.filterValue || ''; - if (kind === 'all' || (activeKind === kind && activeValue === value)) { - activeKind = 'all'; - activeValue = ''; + const value = filter.dataset.filterValue; + if (kind === 'severity') { + activeSeverity = activeSeverity === value ? 'all' : value; } else { - activeKind = kind; - activeValue = value; + activeCategory = activeCategory === value ? 'all' : value; } updateFilterState(); }); diff --git a/internal/viewer/static/style.css b/internal/viewer/static/style.css index 42d6163c6..fdbf5390d 100644 --- a/internal/viewer/static/style.css +++ b/internal/viewer/static/style.css @@ -881,6 +881,7 @@ p { .comment-filter-bar { display: flex; + align-items: center; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.65rem; @@ -888,6 +889,12 @@ p { .category-filters { margin-bottom: 1.25rem; } +.comment-filter-label { + min-width: 4.75rem; + color: var(--text-muted); + font-size: 0.8rem; + font-weight: 600; +} .comment-filter-chip { appearance: none; border: 1px solid transparent; diff --git a/internal/viewer/templates/session.html b/internal/viewer/templates/session.html index c691f8cfe..2718ee4e0 100644 --- a/internal/viewer/templates/session.html +++ b/internal/viewer/templates/session.html @@ -111,7 +111,8 @@

Token Usage

Review Comments ({{len .Session.Comments}} findings)

{{with severityCounts .Session.Comments}}
- + Severity: + {{if .Critical}}{{end}} {{if .High}}{{end}} {{if .Medium}}{{end}} @@ -120,7 +121,8 @@

Review Comments ({{len .Session.Comments}} findings)

{{end}} {{with categoryCounts .Session.Comments}}
- + Category: + {{if .Bug}}{{end}} {{if .Security}}{{end}} {{if .Performance}}{{end}} From d5f37d5c58829193a4b9c30694baf3783be7fa1c Mon Sep 17 00:00:00 2001 From: amh1k Date: Mon, 10 Aug 2026 15:04:07 +0500 Subject: [PATCH 5/5] fix(viewer): simplify active filter chip ring --- internal/viewer/static/style.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/viewer/static/style.css b/internal/viewer/static/style.css index fdbf5390d..da3030fb7 100644 --- a/internal/viewer/static/style.css +++ b/internal/viewer/static/style.css @@ -912,8 +912,8 @@ p { .comment-filter-chip:hover, .comment-filter-chip.is-active { opacity: 1; } .comment-filter-chip.is-active { - border-color: currentColor; - box-shadow: 0 0 0 2px var(--surface), 0 0 0 3px currentColor; + border-color: transparent; + box-shadow: 0 0 0 2px currentColor; } .comment-filter-chip:focus-visible { outline: 2px solid var(--link);