<!DOCTYPE html>
<html lang="en">
<head>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-MWD10CMTCP"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-MWD10CMTCP');
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="static/favicon.ico">
<title>Sashiko</title>
<style>
:root {
--bg: #f5f5f5;
--border: #ccc;
--border-light: #eee;
--text: #24292f;
--text-muted: #666;
--text-dim: #999;
--link: #007bff;
--hover: #e0e0e0;
--selected: #d0d0d0;
--code-bg: #f6f8fa;
--code-border: #d0d7de;
--btn-bg: #fff;
--input-bg: #fff;
--card-bg: #fff;
--ai-bg: #ffffff;
--ai-border: #283593;
--warn-bg: #fff3cd;
--warn-border: #ffeeba;
--warn-text: #856404;
--error-bg: #fff0f0;
--error-border: #ffcdd2;
--error-text: #d73a49;
--error-title: #b71c1c;
--thought-color: #6a737d;
--thought-border: #d0d7de;
--log-color: #000;
--th-bg: #fafafa;
--status-pending: #0366d6;
--status-inreview: #d67f05;
--status-reviewed: #22863a;
--status-failed: #d73a49;
--status-embargoed: #6f42c1;
--tag-bg: #e0f7fa;
--tag-text: #006064;
--diff-add-bg: #e6ffec;
--diff-rm-bg: #ffebe9;
--diff-header: #005cc5;
--role-user: #005cc5;
--role-tool: #3f51b5;
--fn-name: #6f42c1;
/* Severity Colors */
--sev-critical: #B71C1C;
--sev-high: #FF5722;
--sev-medium: #FF9800;
--sev-low: #FBC02D;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--bg: #0d1117;
--border: #30363d;
--border-light: #21262d;
--text: #c9d1d9;
--text-muted: #8b949e;
--text-dim: #6e7681;
--link: #58a6ff;
--hover: #21262d;
--selected: #21262d;
--code-bg: #161b22;
--code-border: #30363d;
--btn-bg: #21262d;
--input-bg: #0d1117;
--card-bg: #161b22;
--ai-bg: #161b22;
--ai-border: #58a6ff;
--warn-bg: #3a2a00;
--warn-border: #755200;
--warn-text: #e5b300;
--error-bg: #3a0000;
--error-border: #750000;
--error-text: #ff6b6b;
--error-title: #ff8b8b;
--thought-color: #8b949e;
--thought-border: #30363d;
--log-color: #c9d1d9;
--th-bg: #161b22;
--status-pending: #58a6ff;
--status-inreview: #d29922;
--status-reviewed: #3fb950;
--status-failed: #f85149;
--status-embargoed: #d2a8ff;
--tag-bg: #032f62;
--tag-text: #79c0ff;
--diff-add-bg: rgba(46, 160, 67, 0.15);
--diff-rm-bg: rgba(248, 81, 73, 0.15);
--diff-header: #79c0ff;
--role-user: #79c0ff;
--role-tool: #82aaff;
--fn-name: #d2a8ff;
/* Severity Colors - Dark Mode */
--sev-critical: #ff8b8b;
--sev-high: #ff9d7e;
--sev-medium: #ffc260;
--sev-low: #ffea7f;
}
}
/* Colorblind Mode Overrides (Okabe-Ito inspired palette) */
/* Base / Light Theme: Uses darkened colors for contrast on light backgrounds */
body.colorblind-mode {
--status-pending: #336C8B; /* Dark Sky Blue */
--status-inreview: #8A5F00; /* Dark Orange */
--status-reviewed: #004D73; /* Dark Blue */
--status-failed: #803800; /* Dark Vermilion */
--status-embargoed: #7A4864; /* Dark Reddish Purple */
--sev-critical: #803800; /* Dark Vermilion */
--sev-high: #8A5F00; /* Dark Orange */
--sev-medium: #787221; /* Dark Yellow/Olive */
--sev-low: #336C8B; /* Dark Sky Blue */
}
/* Dark Theme (System Preference): Uses standard Okabe-Ito colors */
@media (prefers-color-scheme: dark) {
body.colorblind-mode {
--status-pending: #56B4E9; /* Sky Blue */
--status-inreview: #E69F00; /* Orange */
--status-reviewed: #0072B2; /* Blue */
--status-failed: #D55E00; /* Vermilion */
--status-embargoed: #CC79A7; /* Reddish Purple */
--sev-critical: #D55E00; /* Vermilion */
--sev-high: #E69F00; /* Orange */
--sev-medium: #F0E442; /* Yellow */
--sev-low: #56B4E9; /* Sky Blue */
}
}
/* Explicit Light Theme: Overrides system dark preference */
body.colorblind-mode.theme-light {
--status-pending: #336C8B;
--status-inreview: #8A5F00;
--status-reviewed: #004D73;
--status-failed: #803800;
--status-embargoed: #7A4864;
--sev-critical: #803800;
--sev-high: #8A5F00;
--sev-medium: #787221;
--sev-low: #336C8B;
}
/* Explicit Dark Theme: Overrides system light preference */
body.colorblind-mode.theme-dark {
--status-pending: #56B4E9;
--status-inreview: #E69F00;
--status-reviewed: #0072B2;
--status-failed: #D55E00;
--status-embargoed: #CC79A7;
--sev-critical: #D55E00;
--sev-high: #E69F00;
--sev-medium: #F0E442;
--sev-low: #56B4E9;
}
/* Explicit Theme Overrides */
body.theme-light {
color-scheme: light;
--bg: #f5f5f5;
--border: #ccc;
--border-light: #eee;
--text: #24292f;
--text-muted: #666;
--text-dim: #999;
--link: #007bff;
--hover: #e0e0e0;
--selected: #d0d0d0;
--code-bg: #f6f8fa;
--code-border: #d0d7de;
--btn-bg: #fff;
--input-bg: #fff;
--card-bg: #fff;
--ai-bg: #ffffff;
--ai-border: #283593;
--warn-bg: #fff3cd;
--warn-border: #ffeeba;
--warn-text: #856404;
--error-bg: #fff0f0;
--error-border: #ffcdd2;
--error-text: #d73a49;
--error-title: #b71c1c;
--thought-color: #6a737d;
--thought-border: #d0d7de;
--log-color: #000;
--th-bg: #fafafa;
--status-pending: #0366d6;
--status-inreview: #d67f05;
--status-reviewed: #22863a;
--status-failed: #d73a49;
--status-embargoed: #6f42c1;
--tag-bg: #e0f7fa;
--tag-text: #006064;
--diff-add-bg: #e6ffec;
--diff-rm-bg: #ffebe9;
--diff-header: #005cc5;
--role-user: #005cc5;
--role-tool: #3f51b5;
--fn-name: #6f42c1;
--sev-critical: #B71C1C;
--sev-high: #FF5722;
--sev-medium: #FF9800;
--sev-low: #FBC02D;
}
body.theme-dark {
color-scheme: dark;
--bg: #0d1117;
--border: #30363d;
--border-light: #21262d;
--text: #c9d1d9;
--text-muted: #8b949e;
--text-dim: #6e7681;
--link: #58a6ff;
--hover: #21262d;
--selected: #21262d;
--code-bg: #161b22;
--code-border: #30363d;
--btn-bg: #21262d;
--input-bg: #0d1117;
--card-bg: #161b22;
--ai-bg: #161b22;
--ai-border: #58a6ff;
--warn-bg: #3a2a00;
--warn-border: #755200;
--warn-text: #e5b300;
--error-bg: #3a0000;
--error-border: #750000;
--error-text: #ff6b6b;
--error-title: #ff8b8b;
--thought-color: #8b949e;
--thought-border: #30363d;
--log-color: #c9d1d9;
--th-bg: #161b22;
--status-pending: #58a6ff;
--status-inreview: #d29922;
--status-reviewed: #3fb950;
--status-failed: #f85149;
--status-embargoed: #d2a8ff;
--tag-bg: #032f62;
--tag-text: #79c0ff;
--diff-add-bg: rgba(46, 160, 67, 0.15);
--diff-rm-bg: rgba(248, 81, 73, 0.15);
--diff-header: #79c0ff;
--role-user: #79c0ff;
--role-tool: #82aaff;
--fn-name: #d2a8ff;
--sev-critical: #ff8b8b;
--sev-high: #ff9d7e;
--sev-medium: #ffc260;
--sev-low: #ffea7f;
}
* {
box-sizing: border-box;
}
body {
color-scheme: light dark;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.4;
max-width: 1200px;
margin: 0 auto;
padding: 12px 16px;
background-color: var(--bg);
color: var(--text);
}
h1 {
font-size: 2.0em;
margin: 0 0 16px 0;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
h2 {
font-size: 1.1em;
margin: 0 0 8px 0;
padding-bottom: 4px;
border-bottom: 1px solid var(--border-light);
}
h3 {
font-size: 1em;
margin: 12px 0 6px 0;
}
a {
color: var(--link);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Navigation */
.nav {
margin-bottom: 10px;
}
.nav a {
cursor: pointer;
}
/* Search and Controls */
.controls {
display: flex;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
align-items: center;
}
.controls input[type="text"] {
background: var(--input-bg);
color: var(--text);
flex: 1;
min-width: 200px;
padding: 4px 8px;
font-family: inherit;
font-size: inherit;
border: 1px solid var(--border);
border-radius: 3px;
}
.controls button,
button,
select {
padding: 4px 10px;
font-family: inherit;
font-size: inherit;
border: 1px solid var(--border);
background: var(--btn-bg);
color: var(--text);
border-radius: 3px;
cursor: pointer;
}
.controls button:hover,
button:hover,
select:hover {
background: var(--hover);
}
.controls button:disabled,
button:disabled,
select:disabled {
opacity: 0.5;
cursor: default;
}
.controls button.active {
background: var(--selected);
font-weight: bold;
}
/* Table Styles */
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 6px 8px;
border-bottom: 1px solid var(--border-light);
vertical-align: top;
}
th {
font-weight: 600;
background: var(--th-bg);
}
tr:hover {
background-color: var(--hover);
}
tr.selected {
background-color: var(--selected);
outline: 1px solid var(--border);
}
tr {
cursor: pointer;
}
/* Status Badge */
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85em;
font-weight: bold;
background: transparent;
border: none;
}
.status-badge::before {
content: '#';
display: block;
color: currentColor;
}
.status-Pending {
color: var(--status-pending);
}
.status-InReview,
.status-Analyzing,
.status-Reviewing {
color: var(--status-inreview);
}
.status-Finished,
.status-Applied,
.status-Reviewed {
color: var(--status-reviewed);
}
.status-Failed,
.status-FailedToApply {
color: var(--status-failed);
}
.status-Cancelled,
.status-Skipped {
color: var(--thought-color);
}
.status-Embargoed {
color: var(--status-embargoed);
}
.status-Incomplete {
color: var(--thought-color);
}
/* Subsystem Tags - UNCHANGED */
.tag {
display: inline-block;
background: var(--tag-bg);
color: var(--tag-text);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.75em;
margin-right: 4px;
border: 1px solid var(--tag-bg);
vertical-align: middle;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 12px;
font-size: 0.9em;
}
/* Stats Bar */
.stats-container {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
font-size: 0.9em;
color: var(--text-muted);
/* background: var(--btn-bg); */
/* border: 1px solid var(--border-light); */
border-radius: 4px;
padding: 8px 12px;
margin-top: 20px;
}
.stats-group {
display: flex;
align-items: center;
gap: 6px;
}
.stats-sep {
width: 1px;
height: 16px;
background: var(--border-light);
margin: 0 4px;
}
.stats-label {
font-weight: 600;
color: var(--text);
}
.stats-val {
font-weight: bold;
color: var(--text);
}
.stats-sub {
font-size: 0.85em;
opacity: 0.7;
}
.stats-link {
font-weight: 500;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.status-Pending { background-color: var(--status-pending); }
.status-dot.status-InReview { background-color: var(--status-inreview); }
.status-dot.status-Reviewed { background-color: var(--status-reviewed); }
.status-dot.status-Failed { background-color: var(--status-failed); }
.status-dot.status-FailedToApply { background-color: var(--status-failed); }
.status-dot.status-Embargoed { background-color: var(--status-embargoed); }
.status-dot.status-Cancelled { background-color: var(--thought-color); }
.status-dot.status-Incomplete { background-color: var(--thought-color); }
.status-dot.status-Skipped { background-color: var(--thought-color); }
/* Stats Footer */
.stats {
margin-top: 16px;
padding-top: 8px;
border-top: 1px solid var(--border);
font-size: 0.85em;
color: var(--text-muted);
}
/* Key-value display */
.kv {
display: grid;
grid-template-columns: 100px 1fr;
gap: 4px 8px;
margin-bottom: 4px;
font-size: 0.95em;
}
.kv .label {
font-weight: 600;
color: var(--text-muted);
}
/* Section containers */
.section {
margin-bottom: 16px;
}
/* Copy button */
.copy-btn {
cursor: pointer;
border: 1px solid var(--border);
background: var(--btn-bg);
padding: 1px 5px;
border-radius: 3px;
font-size: 11px;
margin-left: 6px;
}
.copy-btn:hover {
background: var(--hover);
}
.permalink-icon {
display: inline-flex;
align-items: center;
color: var(--text-muted);
opacity: 0.3;
transition: opacity 0.2s, color 0.2s;
margin-left: 8px;
text-decoration: none;
vertical-align: middle;
}
.permalink-icon:hover {
opacity: 1;
color: var(--link);
}
/* Review Cards */
.review-card {
border: 1px solid var(--border-light);
padding: 10px;
margin-bottom: 10px;
border-radius: 4px;
background: var(--btn-bg);
}
.review-meta {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
font-size: 0.9em;
color: var(--text-muted);
}
.review-meta-label {
font-weight: 600;
}
.review-meta-sep {
grid-column: 1 / -1;
border-top: 1px solid var(--border-light);
margin: 4px 0;
}
/* Collapsible sections */
.toggle-btn {
cursor: pointer;
color: var(--text-muted);
font-weight: 600;
font-size: 0.9em;
background: none;
border: none;
padding: 0;
font-family: inherit;
}
.toggle-btn:hover {
color: var(--link);
}
.collapsible {
display: none;
margin-top: 6px;
}
.collapsible.open {
display: block;
}
/* Code/Log blocks */
.log-block {
background: var(--code-bg);
color: var(--text);
padding: 8px;
white-space: pre-wrap;
font-family: inherit;
max-height: 400px;
overflow-y: auto;
border: 1px solid var(--code-border);
border-radius: 4px;
font-size: 12px;
line-height: 1.4;
}
.log-inline-block {
background: var(--code-bg);
color: var(--text);
padding: 8px;
white-space: pre-wrap;
font-family: inherit;
max-height: 4000px;
overflow-y: auto;
border: 1px solid var(--code-border);
border-radius: 4px;
font-size: 12px;
line-height: 1.4;
}
/* Diff highlighting */
.diff-line {
display: block;
padding: 0 4px;
min-height: 1.3em;
}
.diff-added {
background-color: var(--diff-add-bg);
}
.diff-removed {
background-color: var(--diff-rm-bg);
}
.diff-header {
color: var(--diff-header);
font-weight: bold;
}
.ai-comment {
background-color: var(--ai-bg);
border-left: 3px solid var(--ai-border);
padding-left: 12px;
font-weight: 500;
}
.ai-comment-critical {
border-left-color: var(--sev-critical) !important;
}
.ai-comment-high {
border-left-color: var(--sev-high) !important;
}
.ai-comment-medium {
border-left-color: var(--sev-medium) !important;
}
.ai-comment-low {
border-left-color: var(--sev-low) !important;
}
/* Thread tree */
.thread-tree {
font-size: 0.95em;
}
.thread-tree ul {
list-style: none;
padding-left: 0;
margin: 0;
}
.thread-tree li {
margin-left: 16px;
padding: 2px 0;
}
.thread-tree .date {
color: var(--text-muted);
font-size: 0.9em;
}
.thread-tree .current {
font-weight: bold;
background: var(--selected);
}
/* Log viewer roles */
.log-entry {
display: flex;
border-bottom: 1px solid var(--border-light);
padding: 4px 0;
}
.log-entry:hover {
background: var(--code-bg);
}
.log-gutter {
flex-shrink: 0;
width: 60px;
text-align: right;
padding-right: 10px;
font-weight: bold;
font-size: 0.85em;
color: var(--text-dim);
}
.log-content {
flex-grow: 1;
min-width: 0;
white-space: pre-wrap;
word-break: break-word;
}
.role-user .log-gutter {
color: var(--diff-header);
}
.role-model .log-gutter {
color: var(--text);
}
.role-system .log-gutter {
color: var(--status-inreview);
}
.role-tool .log-gutter {
color: var(--role-tool); /* material indigo */
}
.role-tool .log-content {
color: var(--log-color);
}
.fn-name {
font-weight: bold;
color: var(--fn-name);
}
/* Expandable JSON */
.json-expandable {
cursor: pointer;
}
.json-expandable:hover .json-preview {
text-decoration: underline;
}
.json-formatted {
background: var(--code-bg);
border: 1px solid var(--code-border);
border-radius: 3px;
padding: 6px;
margin: 4px 0 0 0;
font-family: inherit;
font-size: 12px;
line-height: 1.3;
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
}
/* Message view */
.message-meta {
color: var(--text-muted);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-light);
}
.message-meta div {
margin-bottom: 2px;
}
.message-meta .label {
font-weight: 600;
width: 50px;
display: inline-block;
}
pre.body {
color-scheme: light dark;
background: var(--code-bg);
padding: 10px;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
border: 1px solid var(--code-border);
border-radius: 4px;
margin: 0;
}
/* Keyboard hints */
.kbd-hint {
position: fixed;
bottom: 10px;
right: 10px;
background: var(--text);
color: var(--bg);
padding: 6px 10px;
border-radius: 4px;
font-size: 11px;
opacity: 0.6;
}
.kbd-hint:hover {
opacity: 1;
}
kbd {
background: rgba(255, 255, 255, 0.2);
padding: 1px 4px;
border-radius: 2px;
margin: 0 2px;
}
.json-preview {
color: var(--text-muted);
}
.json-size {
color: var(--border);
/* Light gray like buttons usually are */
font-size: 0.85em;
float: right;
margin-left: 8px;
}
/* Loading state */
.loading {
color: var(--text-muted);
padding: 20px;
text-align: center;
}
.error {
color: var(--status-failed);
padding: 20px;
}
/* Stats & Charts */
.chart-section {
margin-bottom: 30px;
background: var(--card-bg);
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border: 1px solid var(--border-light);
}
.stat-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
background: var(--card-bg);
border: 1px solid var(--border);
padding: 12px;
border-radius: 4px;
}
.stat-card h3 {
margin: 0 0 8px 0;
font-size: 0.9em;
text-transform: uppercase;
color: var(--text-muted);
letter-spacing: 0.5px;
}
.stat-card .value {
font-size: 1.8em;
font-weight: bold;
color: var(--text);
}
/* Modal Styles */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.open {
display: flex;
}
.modal-content {
background: var(--bg);
padding: 32px;
border-radius: 8px;
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
position: relative;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
border: 1px solid var(--border-light);
}
.modal-close {
position: absolute;
top: 16px;
right: 16px;
cursor: pointer;
font-size: 1.5em;
color: var(--text-muted);
line-height: 1;
padding: 4px;
}
.modal-close:hover {
color: var(--text);
}
.about-logo {
display: flex;
align-items: center;
margin-bottom: 24px;
font-size: 1.8em;
font-weight: bold;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.about-text p {
margin-bottom: 16px;
line-height: 1.6;
font-size: 1.05em;
}
.about-text blockquote {
border-left: 4px solid var(--border);
margin: 16px 0 24px 0;
padding: 12px 16px;
background: var(--code-bg);
color: var(--text-muted);
border-radius: 4px;
}
.about-text .disclaimer {
background-color: var(--warn-bg);
border: 1px solid var(--warn-border);
color: var(--warn-text);
padding: 12px;
border-radius: 4px;
margin-top: 24px;
}
/* Utility classes extracted from inline styles */
.status-placeholder {
font-style: italic;
color: var(--text-dim);
}
.text-success-bold {
color: var(--status-reviewed);
font-weight: bold;
}
.text-error-bold {
color: var(--error-text);
font-weight: bold;
}
.text-muted-sm {
color: var(--text-muted);
font-size: 0.85em;
}
.header-subtitle {
font-size: 0.7em;
font-weight: normal;
color: var(--text-dim);
}
.thought-block {
color: var(--thought-color);
font-style: italic;
border-left: 2px solid var(--thought-border);
padding-left: 8px;
margin: 4px 0;
white-space: pre-wrap;
}
.bl-table-header {
text-align: left;
padding: 6px;
border-bottom: 1px solid var(--code-border);
}
.bl-table-cell {
padding: 6px;
border-bottom: 1px solid var(--border-light);
}
.chart-container {
width: 100%;
height: 300px;
}
.chart-container-lg {
width: 100%;
height: 500px;
}
.error-banner {
background: var(--error-bg);
border: 1px solid var(--error-border);
color: var(--error-text);
padding: 12px;
border-radius: 4px;
margin-bottom: 8px;
}
.error-banner-pre {
font-family: monospace;
white-space: pre-wrap;
font-size: 0.9em;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
</head>
<body>
<div id="app"></div>
<div class="kbd-hint">
<kbd>/</kbd> search
<kbd>j</kbd><kbd>k</kbd> nav
<kbd>Enter</kbd> open
<kbd>q</kbd> back
</div>
<div id="settingsModal" class="modal-overlay" onclick="if(event.target===this) closeSettings()">
<div class="modal-content">
<span class="modal-close" onclick="closeSettings()">×</span>
<div class="about-logo">
<svg style="height: 1em; margin-right: 12px;" viewBox="0 0 16 16" width="24" height="24" fill="currentColor"><path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 10.196.256 10.92.507c.052.018.103.037.154.057l.01.004c.431.166.844.387 1.236.657.352.243.68.522.98.833.3.311.57.652.804 1.018.234.367.43.76.585 1.177.025.066.048.133.07.2.145.43.25.874.316 1.332.03.207.045.418.045.629 0 .211-.015.422-.045.629a8.225 8.225 0 0 1-.316 1.332c-.022.067-.045.134-.07.2-.154.416-.351.81-.585 1.177-.234.366-.504.707-.804 1.018-.3.311-.628.59-0.98.833a8.163 8.163 0 0 1-1.236.657l-.01.004c-.051.02-.102.039-.154.057a8.217 8.217 0 0 1-2.219.538c-.23.015-.461.023-.692.023-.23 0-.461-.008-.692-.023a8.217 8.217 0 0 1-2.219-.538c-.052-.018-.103-.037-.154-.057l-.01-.004a8.163 8.163 0 0 1-1.236-.657c-.352-.243-.68-.522-.98-.833a8.14 8.14 0 0 1-.804-1.018 8.12 8.12 0 0 1-.585-1.177c-.025-.066-.048-.133-.07-.2a8.225 8.225 0 0 1-.316-1.332A8.156 8.156 0 0 1 0 8c0-.211.015-.422.045-.629a8.225 8.225 0 0 1 .316-1.332c.022-.067.045-.134.07-.2a8.12 8.12 0 0 1 .585-1.177c.234-.366.504-.707.804-1.018.3-.311.628-.59.98-.833a8.163 8.163 0 0 1 1.236-.657l.01-.004c.051-.02.102-.039.154-.057a8.217 8.217 0 0 1 2.219-.538C7.539.008 7.769 0 8 0Zm0 2.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Zm0 2a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7Z"></path></svg>
Settings
</div>
<div class="about-text">
<div style="margin-bottom: 24px;">
<h3 style="margin-top: 0;">Appearance</h3>
<div style="display: flex; flex-direction: column; gap: 12px;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<span>Theme</span>
<select id="themeSelect" onchange="setTheme(this.value)">
<option value="auto">System Default</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
<div style="display: flex; align-items: center; justify-content: space-between;">
<span>Colorblind Mode</span>
<label class="switch">
<input type="checkbox" id="colorblindToggle" onchange="setColorblind(this.checked)" style="opacity: 0; width: 0; height: 0;">
<span class="slider"></span>
</label>
</div>
</div>
</div>
<div class="disclaimer" style="margin-top: 24px; font-size: 0.9em;">
Settings are saved in your browser's cookies and will persist across sessions.
</div>
</div>
</div>
</div>
<style>
/* Toggle Switch Styling */
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 20px;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--border);
transition: .4s;
border-radius: 20px;
}
.switch input:checked + .slider { background-color: var(--status-reviewed); }
.switch input:focus + .slider { box-shadow: 0 0 1px var(--status-reviewed); }
.switch input:checked + .slider:before { transform: translateX(20px); }
.slider:before {
position: absolute; content: ""; height: 16px; width: 16px; left: 2px; bottom: 2px;
background-color: white; transition: .4s; border-radius: 50%;
}
</style>
<div id="aboutModal" class="modal-overlay" onclick="if(event.target===this) closeAbout()">
<div class="modal-content">
<span class="modal-close" onclick="closeAbout()">×</span>
<div class="about-logo">
<img src="static/logo.png" style="height: 1.5em; margin-right: 12px;" alt="Sashiko Logo">
About Sashiko
</div>
<div class="about-text" id="about-text-content">
<blockquote>
<strong>Sashiko</strong> (刺し子, literally "little stabs") is a form of decorative reinforcement stitching from Japan. Originally used to reinforce points of wear or to repair worn places or tears with patches, here it represents our mission to reinforce the Linux kernel through automated, intelligent patch review.
</blockquote>
<p>
Sashiko is an agentic Linux kernel code review system. It monitors public mailing lists to thoroughly evaluate proposed Linux kernel changes. The system acts like a team of specialized reviewers covering domains from high-level architecture verification and security audits to low-level resource management and concurrency analysis.
</p>
<p>
It relies on an open-source set of <a href="https://github.com/masoncl/review-prompts" target="_blank" rel="noopener noreferrer" style="font-weight: 500;">per-subsystem and generic prompts</a> initially created by Chris Mason, combined with a custom multi-stage review protocol to maximize accuracy and minimize false positives.
</p>
<p>
This is an open-source project that belongs to the <strong>Linux Foundation</strong>, licensed under the <strong>Apache License, Version 2.0</strong>.
</p>
<p>
This particular instance of Sashiko is provided as a service and is actively <strong>reviewing all LKML (Linux Kernel Mailing List) submissions</strong>. All compute resources and LLM tokens utilized for these automated reviews are proudly provided and funded by <strong>Google</strong>.
</p>
</div>
<div style="margin-top: 32px; text-align: center;">
<div style="margin-top: 32px; text-align: center;">
<a href="https://github.com/sashiko-dev/sashiko" target="_blank" rel="noopener noreferrer" style="display: inline-flex; align-items: center; gap: 8px; font-weight: bold; color: var(--text); padding: 8px 16px; border: 1px solid var(--border); border-radius: 6px; background: var(--btn-bg); text-decoration: none; box-shadow: 0 1px 2px rgba(0,0,0,0.05);">
<svg height="20" aria-hidden="true" viewBox="0 0 16 16" version="1.1" width="20" data-view-component="true" style="fill: currentColor;">
<path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"></path>
</svg>
View Project on GitHub
</a>
</div>
</div>
</div>
</div>
<script>
// =====================================================================
// STATE
// =====================================================================
const state = {
view: 'list',
listMode: 'patchsets',
currentPage: 1,
perPage: 50,
totalPages: 1,
searchQuery: '',
selectedIndex: -1,
stats: null,
mailingLists: [],
selectedList: '',
patchsetId: null,
patchsetPage: 1,
patchsetTotalPages: 1,
patchsetLimit: 50,
settings: {
theme: 'auto',
colorblind: false
},
statsData: null,
activeCharts: []
};
// =====================================================================
// SAFE STORAGE HACKS
// =====================================================================
const safeStorage = {
getItem(key) {
try {
return localStorage.getItem(key);
} catch (e) {
console.warn("localStorage is not available:", e);
return null;
}
},
setItem(key, value) {
try {
localStorage.setItem(key, value);
} catch (e) {
console.warn("localStorage is not available:", e);
}
}
};
// =====================================================================
// SETTINGS & LOCAL STORAGE
// =====================================================================
function applySettings() {
const body = document.body;
// Apply Theme
body.classList.remove('theme-light', 'theme-dark');
if (state.settings.theme === 'light') body.classList.add('theme-light');
else if (state.settings.theme === 'dark') body.classList.add('theme-dark');
// Apply Colorblind Mode
if (state.settings.colorblind) body.classList.add('colorblind-mode');
else body.classList.remove('colorblind-mode');
// Sync UI
const themeSelect = document.getElementById('themeSelect');
if (themeSelect) themeSelect.value = state.settings.theme;
const cbToggle = document.getElementById('colorblindToggle');
if (cbToggle) cbToggle.checked = state.settings.colorblind;
}
function setTheme(val) {
state.settings.theme = val;
safeStorage.setItem('sashiko_theme', val);
applySettings();
}
function setColorblind(val) {
state.settings.colorblind = !!val;
safeStorage.setItem('sashiko_colorblind', state.settings.colorblind ? '1' : '0');
applySettings();
}
function loadSettings() {
const theme = safeStorage.getItem('sashiko_theme');
if (theme) state.settings.theme = theme;
const cb = safeStorage.getItem('sashiko_colorblind');
if (cb) state.settings.colorblind = cb === '1';
applySettings();
}
function openSettings() {
document.getElementById('settingsModal').classList.add('open');
}
function closeSettings() {
document.getElementById('settingsModal').classList.remove('open');
}
// =====================================================================
// ROUTER
// =====================================================================
function parseHash() {
let hash = window.location.hash || '#/';
let search = window.location.search || '';
let query = {};
if (search.startsWith('?')) {
const qStr = search.substring(1);
qStr.split('&').forEach(part => {
const [k, v] = part.split('=');
if (k) query[decodeURIComponent(k)] = decodeURIComponent(v || '');
});
}
const qIndex = hash.indexOf('?');
if (qIndex !== -1) {
const qStr = hash.substring(qIndex + 1);
hash = hash.substring(0, qIndex);
qStr.split('&').forEach(part => {
const [k, v] = part.split('=');
if (k) query[decodeURIComponent(k)] = decodeURIComponent(v || '');
});
}
if (hash === '#/' || hash === '#') {
return { view: 'list', query };
}
const patchMatch = hash.match(/^#\/patchset\/(.+)$/);
if (patchMatch) {
return { view: 'patchset', id: decodeURIComponent(patchMatch[1]), query };
}
const msgMatch = hash.match(/^#\/message\/(.+)$/);
if (msgMatch) {
return { view: 'message', id: decodeURIComponent(msgMatch[1]), query };
}
const baselineLogMatch = hash.match(/^#\/log\/baseline\/(.+)\/(\d+)$/);
if (baselineLogMatch) {
return { view: 'baseline_log', id: baselineLogMatch[1], idx: parseInt(baselineLogMatch[2]), query };
}
const logMatch = hash.match(/^#\/log\/(\d+)$/);
if (logMatch) {
return { view: 'log', id: logMatch[1], query };
}
if (hash === '#/stats') {
return { view: 'stats', query };
}
return { view: 'list', query };
}
let hasNavigatedInternally = false;
function navigate(hash) {
hasNavigatedInternally = true;
window.location.hash = hash;
}
function goBack() {
if (hasNavigatedInternally) {
window.history.back();
} else {
navigate('#/');
}
}
let config = {};
async function fetchConfig() {
try {
const res = await fetch('/api/config');
if (res.ok) {
config = await res.json();
document.title = config.project_name;
document.getElementById('about-text-content').innerHTML = `
<p>${escapeHtml(config.project_description)}</p>
`;
const els = document.querySelectorAll('.header-title, .about-logo');
els.forEach(el => {
el.innerHTML = `<img src="static/logo.png" style="height: 1.5em; margin-right: 12px;" alt="Logo"> ${escapeHtml(config.project_name)}`;
});
}
} catch(e) { console.error("Failed to load config", e); }
}
async function router() {
const route = parseHash();
const oldView = state.view;
state.view = route.view;
state.selectedIndex = -1;
// Handle list param
if (route.query && typeof route.query.list !== 'undefined') {
state.selectedList = route.query.list;
}
// Clear ECharts cache if navigating to stats from elsewhere
if (oldView !== 'stats' && state.view === 'stats') {
state.statsData = null;
}
clearActiveCharts();
const app = document.getElementById('app');
app.innerHTML = '<div class="loading">Loading...</div>';
try {
// Ensure lists are loaded
if (state.mailingLists.length === 0) {
await fetchMailingLists();
}
switch (route.view) {
case 'list':
await renderListView();
break;
case 'patchset':
await renderPatchsetView(route.id, route.query || {});
break;
case 'message':
await renderMessageView(route.id);
break;
case 'log':
await renderLogView(route.id);
break;
case 'baseline_log':
await renderBaselineLogView(route.id, route.idx);
break;
case 'stats':
await renderStatsView();
break;
default:
await renderListView();
}
} catch (e) {
app.innerHTML = `<div class="error">Error: ${escapeHtml(e.message)}</div>`;
}
}
window.addEventListener('hashchange', () => {
hasNavigatedInternally = true;
fetchConfig().then(router);
});
// =====================================================================
// DATA FETCHING
// =====================================================================
async function fetchMailingLists() {
try {
const res = await fetch('/api/lists');
if (res.ok) {
const lists = await res.json();
lists.sort((a, b) => a.name.localeCompare(b.name));
state.mailingLists = lists;
}
} catch (e) {
console.error("Failed to fetch mailing lists", e);
}
}
// =====================================================================
// UTILITIES
// =====================================================================
function clearActiveCharts() {
if (state.activeCharts) {
state.activeCharts.forEach(chart => {
try {
chart.dispose();
} catch (e) {
console.error("Failed to dispose chart", e);
}
});
}
state.activeCharts = [];
}
// Global resize listener for ECharts
window.addEventListener('resize', () => {
if (state.activeCharts) {
state.activeCharts.forEach(chart => {
if (chart && typeof chart.resize === 'function') {
chart.resize();
}
});
}
});
function openAbout() {
document.getElementById('aboutModal').classList.add('open');
}
function closeAbout() {
document.getElementById('aboutModal').classList.remove('open');
}
function parseAuthor(authorStr) {
if (!authorStr) return { name: '', email: 'unknown' };
authorStr = authorStr.trim();
const match = authorStr.match(/^(?:"?([^"]*)"?\s+)?<([^>]+)>$/);
if (match) {
let name = match[1] ? match[1].trim() : '';
const email = match[2].trim();
if (name.toLowerCase() === 'unknown') {
name = '';
}
return { name, email };
}
if (authorStr.includes('@')) {
return { name: '', email: authorStr };
}
if (authorStr.toLowerCase() === 'unknown') {
return { name: '', email: 'unknown' };
}
return { name: authorStr, email: '' };
}
function formatAuthorList(str) {
if (!str) return '-';
const parts = str.split(',');
return parts.map(p => {
const a = parseAuthor(p.trim());
return a.name ? `${a.name} <${a.email}>` : a.email;
}).join(', ');
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeAbout();
});
function escapeHtml(text, highlightDiff = false, renderAiComments = false) {
if (!text) return '';
const escaped = String(text)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
if (highlightDiff) {
const lines = escaped.split('\n');
const hasQuotes = lines.some(l => l.trimStart().startsWith('>')); // Escaped >
let inDiffBlock = false;
let currentBlockIsQuoted = false;
let seenDiffOrQuote = false;
// Pass 1: Classify
const classified = lines.map(line => {
const trimmed = line.trimStart();
const isQuoted = trimmed.startsWith('>');
let content = line;
if (isQuoted) {
content = trimmed.substring(4);
if (content.startsWith(' ')) content = content.substring(1);
}
const isHeader = content.startsWith('diff --git') ||
content.startsWith('Index: ') ||
content.startsWith('--- ') ||
content.startsWith('+++ ') ||
content.startsWith('@@ ');
const isExtendedHeader = /^(new|old|deleted) file mode|similarity index|copy (from|to)|rename (from|to)/.test(content);
if (isHeader || isExtendedHeader || isQuoted) {
seenDiffOrQuote = true;
}
const isMeta = content.match(/^(commit [0-9a-f]+|Author:|Date:|Subject:|From:|To:|Cc:|Signed-off-by:)/i) && !renderAiComments;
let type = 'TEXT'; // Default to comment candidate
let isDefiniteDiff = false;
if (isHeader || isExtendedHeader) {
isDefiniteDiff = true;
inDiffBlock = true;
currentBlockIsQuoted = isQuoted;
} else if (hasQuotes) {
if (isQuoted) {
isDefiniteDiff = true;
} else {
if (isMeta) isDefiniteDiff = true;
else if (inDiffBlock) {
if (currentBlockIsQuoted) {
// Breaking out of a quoted block
inDiffBlock = false;
} else {
if (content.startsWith('+') && !content.startsWith('+++')) isDefiniteDiff = true;
else if (content.startsWith('-') && !content.startsWith('---') && content.trim() !== '--') isDefiniteDiff = true;
else if (content.startsWith(' ') || content.startsWith('\\') || content.startsWith('\t')) isDefiniteDiff = true;
}
}
}
} else {
if (isMeta) isDefiniteDiff = true;
else if (inDiffBlock) {
if (content.startsWith('+') && !content.startsWith('+++')) isDefiniteDiff = true;
else if (content.startsWith('-') && !content.startsWith('---')) isDefiniteDiff = true;
else if (content.startsWith(' ') || content.startsWith('\\') || content.startsWith('\t')) isDefiniteDiff = true;
}
}
if (isDefiniteDiff) {
type = 'DIFF';
} else if (line.trim() === '') {
type = 'EMPTY';
}
// Parse severity if this is a potential comment candidate
let severity = null;
let displayLine = line;
if (type === 'TEXT' && renderAiComments) {
const severityMatch = line.match(/\[Severity:\s*(Critical|High|Medium|Low)\]/i);
if (severityMatch) {
severity = severityMatch[1].toLowerCase();
displayLine = line.replace(/\[Severity:\s*(Critical|High|Medium|Low)\]/i, '').trim();
}
}
// Store metadata for rendering
let cls = 'diff-line';
if (isHeader) cls += ' diff-header';
else if (isDefiniteDiff) {
if (content.startsWith('+') && !content.startsWith('+++')) cls += ' diff-added';
else if (content.startsWith('-') && !content.startsWith('---') && content.trim() !== '--') cls += ' diff-removed';
}
return { line: displayLine, type, cls, severity };
});
// Pass 2: Group into logical blocks and propagate severity
let blocks = [];
let currentBlock = null;
const flushCurrentBlock = () => {
if (currentBlock) {
let trailingEmpty = [];
while (currentBlock.items.length > 0 && currentBlock.items[currentBlock.items.length - 1].type === 'EMPTY') {
trailingEmpty.unshift(currentBlock.items.pop());
}
currentBlock = null;
trailingEmpty.forEach(e => blocks.push({ type: 'EMPTY', item: e }));
}
};
for (let i = 0; i < classified.length; i++) {
const item = classified[i];
if (item.type === 'TEXT') {
if (item.severity) {
flushCurrentBlock();
}
if (!currentBlock) {
currentBlock = { type: 'COMMENT', items: [], severity: null };
blocks.push(currentBlock);
}
currentBlock.items.push(item);
if (item.severity) {
currentBlock.severity = item.severity;
}
} else if (item.type === 'EMPTY') {
if (currentBlock) {
currentBlock.items.push(item);
} else {
blocks.push({ type: 'EMPTY', item: item });
}
} else { // DIFF
flushCurrentBlock();
blocks.push({ type: 'DIFF', item: item });
}
}
// Handle end of loop trailing empty lines
if (currentBlock) {
while (currentBlock.items.length > 0 && currentBlock.items[currentBlock.items.length - 1].type === 'EMPTY') {
currentBlock.items.pop(); // Drop trailing blank lines completely to prevent bloating
}
}
// Render blocks
let resultHtml = '';
blocks.forEach(block => {
if (block.type === 'COMMENT') {
const sevClass = block.severity ? ` ai-comment-${block.severity}` : '';
block.items.forEach(item => {
if (item.line === "" && item.severity !== null) {
return; // Skip the tag-only line
}
let c = item.cls;
if (renderAiComments) c += ' ai-comment' + sevClass;
resultHtml += `<div class="${c}">${item.line}</div>`;
});
} else if (block.type === 'EMPTY') {
resultHtml += `<div class="${block.item.cls}">${block.item.line}</div>`;
} else if (block.type === 'DIFF') {
resultHtml += `<div class="${block.item.cls}">${block.item.line}</div>`;
}
});
return resultHtml;
}
return escaped;
}
function formatDate(unix, full = false) {
const d = new Date(unix * 1000);
return full ? d.toLocaleString() : d.toISOString().split('T')[0];
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
console.log('Copied:', text);
});
}
function renderStatCard(title, value) {
return `
<div class="stat-card">
<h3>${escapeHtml(title)}</h3>
<div class="value">${escapeHtml(String(value))}</div>
</div>
`;
}
function formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function toggleCollapsible(id, btn) {
const el = document.getElementById(id);
if (el.classList.contains('open')) {
el.classList.remove('open');
if (btn) btn.innerText = btn.innerText.replace('▼', '▶');
} else {
el.classList.add('open');
if (btn) btn.innerText = btn.innerText.replace('▶', '▼');
}
}
function renderFindingsBadges(counts, status) {
const { low = 0, medium = 0, high = 0, critical = 0 } = counts;
if (low + medium + high + critical === 0) {
if (status === 'Reviewed' || status === 'Finished') {
return '<span style="color:var(--status-reviewed);">✓</span>';
}
return '<span style="color:var(--text-dim)">-</span>';
}
return `
<div style="display:inline-flex; gap:6px; font-weight:bold; font-size:0.9em; vertical-align:middle;">
<span style="color:${critical > 0 ? 'var(--sev-critical)' : 'var(--text-dim)'}" title="Critical">${critical}</span>
<span style="color:${high > 0 ? 'var(--sev-high)' : 'var(--text-dim)'}" title="High">${high}</span>
<span style="color:${medium > 0 ? 'var(--sev-medium)' : 'var(--text-dim)'}" title="Medium">${medium}</span>
<span style="color:${low > 0 ? 'var(--sev-low)' : 'var(--text-dim)'}" title="Low">${low}</span>
</div>
`;
}
function sortToolArgs(entries) {
const priorityKeys = ['file_path', 'path', 'dir_path', 'filename', 'pattern', 'command', 'instruction', 'old_string', 'new_string', 'content', 'objective', 'fact'];
function getKeyKey(k) {
const pIdx = priorityKeys.indexOf(k);
if (pIdx !== -1) return String.fromCharCode(32 + pIdx);
if (k.startsWith('start_')) return k.substring(6) + '_0';
if (k.startsWith('end_')) return k.substring(4) + '_1';
return 'z' + k;
}
return entries.sort((a, b) => {
const ka = getKeyKey(a[0]);
const kb = getKeyKey(b[0]);
return ka.localeCompare(kb);
});
}
function updateUrlWithList() {
let url = new URL(window.location.href);
let searchParams = new URLSearchParams(window.location.search);
// Check if 'list' is in the query string
if (searchParams.has('list') || window.location.search.startsWith('?list=')) {
if (state.selectedList) {
searchParams.set('list', state.selectedList);
} else {
searchParams.delete('list');
}
let searchStr = searchParams.toString() ? '?' + searchParams.toString() : '';
window.history.replaceState(null, null, window.location.pathname + searchStr + window.location.hash);
return;
}
let hash = url.hash;
let queryPart = '';
const qIndex = hash.indexOf('?');
if (qIndex !== -1) {
hash = hash.substring(0, qIndex);
}
if (!hash) {
hash = '#/';
}
// Rebuild query
const params = new URLSearchParams();
if (state.selectedList) {
params.set('list', state.selectedList);
}
if (params.toString()) {
queryPart = '?' + params.toString();
}
// Update without reload
window.history.replaceState(null, null, window.location.pathname + window.location.search + hash + queryPart);
}
function setMailingList(list) {
state.selectedList = list;
state.currentPage = 1;
updateUrlWithList();
fetchListData();
}
// =====================================================================
// LIST VIEW
// =====================================================================
async function renderListView() {
const app = document.getElementById('app');
const listOptions = state.mailingLists.map(l =>
`<option value="${escapeHtml(l.group)}" ${state.selectedList === l.group ? 'selected' : ''}>${escapeHtml(l.name)}</option>`
).join('');
app.innerHTML = `
<h1>
<span style="display:flex; align-items:center; cursor:pointer;" onclick="openAbout()">
<img src="static/logo.png" style="height: 1.2em; margin-right: 12px;">
Sashiko
</span>
</h1>
<div class="controls">
<select id="listSelector" onchange="setMailingList(this.value)">
<option value="">All Lists</option>
${listOptions}
</select>
<input type="text" id="searchInput" placeholder="Search (subject, author:, date:)" value="${escapeHtml(state.searchQuery)}">
<button onclick="triggerSearch()">Search</button>
<button onclick="clearSearch()">Clear</button>
<div style="flex:1"></div>
<button id="btn-patchsets" onclick="setListMode('patchsets')" class="${state.listMode === 'patchsets' ? 'active' : ''}">Patchsets</button>
<button id="btn-messages" onclick="setListMode('messages')" class="${state.listMode === 'messages' ? 'active' : ''}">Messages</button>
</div>
<table id="dataTable">
<thead id="tableHead"></thead>
<tbody id="tableBody"></tbody>
</table>
<div class="pagination" id="pagination"></div>
<div class="stats" id="stats">Loading stats...</div>
`;
updateTableHeader();
await fetchListData();
fetchStats();
document.getElementById('searchInput').addEventListener('keypress', e => {
if (e.key === 'Enter') triggerSearch();
});
}
function updateTableHeader() {
const thead = document.getElementById('tableHead');
if (!thead) return;
if (state.listMode === 'patchsets') {
thead.innerHTML = `<tr><th>Subject</th><th style="width:180px">Author</th><th style="width:100px">Date</th><th style="width:50px">Parts</th><th style="width:80px">Status</th><th style="width:120px">Findings</th></tr>`;
} else {
thead.innerHTML = `<tr><th>Subject</th><th style="width:180px">Author</th><th style="width:100px">Date</th><th style="width:30px"></th></tr>`;
}
}
async function fetchListData() {
const endpoint = state.listMode === 'patchsets' ? '/api/patchsets' : '/api/messages';
let url = `${endpoint}?page=${state.currentPage}&per_page=${state.perPage}`;
if (state.searchQuery) {
url += `&q=${encodeURIComponent(state.searchQuery)}`;
}
if (state.selectedList) {
url += `&mailing_list=${encodeURIComponent(state.selectedList)}`;
}
const res = await fetch(url);
const data = await res.json();
const items = data.items || [];
const total = data.total || 0;
state.totalPages = Math.ceil(total / state.perPage) || 1;
const tbody = document.getElementById('tableBody');
tbody.innerHTML = '';
items.forEach((p, idx) => {
const row = document.createElement('tr');
const d = new Date(p.date * 1000);
const dateStr = d.toISOString().split('T')[0];
const timeStr = d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
const dateHtml = `
<div>${dateStr}</div>
<div class="text-muted-sm">${timeStr}</div>
`;
// Parse author
const author = parseAuthor(p.author);
const nameToDisplay = author.name || author.email;
const emailToDisplay = author.name ? author.email : '';
const authorHtml = `
<div>${escapeHtml(nameToDisplay)}</div>
${emailToDisplay ? `<div class="text-muted-sm">${escapeHtml(emailToDisplay)}</div>` : ''}
`;
if (state.listMode === 'patchsets') {
const parts = (p.received_parts === p.total_parts && p.total_parts > 0)
? `${p.total_parts}`
: `${p.received_parts || 0}/${p.total_parts || 0}`;
const clickHandler = (e) => {
if (e.target.closest('.copy-btn')) return;
const target = `#/patchset/${encodeURIComponent(p.slug || p.message_id || p.id)}`;
if (e.button === 1 || e.ctrlKey || e.metaKey) {
window.open(target, '_blank');
} else if (e.button === 0) {
navigate(target);
}
};
row.onclick = clickHandler;
row.onauxclick = clickHandler;
let tagsHtml = '';
if (p.subsystems && Array.isArray(p.subsystems) && p.subsystems.length > 0) {
tagsHtml = '<div style="margin-top:2px">' + p.subsystems.map(s => `<span class="tag">${escapeHtml(s)}</span>`).join('') + '</div>';
}
let statusLabel = p.status || 'Pending';
let findingsHtml = '-';
if (p.findings_low !== undefined || p.findings_medium !== undefined || p.findings_high !== undefined || p.findings_critical !== undefined) {
const counts = {
low: p.findings_low || 0,
medium: p.findings_medium || 0,
high: p.findings_high || 0,
critical: p.findings_critical || 0
};
findingsHtml = renderFindingsBadges(counts, statusLabel);
}
row.innerHTML = `
<td><div>${escapeHtml(p.subject) || '(no subject)'}</div>${tagsHtml}</td>
<td style="line-height:1.2;">${authorHtml}</td>
<td style="line-height:1.2;">${dateHtml}</td>
<td>${parts}</td>
<td><span class="status-badge status-${statusLabel.replace(/ /g, '')}">${statusLabel}</span></td>
<td>${findingsHtml}</td>
`;
} else {
const clickHandler = (e) => {
if (e.target.closest('.copy-btn')) return;
const target = `#/message/${encodeURIComponent(p.message_id)}`;
if (e.button === 1 || e.ctrlKey || e.metaKey) {
window.open(target, '_blank');
} else if (e.button === 0) {
navigate(target);
}
};
row.onclick = clickHandler;
row.onauxclick = clickHandler;
row.innerHTML = `
<td>${escapeHtml(p.subject) || '(no subject)'}</td>
<td style="line-height:1.2;">${authorHtml}</td>
<td style="line-height:1.2;">${dateHtml}</td>
<td><button class="copy-btn" onclick="event.stopPropagation(); copyToClipboard('${escapeHtml(p.message_id)}')">📋</button></td>
`;
}
tbody.appendChild(row);
});
updatePagination(total);
}
function updatePagination(total) {
const el = document.getElementById('pagination');
el.innerHTML = `
<button onclick="goToPage(1)" ${state.currentPage <= 1 ? 'disabled' : ''}>First</button>
<button onclick="changePage(-1)" ${state.currentPage <= 1 ? 'disabled' : ''}>Prev</button>
<span>Page ${state.currentPage} of ${state.totalPages} (${total} total)</span>
<button onclick="changePage(1)" ${state.currentPage >= state.totalPages ? 'disabled' : ''}>Next</button>
<button onclick="goToPage(${state.totalPages})" ${state.currentPage >= state.totalPages ? 'disabled' : ''}>Last</button>
`;
}
async function fetchStats() {
try {
const res = await fetch('/api/stats');
const data = await res.json();
state.stats = data;
document.getElementById('stats').outerHTML = `
<div class="stats-container" id="stats">
<div class="stats-group">
<span class="stats-label">Sashiko</span> v${data.version}
<span class="stats-sep"></span>
<a href="#" onclick="event.preventDefault(); openAbout();" style="font-weight:500;">About</a>
<span class="stats-sep"></span>
<a href="#" onclick="event.preventDefault(); openSettings();" style="font-weight:500;">Settings</a>
<span class="stats-sep"></span>
<a href="#/stats" style="font-weight:500;">Stats</a>
</div>
<div class="stats-sep"></div>
<div class="stats-group">
<span class="stats-val">${data.patchsets}</span> Patchsets
<span class="stats-sub">(${data.messages} msgs)</span>
</div>
<div class="stats-sep"></div>
<div class="stats-group" title="Pending patches to review">
<span class="status-dot status-Pending"></span> ${data.pending || 0} Pending
</div>
<div class="stats-group" title="Patches currently in review">
<span class="status-dot status-InReview"></span> ${data.reviewing || 0} In Review
</div>
</div>
`;
} catch (e) {
console.error(e);
document.getElementById('stats').innerHTML = 'Stats unavailable';
}
}
function triggerSearch() {
const input = document.getElementById('searchInput');
state.searchQuery = input.value.trim();
state.currentPage = 1;
fetchListData();
}
function clearSearch() {
document.getElementById('searchInput').value = '';
state.searchQuery = '';
state.currentPage = 1;
fetchListData();
}
function setListMode(mode) {
if (state.listMode === mode) return;
state.listMode = mode;
state.currentPage = 1;
document.getElementById('btn-patchsets').classList.toggle('active', mode === 'patchsets');
document.getElementById('btn-messages').classList.toggle('active', mode === 'messages');
updateTableHeader();
fetchListData();
}
async function changePage(delta) {
const newPage = state.currentPage + delta;
if (newPage < 1 || newPage > state.totalPages) return;
state.currentPage = newPage;
await fetchListData();
}
async function goToPage(page) {
if (page < 1 || page > state.totalPages) return;
state.currentPage = page;
await fetchListData();
}
// =====================================================================
// PATCHSET DETAIL VIEW
// =====================================================================
async function renderPatchsetView(id, query = {}) {
const page = parseInt(query.page) || 1;
const limit = parseInt(query.limit) || 50;
const res = await fetch(`/api/patchset?id=${encodeURIComponent(id)}&page=${page}&limit=${limit}`);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const data = await res.json();
state.patchsetId = id;
state.patchsetPage = data.page || page;
state.patchsetLimit = data.limit || limit;
state.patchsetTotalPages = data.total_patches_in_db > 0 ? Math.ceil(data.total_patches_in_db / state.patchsetLimit) : 1;
const date = formatDate(data.date, true);
const patchMap = new Map();
if (data.patches) data.patches.forEach(p => patchMap.set(p.id, p));
const reviewsByPatch = new Map();
if (data.patches) data.patches.forEach(p => reviewsByPatch.set(p.id, []));
reviewsByPatch.set(null, []);
if (data.reviews) {
data.reviews.forEach(r => {
const pid = r.patch_id || null;
if (!reviewsByPatch.has(pid)) reviewsByPatch.set(pid, []);
reviewsByPatch.get(pid).push(r);
});
}
let reviewsHtml = '';
const renderReviewList = (reviews, emailHtml = '', patchStatus = null) => {
if (!reviews || reviews.length === 0) {
const statusToCheck = patchStatus || data.status;
if (statusToCheck === 'Embargoed' && data.embargo_until) {
return `<p style="font-style:italic; color:var(--status-embargoed);">Embargoed until ${formatDate(data.embargo_until, true)}</p>`;
}
return `<p class="status-placeholder">No reviews yet.</p>`;
}
return reviews.map((r, i) => renderReviewCard(r, i === reviews.length - 1 ? emailHtml : '')).join('');
};
const generalReviews = reviewsByPatch.get(null) || [];
if (generalReviews.length > 0) {
reviewsHtml += `<h3>General Reviews</h3>${renderReviewList(generalReviews)}`;
}
const sortedPatchIds = Array.from(reviewsByPatch.keys())
.filter(id => id !== null)
.sort((a, b) => {
const pa = patchMap.get(a);
const pb = patchMap.get(b);
return (pa?.part_index || 0) - (pb?.part_index || 0);
});
let summaryHtml = '';
if (sortedPatchIds.length > 1) {
summaryHtml = '<table style="margin-bottom: 20px;"><thead><tr><th>Patch</th><th>Potential Regressions</th></tr></thead><tbody>';
}
sortedPatchIds.forEach((pid, index) => {
const patch = patchMap.get(pid);
const reviews = reviewsByPatch.get(pid);
let header = patch
? `Patch ${patch.part_index}: <a href="#/message/${encodeURIComponent(patch.message_id)}">${escapeHtml(patch.subject || '(no subject)')}</a>`
: `Patch ID ${pid}`;
const patchLink = window.location.origin + window.location.pathname + `#/patchset/${encodeURIComponent(state.patchsetId)}?${patch ? 'part=' + patch.part_index : 'patch=' + pid}`;
header += `<a href="${patchLink}" class="permalink-icon" onclick="copyToClipboard('${patchLink}'); return false;" title="Copy permalink to this patch">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path></svg>
</a>`;
let totalTokens = 0;
reviews.forEach(r => totalTokens += (r.tokens_in || 0) + (r.tokens_out || 0) + (r.tokens_cached || 0));
const borderStyle = '';
let statusHtml = '';
if (patch && (patch.status === 'error' || patch.status === 'failed')) {
statusHtml = `<div class="error-banner error-banner-pre"><strong>Patch Application Failed:</strong>\n${escapeHtml(patch.apply_error || 'Unknown error')}</div>`;
}
let emailHtml = '';
if (patch) {
let toAddrs = patch.email_to || '';
let ccAddrs = patch.email_cc || '';
let eStatus = patch.email_status;
if (!data.smtp_enabled) {
eStatus = 'Disabled (SMTP not configured)';
} else if (data.dry_run) {
eStatus = eStatus ? `Dry-run (${eStatus})` : 'Dry-run (Not sent)';
} else {
eStatus = eStatus || 'None';
}
try { if (toAddrs.startsWith('[')) toAddrs = JSON.parse(toAddrs).join(', '); } catch(e) {}
try { if (ccAddrs.startsWith('[')) ccAddrs = JSON.parse(ccAddrs).join(', '); } catch(e) {}
emailHtml = `
<div style="font-size: 0.9em; color: var(--text-muted); line-height: 1.4; word-break: break-all;">
<strong>To:</strong> ${escapeHtml(toAddrs || 'None')}
<strong>Cc:</strong> ${escapeHtml(ccAddrs || 'None')}
<strong>Status:</strong> ${escapeHtml(eStatus)}
</div>
`;
}
let listHtml = '';
if (patch && patch.status === 'Skipped') {
listHtml = `<p class="status-placeholder">Skipped.</p>`;
} else if (reviews && reviews.length > 0 && reviews.every(r => r.status === 'Skipped')) {
const reason = reviews[0].result || 'Skipped.';
listHtml = `<p class="status-placeholder">${escapeHtml(reason)}</p>`;
} else {
listHtml = renderReviewList(reviews, emailHtml, patch ? patch.status : null);
}
reviewsHtml += `
<div id="patch-${pid}" ${patch ? `data-part="${patch.part_index}"` : ''} style="margin-top:12px; ${borderStyle}">
<h3 style="display:flex; justify-content:space-between;">
<span>${header}</span>
</h3>
${statusHtml}
${listHtml}
</div>
`;
if (sortedPatchIds.length > 1) {
let summaryStatus = '';
if (patch && (patch.status === 'error' || patch.status === 'failed')) {
summaryStatus = `<span class="text-error-bold">Apply Failed</span>`;
} else if (patch && patch.status === 'Skipped') {
summaryStatus = '<span style="color:var(--text-dim)">Skipped</span>';
} else if (!reviews || reviews.length === 0) {
summaryStatus = '<span style="color:var(--text-dim)">No reviews</span>';
} else { // Aggregate "MAX" across all reviews for this patch
let maxCounts = { low: 0, medium: 0, high: 0, critical: 0 };
let hasSuccessful = false;
let hasSkipped = false;
let hasInProgress = false;
let skippedReason = '';
let errors = [];
let validRunCount = 0;
reviews.forEach(r => {
if (r.status !== 'Failed To Apply') {
validRunCount++;
}
if (r.status === 'Reviewed') {
hasSuccessful = true;
try {
if (r.output) {
const out = typeof r.output === 'string' ? JSON.parse(r.output) : r.output;
if (out && out.findings && Array.isArray(out.findings)) {
let current = { low: 0, medium: 0, high: 0, critical: 0 };
out.findings.forEach(f => {
if (f.preexisting) return; // Skip preexisting findings for summary counts
const s = String(f.severity || '').toLowerCase();
if (s === 'critical') current.critical++;
else if (s === 'high') current.high++;
else if (s === 'medium') current.medium++;
else current.low++;
});
// Max aggregation
maxCounts.critical = Math.max(maxCounts.critical, current.critical);
maxCounts.high = Math.max(maxCounts.high, current.high);
maxCounts.medium = Math.max(maxCounts.medium, current.medium);
maxCounts.low = Math.max(maxCounts.low, current.low);
}
}
} catch (e) {}
} else if (r.status === 'Skipped') {
hasSkipped = true;
skippedReason = r.result || 'Skipped';
} else if (r.status && (r.status.toLowerCase().includes('failed') || r.status.toLowerCase().includes('error'))) {
errors.push(r.status);
} else if (r.status === 'Pending' || r.status === 'In Review') {
hasInProgress = true;
}
});
if (hasSuccessful) {
summaryStatus = renderFindingsBadges(maxCounts, 'Reviewed');
if (validRunCount > 1) {
summaryStatus += ` <span style="color:var(--text-dim); font-size:0.85em; font-style:italic;">(max of ${validRunCount} runs)</span>`;
}
} else if (errors.length > 0) {
summaryStatus = `<span class="text-error-bold">${escapeHtml(errors[0])}</span>`;
} else if (hasSkipped && !hasInProgress) {
// Only skipped
summaryStatus = `<span style="color:var(--text-dim)" title="${escapeHtml(skippedReason)}">Skipped</span>`;
} else {
summaryStatus = '<span style="color:var(--text-dim)">In progress...</span>';
}
}
const patchLabel = patch ? `Patch ${patch.part_index}` : `Patch ${pid}`;
const patchSubject = patch ? escapeHtml(patch.subject || '(no subject)') : '';
summaryHtml += `<tr onclick="document.getElementById('patch-${pid}').scrollIntoView({behavior: 'smooth'})" style="cursor:pointer"><td><strong>${patchLabel}</strong>: ${patchSubject}</td><td>${summaryStatus}</td></tr>`;
}
});
if (sortedPatchIds.length > 1) {
summaryHtml += '</tbody></table>';
}
if (!reviewsHtml) reviewsHtml = '<p>No AI reviews yet.</p>';
let paginationHtml = '';
if (data.total_patches_in_db > data.limit) {
const totalPages = Math.ceil(data.total_patches_in_db / data.limit);
const basePath = `#/patchset/${encodeURIComponent(data.slug || data.id || data.message_id)}`;
paginationHtml = `
<div class="pagination">
<button onclick="navigate('${basePath}?page=1&limit=${data.limit}')" ${data.page <= 1 ? 'disabled' : ''}>First</button>
<button onclick="navigate('${basePath}?page=${data.page - 1}&limit=${data.limit}')" ${data.page <= 1 ? 'disabled' : ''}>Prev</button>
<span>Page ${data.page} of ${totalPages} (${data.total_patches_in_db} total)</span>
<button onclick="navigate('${basePath}?page=${data.page + 1}&limit=${data.limit}')" ${data.page >= totalPages ? 'disabled' : ''}>Next</button>
<button onclick="navigate('${basePath}?page=${totalPages}&limit=${data.limit}')" ${data.page >= totalPages ? 'disabled' : ''}>Last</button>
</div>
`;
}
// Only show baseline/thread section if NOT a direct git submission (heuristic: message_id is SHA or synthetic local ID or subject indicates git import)
const isGitSubmission = (data.message_id && (/^[0-9a-f]{40}$/.test(data.message_id) || data.message_id.includes('@sashiko.local'))) || (data.subject && data.subject.startsWith('Git Import:'));
const threadHtml = isGitSubmission ? '' : renderThreadTree(data.thread);
const subsystemsHtml = data.subsystems?.length > 0
? data.subsystems.map(s => `<span class="tag">${escapeHtml(s)}</span>`).join('')
: '-';
let failureHtml = '';
if (data.failed_reason) {
failureHtml = `
<div class="section error-banner" style="margin-bottom:16px;">
<h3 style="margin-top:0; color:var(--error-title);">Ingestion Failed</h3>
<div>${escapeHtml(data.failed_reason)}</div>
</div>
`;
}
const modelName = data.model_name || '-';
const providerModel = data.provider ? `${data.provider}/${modelName}` : modelName;
const promptsHash = data.prompts_git_hash ? data.prompts_git_hash.substring(0, 8) : '-';
let baselineStr = '-';
if (data.baseline) {
const parts = [];
if (data.baseline.branch) parts.push(data.baseline.branch);
if (data.baseline.commit) parts.push(data.baseline.commit.substring(0, 8));
if (parts.length > 0) baselineStr = parts.join(' / ');
}
let baselineLogsHtml = '';
if (data.baseline_logs && !isGitSubmission) {
const logId = 'baseline-logs-' + Math.random().toString(36).substr(2, 9);
let contentHtml = '';
let isJson = false;
try {
const logs = JSON.parse(data.baseline_logs);
if (Array.isArray(logs)) {
isJson = true;
// Render table
contentHtml = `
<table style="width:100%; border-collapse:collapse; margin-top:8px;">
<thead>
<tr style="background:var(--code-bg);">
<th class="bl-table-header">Baseline</th>
<th class="bl-table-header" style="width:100px;">Status</th>
<th class="bl-table-header" style="text-align:right; width:120px;">Log</th>
</tr>
</thead>
<tbody>
`;
logs.forEach((item, idx) => {
const rowId = `bl-log-${logId}-${idx}`;
let statusBadge = '';
if (item.status === 'Applied') {
statusBadge = '<span class="status-badge status-Applied">Applied</span>';
} else {
statusBadge = '<span class="status-badge status-Failed">Failed</span>';
}
contentHtml += `
<tr>
<td class="bl-table-cell">${escapeHtml(item.baseline)}</td>
<td class="bl-table-cell">${statusBadge}</td>
<td class="bl-table-cell" style="text-align:right;">
<a href="#/log/baseline/${encodeURIComponent(data.id || data.message_id)}/${idx}" style="font-weight:600; color:var(--text-muted); text-decoration:none; font-size:0.85em;" onmouseover="this.style.color='var(--link)'" onmouseout="this.style.color='var(--text-muted)'">View Log</a>
</td>
</tr>
`;
});
contentHtml += '</tbody></table>';
}
} catch (e) {
// Fallback to text
}
if (!isJson) {
const formattedLogs = escapeHtml(data.baseline_logs)
.replace(/ - passed/g, ' - <span class="text-success-bold">passed</span>')
.replace(/ - failed/g, ' - <span class="text-error-bold">failed</span>')
.replace(/Application successful./g, '<span class="text-success-bold">Application successful.</span>')
.replace(/Application failed./g, '<span class="text-error-bold">Application failed.</span>');
contentHtml = `<div class="log-block" style="max-height:300px;">${formattedLogs}</div>`;
}
baselineLogsHtml = `
<div class="section">
<h2 style="display:flex; align-items:center; gap:10px;">
Baseline
<button class="toggle-btn" onclick="toggleCollapsible('${logId}', this)" style="font-size:0.8em; font-weight:normal;">▶ Show Details</button>
</h2>
<div class="kv"><div class="label">Selected:</div><div>${baselineStr}</div></div>
<div id="${logId}" class="collapsible">
${contentHtml}
</div>
</div>
`;
}
let currentStatus = data.status || 'Pending';
document.getElementById('app').innerHTML = `
<div class="nav"><a href="#/" onclick="event.preventDefault(); goBack();">← Back</a></div>
<h1>
<span>${escapeHtml(data.subject) || '(no subject)'}</span>
<span class="status-badge status-${currentStatus.replace(/ /g, '')}">${currentStatus}</span>
</h1>
${failureHtml}
<div class="section">
<div class="kv"><div class="label">Author:</div><div>${(() => {
const a = parseAuthor(data.author);
return escapeHtml(a.name ? `${a.name} <${a.email}>` : a.email);
})()}</div></div>
<div class="kv"><div class="label">Date:</div><div>${date}</div></div>
<div class="kv"><div class="label">Subsystems:</div><div>${subsystemsHtml}</div></div>
<div class="kv"><div class="label">Patches:</div><div>${data.received_parts || 0} / ${data.total_parts || 0}</div></div>
<div class="kv"><div class="label">Model:</div><div>${escapeHtml(providerModel)}</div></div>
<div class="kv"><div class="label">Sashiko ver.:</div><div>${escapeHtml(promptsHash)}</div></div>
</div>
${data.mr_url ? `<div class="section">
<h2>Merge Request</h2>
<div style="padding: 12px; background: var(--card-bg); border-radius: 6px; border: 1px solid var(--border);">
<a href="${escapeHtml(data.mr_url)}" target="_blank" rel="noopener noreferrer" style="color: var(--link); text-decoration: none; font-weight: 500;">
${escapeHtml(data.mr_url)} ↗
</a>
</div>
</div>` : ''}
${threadHtml ? `<div class="section">
<h2>Thread</h2>
${threadHtml}
</div>` : ''}
${baselineLogsHtml}
<div class="section">
<h2 id="ai-reviews">AI Reviews</h2>
${summaryHtml}
${reviewsHtml}
${paginationHtml}
</div>
`;
if (query.patch || query.part) {
setTimeout(() => {
let el = null;
if (query.patch) {
el = document.getElementById(`patch-${query.patch}`);
}
if (!el && query.part) {
el = document.querySelector(`[data-part="${query.part}"]`);
}
if (el) {
el.scrollIntoView({behavior: 'smooth', block: 'center'});
const origBg = el.style.backgroundColor;
el.style.backgroundColor = 'var(--selected-bg)';
el.style.transition = 'background-color 1.5s ease-out';
setTimeout(() => {
el.style.backgroundColor = origBg;
}, 2000);
}
}, 100);
}
}
// =====================================================================
// BASELINE LOG VIEW
// =====================================================================
async function renderBaselineLogView(id, idx) {
const res = await fetch(`/api/patchset?id=${encodeURIComponent(id)}`);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const data = await res.json();
let logContent = 'Log not found';
let baselineName = '-';
let status = '-';
try {
const logs = JSON.parse(data.baseline_logs);
if (Array.isArray(logs) && logs[idx]) {
logContent = logs[idx].log || '(empty log)';
baselineName = logs[idx].baseline || '-';
status = logs[idx].status || '-';
}
} catch (e) {
logContent = 'Failed to parse baseline logs';
}
document.getElementById('app').innerHTML = `
<div class="nav"><a href="#/" onclick="event.preventDefault(); goBack();">← Back</a></div>
<h1>
<span>Baseline Application Log</span>
<span class="header-subtitle">
${escapeHtml(baselineName)} ·
<span class="status-badge status-${(status || '').replace(/ /g, '')}">${status}</span>
</span>
</h1>
<div class="section">
<div class="log-block" style="max-height: none;">${escapeHtml(logContent)}</div>
</div>
`;
}
function renderReviewCard(r, emailHtml = '') {
const uniqueId = Math.random().toString(36).substr(2, 9);
let displayResult = '';
// 1. Try to parse output for structured findings
let parsedFindings = false;
try {
if (r.output) {
let outputJson = null;
if (typeof r.output === 'string') {
outputJson = JSON.parse(r.output);
} else if (typeof r.output === 'object') {
outputJson = r.output;
}
// Findings
if (outputJson && outputJson.findings && Array.isArray(outputJson.findings)) {
const isPatchset = (r.patch_id === null || r.patch_id === undefined);
if (isPatchset) {
// For patchsets, only count and display newly introduced bugs.
const newFindings = outputJson.findings.filter(f => !f.preexisting);
let counts = { low: 0, medium: 0, high: 0, critical: 0 };
newFindings.forEach(f => {
const s = (f.severity || '').toLowerCase();
if (s === 'critical') counts.critical++;
else if (s === 'high') counts.high++;
else if (s === 'medium') counts.medium++;
else counts.low++;
});
const total = counts.low + counts.medium + counts.high + counts.critical;
if (total === 0) {
displayResult = '<span class="text-success-bold">No regressions</span>';
} else {
const parts = [];
parts.push(`Critical: <span style="${counts.critical > 0 ? 'color:var(--sev-critical);font-weight:bold' : 'color:var(--text-dim)'}">${counts.critical}</span>`);
parts.push(`High: <span style="${counts.high > 0 ? 'color:var(--sev-high);font-weight:bold' : 'color:var(--text-dim)'}">${counts.high}</span>`);
parts.push(`Medium: <span style="${counts.medium > 0 ? 'color:var(--sev-medium);font-weight:bold' : 'color:var(--text-dim)'}">${counts.medium}</span>`);
parts.push(`Low: <span style="${counts.low > 0 ? 'color:var(--sev-low);font-weight:bold' : 'color:var(--text-dim)'}">${counts.low}</span>`);
displayResult = parts.join(' · ');
}
} else {
// For individual patches, count new and preexisting separately.
let counts = {
low: { new: 0, preexisting: 0 },
medium: { new: 0, preexisting: 0 },
high: { new: 0, preexisting: 0 },
critical: { new: 0, preexisting: 0 }
};
outputJson.findings.forEach(f => {
const s = (f.severity || '').toLowerCase();
const preexisting = !!f.preexisting;
const target = preexisting ? 'preexisting' : 'new';
if (s === 'critical') counts.critical[target]++;
else if (s === 'high') counts.high[target]++;
else if (s === 'medium') counts.medium[target]++;
else counts.low[target]++;
});
const totalNew = counts.low.new + counts.medium.new + counts.high.new + counts.critical.new;
const totalPre = counts.low.preexisting + counts.medium.preexisting + counts.high.preexisting + counts.critical.preexisting;
if (totalNew + totalPre === 0) {
displayResult = '<span class="text-success-bold">No regressions</span>';
} else {
const renderSeverity = (label, severityCounts, color) => {
const newCount = severityCounts.new;
const preCount = severityCounts.preexisting;
let newSpan = `<span style="${newCount > 0 ? 'color:' + color + ';font-weight:bold' : 'color:var(--text-dim)'}">${newCount}</span>`;
let preSpan = '';
if (preCount > 0) {
preSpan = ` <span style="color:var(--text-dim)">(<span style="color:${color}">${preCount}</span>)</span>`;
}
return `${label}: ${newSpan}${preSpan}`;
};
const parts = [];
parts.push(renderSeverity('Critical', counts.critical, 'var(--sev-critical)'));
parts.push(renderSeverity('High', counts.high, 'var(--sev-high)'));
parts.push(renderSeverity('Medium', counts.medium, 'var(--sev-medium)'));
parts.push(renderSeverity('Low', counts.low, 'var(--sev-low)'));
displayResult = parts.join(' · ');
}
}
parsedFindings = true;
}
}
} catch (e) { }
// 2. If no structured findings, fallback to r.result
if (!parsedFindings) {
const rawResult = r.result || 'Finished';
if (rawResult === "Review completed successfully.") {
displayResult = '<span class="text-success-bold">No regressions</span>';
} else {
// Check for "garbage" (long JSON or similar)
if (rawResult.trim().startsWith('{') || rawResult.length > 60) {
displayResult = `<span style="color:var(--text-muted); font-style:italic;" title="${escapeHtml(rawResult)}">See log for details</span>`;
} else {
displayResult = `<span style="font-weight:bold;">${escapeHtml(rawResult)}</span>`;
}
}
}
const tokensOut = (r.tokens_out || 0);
const tokensCached = (r.tokens_cached || 0);
const tokensIn = Math.max(0, (r.tokens_in || 0) - tokensCached);
const tokenStr = `In: ${tokensIn.toLocaleString()} · Cached: ${tokensCached.toLocaleString()} · Out: ${tokensOut.toLocaleString()}`;
let inlineHtml = '';
if (r.inline_review) {
// If we found issues (parsedFindings is true and result isn't "No regressions"), expand by default
const hasIssues = parsedFindings && !displayResult.includes('No regressions');
const isOpen = hasIssues ? 'open' : '';
const icon = hasIssues ? '▼' : '▶';
inlineHtml = `
<div style="margin-top:8px;">
<button class="toggle-btn" onclick="toggleCollapsible('inline-${uniqueId}', this)">${icon} Inline Review</button>
<div id="inline-${uniqueId}" class="collapsible ${isOpen}" style="position: relative;">
<button class="copy-btn" style="position: absolute; top: 5px; right: 5px; z-index: 10;" onclick="copyToClipboard(this.nextElementSibling.innerText)">Copy</button>
<div class="log-inline-block">${escapeHtml(r.inline_review, true, true)}</div>
</div>
</div>
`;
}
const statusRaw = r.status || 'Finished';
const s = statusRaw.toLowerCase();
const showDetails = s === 'reviewed' || s === 'finished' || s === 'failed' || s === 'skipped';
return `
<div class="review-card">
<div class="review-meta">
${showDetails ? `
<div class="review-meta-label">Result:</div>
<div style="display:flex; justify-content:space-between; align-items:center;">
<span>${displayResult}</span>
<span class="status-badge status-${(statusRaw || '').replace(/ /g, '')}">${statusRaw}</span>
</div>
` : `
<div class="review-meta-label">Status:</div>
<div><span class="status-badge status-${(statusRaw || '').replace(/ /g, '')}">${statusRaw}</span></div>
`}
</div>
${showDetails ? `
${inlineHtml}
<div style="margin-top: 24px; margin-bottom: 0;">
${emailHtml}
<div style="font-size: 0.9em; color: var(--text-muted); line-height: 1.4; margin-top: 2px;">
<strong>Tokens used:</strong> ${tokenStr}
</div>
</div>
<div style="margin-top:8px; display:flex; justify-content:space-between; align-items:center; font-size: 0.9em;">
${r.id ? `<a href="#/log/${r.id}" style="font-weight:600; color:var(--text-dim); text-decoration:none;" onmouseover="this.style.textDecoration='underline'" onmouseout="this.style.textDecoration='none'">View Raw Log</a>` : '<span></span>'}
<a href="javascript:void(0)" onclick="document.getElementById('ai-reviews').scrollIntoView({behavior: 'smooth'})" style="font-size: 0.95em; color: var(--text-dim); text-decoration: none;">↑ Back to Summary</a>
</div>
` : ''}
</div>
`;
}
function renderThreadTree(thread) {
if (!thread || thread.length === 0) return '<p class="status-placeholder">No thread history.</p>';
const msgMap = new Map();
const childrenMap = new Map();
thread.forEach(m => {
msgMap.set(m.message_id, m);
childrenMap.set(m.message_id, []);
});
const roots = [];
thread.forEach(m => {
const parentId = m.in_reply_to;
if (parentId && msgMap.has(parentId)) {
childrenMap.get(parentId).push(m);
} else {
roots.push(m);
}
});
let html = '<div class="thread-tree"><ul>';
function renderNode(m, depth) {
const mDate = m.date ? formatDate(m.date) : '-';
const arrow = depth > 0 ? '↳ ' : '';
const author = parseAuthor(m.author);
const authorStr = author.name ? `${author.name} <${author.email}>` : author.email;
html += `<li>${arrow}<span class="date">[${mDate}]</span> ${escapeHtml(authorStr)}: <a href="#/message/${encodeURIComponent(m.message_id)}">${escapeHtml(m.subject || '(no subject)')}</a>`;
const children = childrenMap.get(m.message_id) || [];
if (children.length > 0) {
html += '<ul>';
children.forEach(c => renderNode(c, depth + 1));
html += '</ul>';
}
html += '</li>';
}
roots.forEach(r => renderNode(r, 0));
html += '</ul></div>';
return html;
}
// =====================================================================
// MESSAGE VIEW
// =====================================================================
async function renderMessageView(id) {
const res = await fetch(`/api/message?id=${encodeURIComponent(id)}`);
if (!res.ok) throw new Error('Message not found');
const data = await res.json();
document.title = `Sashiko - ${data.subject}`;
const date = formatDate(data.date, true);
// Heuristic: message_id is SHA or synthetic local ID means it's a direct git submission
const isGitSubmission = data.message_id && (/^[0-9a-f]{40}$/.test(data.message_id) || data.message_id.includes('@sashiko.local'));
const threadHtml = (!isGitSubmission && data.thread?.length > 0) ? renderThreadTreeWithCurrent(data.thread, data.message_id) : '';
document.getElementById('app').innerHTML = `
<div class="nav"><a href="#/" onclick="event.preventDefault(); goBack();">← Back</a></div>
<h1>${escapeHtml(data.subject) || '(no subject)'}</h1>
<div class="message-meta">
<div><span class="label">From:</span> ${(() => {
const a = parseAuthor(data.author);
return escapeHtml(a.name ? `${a.name} <${a.email}>` : a.email);
})()}</div>
<div><span class="label">To:</span> ${escapeHtml(formatAuthorList(data.to))}</div>
<div><span class="label">Cc:</span> ${escapeHtml(formatAuthorList(data.cc))}</div>
<div><span class="label">Date:</span> ${date}</div>
<div><span class="label">ID:</span> ${escapeHtml(data.message_id)} <button class="copy-btn" onclick="copyToClipboard('${escapeHtml(data.message_id)}')">Copy</button></div>
</div>
${threadHtml ? `<div class="section"><h2>Thread</h2>${threadHtml}</div>` : ''}
<pre class="body"><div style="font-weight: bold; color: var(--text);">${escapeHtml(data.subject || '(no subject)')}</div>
${escapeHtml((data.body || '(no body)').replace(/\n+$/, ''), true, false)}${data.diff ? '\n\n' + escapeHtml(data.diff, true) : ''}</pre>
`;
}
function renderThreadTreeWithCurrent(thread, currentMsgId) {
if (!thread || thread.length === 0) return '';
const msgMap = new Map();
const childrenMap = new Map();
thread.forEach(m => {
msgMap.set(m.message_id, m);
childrenMap.set(m.message_id, []);
});
const roots = [];
thread.forEach(m => {
const parentId = m.in_reply_to;
if (parentId && msgMap.has(parentId)) {
childrenMap.get(parentId).push(m);
} else {
roots.push(m);
}
});
let html = '<div class="thread-tree"><ul>';
function renderNode(m, depth) {
const mDate = m.date ? formatDate(m.date) : '-';
const arrow = depth > 0 ? '↳ ' : '';
const isCurrent = m.message_id === currentMsgId;
const style = isCurrent ? 'class="current"' : '';
const author = parseAuthor(m.author);
const authorStr = author.name ? `${author.name} <${author.email}>` : author.email;
html += `<li>${arrow}<span class="date">[${mDate}]</span> ${escapeHtml(authorStr)}: <a href="#/message/${encodeURIComponent(m.message_id)}" ${style}>${escapeHtml(m.subject || '(no subject)')}</a>`;
const children = childrenMap.get(m.message_id) || [];
if (children.length > 0) {
html += '<ul>';
children.forEach(c => renderNode(c, depth + 1));
html += '</ul>';
}
html += '</li>';
}
roots.forEach(r => renderNode(r, 0));
html += '</ul></div>';
return html;
}
// =====================================================================
// LOG VIEW
// =====================================================================
async function renderLogView(id) {
const res = await fetch(`/api/review?id=${id}`);
if (!res.ok) throw new Error('Review not found');
const data = await res.json();
const totalTokens = (data.tokens_in || 0) + (data.tokens_out || 0) + (data.tokens_cached || 0);
let logs = [];
try { logs = JSON.parse(data.logs || '[]'); } catch (e) { }
let logsHtml = '';
if (!logs.length) {
logsHtml = '<div style="padding:20px; text-align:center; color:var(--text-dim);">No logs recorded.</div>';
} else {
let jsonCounter = 0;
logsHtml = logs.map((entry, idx) => {
const role = (entry.role || 'unknown').toLowerCase();
const parts = entry.parts || [];
let rowClass = 'log-entry';
if (role === 'user') rowClass += ' role-user';
else if (role === 'model' || role === 'assistant') rowClass += ' role-model';
else if (role === 'tool' || role === 'function') rowClass += ' role-tool';
else rowClass += ' role-system';
let label = (role === 'model' || role === 'assistant') ? 'LLM' : (role === 'tool' || role === 'function') ? 'TOOL' : role.toUpperCase();
let content = '';
// Unified format: direct content and tool_calls
if (entry.thought && typeof entry.thought === 'string') {
content += `<div class="thought-block">${escapeHtml(entry.thought)}</div>`;
}
if (entry.content && typeof entry.content === 'string') {
if (role === 'tool') {
content += formatJsonExpandable(entry.content, jsonCounter++);
} else {
content += `<div>${escapeHtml(entry.content)}</div>`;
}
}
if (entry.tool_calls && Array.isArray(entry.tool_calls)) {
entry.tool_calls.forEach(call => {
const callId = jsonCounter++;
content += formatFuncCallExpandable(call.function_name, call.arguments, callId);
});
}
// Gemini format: parts array
parts.forEach(part => {
if (part.text) {
if (part.thought) {
content += `<div class="thought-block">${escapeHtml(part.text)}</div>`;
} else {
content += `<div>${escapeHtml(part.text)}</div>`;
}
} else if (part.functionCall || part.function_call) {
const fc = part.functionCall || part.function_call;
const callId = jsonCounter++;
const callHtml = formatFuncCallExpandable(fc.name, fc.args, callId);
content += callHtml;
} else if (part.functionResponse || part.function_response) {
const fr = part.functionResponse || part.function_response;
const respHtml = formatJsonExpandable(fr.response, jsonCounter++);
content += `<div><span style="opacity:0.6">→ ${escapeHtml(fr.name)}:</span> ${respHtml}</div>`;
}
});
return `<div class="${rowClass}"><div class="log-gutter">${label}</div><div class="log-content">${content}</div></div>`;
}).join('');
}
document.getElementById('app').innerHTML = `
<div class="nav"><a href="#/" onclick="event.preventDefault(); goBack();">← Back</a></div>
<h1>
<span>Interaction Log</span>
<span class="header-subtitle">
${data.model || '?'} · ${data.prompts_hash ? 'Sashiko ver.: ' + data.prompts_hash.substring(0, 8) + ' · ' : ''}
<span class="status-badge status-${(data.status || '').replace(/ /g, '')}">${data.status}</span>
</span>
</h1>
<div>${logsHtml}</div>
`;
}
// Custom preview formatters for common tools
function getToolPreview(name, args) {
try {
switch (name) {
case 'git_read_files': {
const files = args.files || [];
const mode = args.mode || 'raw';
const fileStrs = files.map(f => {
let s = `"${f.path}"`;
if (f.start_line || f.end_line) {
s += `::${f.start_line || ''}..${f.end_line || ''}`;
}
return s;
});
const filesPreview = fileStrs.length > 3 ? fileStrs.slice(0, 3).join(', ') + `, +${fileStrs.length - 3}` : fileStrs.join(', ');
return mode !== 'raw' ? `${filesPreview}, mode=${mode}` : filesPreview;
}
case 'git_blame': {
let s = `"${args.path}"`;
if (args.start_line || args.end_line) {
s += `::${args.start_line || ''}..${args.end_line || ''}`;
}
return s;
}
case 'git_diff': {
const diffArgs = args.args || [];
return diffArgs.map(a => `"${a}"`).join(' ');
}
case 'git_show': {
let s = `"${args.object}"`;
if (args.suppress_diff) s += ', no-diff';
if (args.start_line || args.end_line) {
s += `::${args.start_line || ''}..${args.end_line || ''}`;
}
return s;
}
case 'git_ls':
return `"${args.path}"`;
case 'write_file': {
const contentLen = (args.content || '').length;
return `"${args.path}", ${formatSize(contentLen)} content`;
}
case 'git_grep': {
let s = `"${args.pattern}"`;
if (args.path && args.path !== '.') s += ` in "${args.path}"`;
if (args.context_lines) s += ` ±${args.context_lines}`;
return s;
}
case 'git_find_files': {
let s = `"${args.pattern}"`;
if (args.path && args.path !== '.') s += ` in "${args.path}"`;
return s;
}
default:
return null;
}
} catch (e) {
return null;
}
}
function formatFuncCallExpandable(name, args, id) {
if (typeof args === 'string') { try { args = JSON.parse(args); } catch (e) { } }
if (!args || typeof args !== 'object') {
return `<div><span class="fn-name">${escapeHtml(name)}</span>()</div>`;
}
let entries = Object.entries(args);
if (entries.length === 0) {
return `<div><span class="fn-name">${escapeHtml(name)}</span>()</div>`;
}
// Try custom preview first
let preview = getToolPreview(name, args);
if (!preview) {
if (typeof sortToolArgs === 'function') {
entries = sortToolArgs(entries);
}
preview = entries.map(([k, v]) => {
let valStr = JSON.stringify(v);
if (valStr?.length > 40) valStr = valStr.substring(0, 37) + '...';
return `${k}=${valStr}`;
}).join(', ');
}
const fullJson = JSON.stringify(args, null, 2);
const argsSize = formatSize(fullJson.length);
const expandId = `json-call-${id}`;
const shortPreview = preview.length > 80 ? preview.substring(0, 77) + '...' : preview;
return `<div class="json-expandable" onclick="toggleJsonExpand('${expandId}')"><span class="fn-name">${escapeHtml(name)}</span>(<span class="json-preview">${escapeHtml(shortPreview)}</span>) <span class="json-size">(${argsSize})</span><pre id="${expandId}" class="json-formatted" style="display:none">${formatJsonPretty(args)}</pre></div>`;
}
function formatFuncArgsExpandable(args, id) {
if (typeof args === 'string') { try { args = JSON.parse(args); } catch (e) { return escapeHtml(args); } }
if (!args || typeof args !== 'object') return '';
let entries = Object.entries(args);
if (typeof sortToolArgs === 'function') {
entries = sortToolArgs(entries);
}
const preview = entries.map(([k, v]) => {
let valStr = JSON.stringify(v);
if (valStr?.length > 40) valStr = valStr.substring(0, 37) + '...';
return `${k}=${valStr}`;
}).join(', ');
const fullJson = JSON.stringify(args, null, 2);
if (preview.length < 80 && !fullJson.includes('\\n') && !fullJson.includes('\\t')) {
return escapeHtml(preview);
}
const expandId = `json-args-${id}`;
return `<span class="json-expandable" onclick="toggleJsonExpand('${expandId}')"><span class="json-preview">${escapeHtml(preview.length > 80 ? preview.substring(0, 77) + '...' : preview)}</span><pre id="${expandId}" class="json-formatted" style="display:none">${formatJsonPretty(args)}</pre></span>`;
}
function formatJsonExpandable(obj, id) {
if (typeof obj === 'string') { try { obj = JSON.parse(obj); } catch (e) { } }
const jsonStr = JSON.stringify(obj);
const size = formatSize(jsonStr.length);
const sizeHtml = `<span class="json-size">(${size})</span>`;
const preview = jsonStr.length > 100 ? jsonStr.substring(0, 97) + '...' : jsonStr;
if (jsonStr.length <= 100 && !jsonStr.includes('\\n') && !jsonStr.includes('\\t')) {
return `${sizeHtml}${escapeHtml(preview)}`;
}
const expandId = `json-resp-${id}`;
return `${sizeHtml}<span class="json-expandable" onclick="toggleJsonExpand('${expandId}')"><span class="json-preview">${escapeHtml(preview)}</span><pre id="${expandId}" class="json-formatted" style="display:none">${formatJsonPretty(obj)}</pre></span>`;
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + 'B';
return (bytes / 1024).toFixed(1) + 'KB';
}
function formatJsonPretty(obj) {
const formatted = JSON.stringify(obj, null, 2);
return escapeHtml(formatted).replace(/\\n/g, '\n').replace(/\\t/g, ' ');
}
function toggleJsonExpand(id) {
const el = document.getElementById(id);
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
// =====================================================================
// KEYBOARD NAVIGATION
// =====================================================================
document.addEventListener('keydown', (e) => {
const activeEl = document.activeElement;
const isInputFocused = activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA';
// Escape blurs inputs
if (e.key === 'Escape' && isInputFocused) {
activeEl.blur();
return;
}
// If input is focused, don't handle other keys
if (isInputFocused) return;
// Focus search with /
if (e.key === '/' && state.view === 'list') {
e.preventDefault();
const input = document.getElementById('searchInput');
if (input) { input.focus(); input.select(); }
return;
}
// List view navigation
if (state.view === 'list') {
const rows = document.querySelectorAll('#dataTable tbody tr');
if (e.key === 'j' || e.key === 'ArrowDown') {
e.preventDefault();
state.selectedIndex = Math.min(state.selectedIndex + 1, rows.length - 1);
updateRowSelection(rows);
} else if (e.key === 'k' || e.key === 'ArrowUp') {
e.preventDefault();
state.selectedIndex = Math.max(state.selectedIndex - 1, 0);
updateRowSelection(rows);
} else if ((e.key === 'Enter' || e.key === 'o') && state.selectedIndex >= 0 && rows[state.selectedIndex]) {
e.preventDefault();
rows[state.selectedIndex].click();
} else if (e.key === 'l' || e.key === 'ArrowRight') {
if (state.currentPage < state.totalPages) changePage(1);
} else if (e.key === 'h' || e.key === 'ArrowLeft') {
if (state.currentPage > 1) changePage(-1);
}
} else {
// Detail views: scroll and back
if (e.key === 'j' || (e.ctrlKey && e.key === 'n')) {
window.scrollBy(0, 60);
} else if (e.key === 'k' || (e.ctrlKey && e.key === 'p')) {
window.scrollBy(0, -60);
} else if (e.key === 'l' || e.key === 'ArrowRight') {
if (state.view === 'patchset' && state.patchsetPage < state.patchsetTotalPages) {
const basePath = `#/patchset/${encodeURIComponent(state.patchsetId)}`;
navigate(`${basePath}?page=${state.patchsetPage + 1}&limit=${state.patchsetLimit}`);
}
} else if (e.key === 'h' || e.key === 'ArrowLeft') {
if (state.view === 'patchset' && state.patchsetPage > 1) {
const basePath = `#/patchset/${encodeURIComponent(state.patchsetId)}`;
navigate(`${basePath}?page=${state.patchsetPage - 1}&limit=${state.patchsetLimit}`);
} else if (e.key === 'h') {
e.preventDefault();
goBack();
}
} else if (e.key === 'q' || e.key === 'Backspace') {
e.preventDefault();
goBack();
} else if (e.key === 'g') {
// gg = go to top (wait for second g)
window.scrollTo(0, 0);
} else if (e.key === 'G') {
window.scrollTo(0, document.body.scrollHeight);
}
}
});
function updateRowSelection(rows) {
rows.forEach((r, i) => {
if (i === state.selectedIndex) {
r.classList.add('selected');
r.scrollIntoView({ block: 'nearest' });
} else {
r.classList.remove('selected');
}
});
}
// =====================================================================
// INIT
// =====================================================================
// =====================================================================
// STATS VIEW
// =====================================================================
async function renderStatsView() {
const app = document.getElementById('app');
app.innerHTML = `
<div class="nav"><a href="#/" onclick="event.preventDefault(); goBack();">← Back</a></div>
<h1>Statistics</h1>
<div id="stats-content" class="loading">Loading stats...</div>
`;
try {
if (!state.statsData) {
const [timeline, reviewsData, tools] = await Promise.all([
fetch('/api/stats/timeline').then(r => r.json()),
fetch('/api/stats/reviews').then(r => r.json()),
fetch('/api/stats/tools').then(r => r.json())
]);
state.statsData = { timeline, reviewsData, tools };
}
const { timeline, reviewsData, tools } = state.statsData;
const reviews = reviewsData.reviews;
const historicalTotal = reviewsData.total_reviews;
const historicalFailed = reviewsData.total_failures;
// Colors
const isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const themeBg = isDark ? 'rgba(22, 27, 34, 0.9)' : 'rgba(255, 255, 255, 0.9)';
const themeBorder = isDark ? '#30363d' : '#eee';
const themeText = isDark ? '#c9d1d9' : '#333';
const themeAxis = isDark ? '#444' : '#ccc';
const colors = {
primary: '#5470c6',
secondary: '#91cc75',
patchset: '#fac858',
individual_patches: '#5ab1ef', // Light Blue/Teal
failure: '#ee6666',
text: themeText
};
// HTML Structure
let html = '<h2>Activity Timeline</h2><div class="chart-section"><div id="chart-timeline" class="chart-container"></div></div>';
// Summary Cards
html += '<h2>AI Reviews</h2>';
html += '<div class="stat-card-grid">';
// Filter for Total Reviews and Success Rate: Exclude in-progress reviews
const completedReviews = reviews.filter(r => !['Pending', 'In Review'].includes(r.status));
const totalReviewsLast1k = completedReviews.reduce((acc, r) => acc + r.count, 0);
const successfulLast1k = completedReviews.filter(r => r.status === 'Reviewed').reduce((acc, r) => acc + r.count, 0);
const successRateLast1k = totalReviewsLast1k ? Math.round(successfulLast1k / totalReviewsLast1k * 100) + '%' : '-';
html += renderStatCard('Total Reviews', historicalTotal);
html += renderStatCard('Total Failures', historicalFailed);
html += renderStatCard('Success Rate (last 1k reviews)', successRateLast1k);
html += '</div>';
html += '<h3>Review Outcomes</h3><div class="chart-section"><div id="chart-review-outcomes" class="chart-container"></div></div>';
html += '<h3>Findings Severity</h3><div class="chart-section"><div id="chart-findings" class="chart-container"></div></div>';
// Calculate total tokens
// Exclude In Review (Pending/In Review) AND Failed To Apply
const tokenReviews = reviews.filter(r => {
const s = r.status.toLowerCase().replace(/ /g, ''); // Normalize "Failed To Apply" -> "failedtoapply"
return !['pending', 'inreview'].includes(s) && !s.includes('failedtoapply');
});
const totalTokensIn = tokenReviews.reduce((acc, r) => acc + (r.tokens_in || 0), 0);
const totalTokensOut = tokenReviews.reduce((acc, r) => acc + (r.tokens_out || 0), 0);
const totalTokensCached = tokenReviews.reduce((acc, r) => acc + (r.tokens_cached || 0), 0);
const tokenReviewCount = tokenReviews.reduce((acc, r) => acc + r.count, 0);
// Average per patch (review)
const avgTokensIn = tokenReviewCount ? Math.round(totalTokensIn / tokenReviewCount) : 0;
const avgTokensOut = tokenReviewCount ? Math.round(totalTokensOut / tokenReviewCount) : 0;
const avgTokensCached = tokenReviewCount ? Math.round(totalTokensCached / tokenReviewCount) : 0;
html += '<h3>Token Usage (last 1k reviews)</h3><div class="stat-card-grid">';
html += renderStatCard('Input tokens/patch', avgTokensIn);
html += renderStatCard('Cached tokens/patch', avgTokensCached);
html += renderStatCard('Out tokens/patch', avgTokensOut);
html += '</div>';
html += '<h2>Tool Usage (last 1k reviews)</h2><div class="chart-section"><div id="chart-tools" class="chart-container-lg"></div></div>';
const container = document.getElementById('stats-content');
container.innerHTML = html;
container.classList.remove('loading');
// --- 1. Timeline Chart ---
if (timeline.messages && timeline.messages.length) {
const chart = echarts.init(document.getElementById('chart-timeline'), isDark ? 'dark' : null);
const dates = [...new Set([
...timeline.messages.map(m => m.day),
...timeline.patchsets.map(p => p.day),
...(timeline.patches || []).map(p => p.day)
])].sort();
const msgData = dates.map(d => timeline.messages.find(m => m.day === d)?.count || 0);
const patchsetData = dates.map(d => timeline.patchsets.filter(p => p.day === d).reduce((a, b) => a + b.count, 0));
const patchesData = dates.map(d => timeline.patches?.find(p => p.day === d)?.count || 0);
const option = {
backgroundColor: 'transparent',
color: [colors.primary, colors.individual_patches, colors.patchset],
tooltip: { trigger: 'axis', backgroundColor: themeBg, borderColor: themeBorder, borderWidth: 1, textStyle: { color: themeText } },
legend: { data: ['Messages', 'Patches', 'Patchsets'], bottom: 0 },
grid: { left: '3%', right: '4%', bottom: '10%', top: '10%', containLabel: true, show: true, borderColor: themeBorder },
xAxis: { type: 'category', data: dates, splitLine: { show: false }, axisLine: { lineStyle: { color: themeAxis } } },
yAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
series: [
{ name: 'Messages', type: 'line', smooth: true, showSymbol: false, lineStyle: { width: 3 }, data: msgData, itemStyle: { color: colors.primary } },
{ name: 'Patches', type: 'line', smooth: true, showSymbol: false, lineStyle: { width: 3 }, data: patchesData, itemStyle: { color: colors.individual_patches } },
{ name: 'Patchsets', type: 'line', smooth: true, showSymbol: false, lineStyle: { width: 3 }, data: patchsetData, itemStyle: { color: colors.patchset } }
]
};
chart.setOption(option);
state.activeCharts.push(chart);
} else {
document.getElementById('chart-timeline').innerHTML = '<p class="loading">No timeline data available.</p>';
}
// --- 1.5. Review Outcomes Timeline ---
if (timeline.reviews && timeline.reviews.length) {
const chart = echarts.init(document.getElementById('chart-review-outcomes'), isDark ? 'dark' : null);
const dates = [...new Set(timeline.reviews.map(r => r.day))].sort();
const statuses = [...new Set(timeline.reviews.map(r => r.status))];
// Custom colors for known statuses
const getColor = (s) => {
if (s === 'Reviewed') return colors.secondary;
if (s === 'Pending') return colors.primary;
if (s === 'In Review') return getComputedStyle(document.body).getPropertyValue('--status-inreview').trim() || '#d67f05';
if (s.toLowerCase().includes('failed')) return colors.failure;
return null;
};
const series = statuses.map(s => {
const style = {};
const c = getColor(s);
if (c) style.color = c;
return {
name: s,
type: 'line',
smooth: true,
showSymbol: false,
itemStyle: style,
data: dates.map(d => timeline.reviews.find(r => r.day === d && r.status === s)?.count || 0)
};
});
const option = {
backgroundColor: 'transparent',
tooltip: { trigger: 'axis' },
legend: { data: statuses, bottom: 0 },
grid: { left: '3%', right: '4%', bottom: '10%', top: '10%', containLabel: true },
xAxis: { type: 'category', data: dates, splitLine: { show: false }, axisLine: { lineStyle: { color: themeAxis } } },
yAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
series: series
};
chart.setOption(option);
state.activeCharts.push(chart);
} else {
document.getElementById('chart-review-outcomes').innerHTML = '<p class="loading">No review history available.</p>';
}
// --- 3. Tool Usage Chart ---
if (tools && tools.length) {
const chart = echarts.init(document.getElementById('chart-tools'), isDark ? 'dark' : null);
// Group by tool name
const toolStats = {};
tools.forEach(t => {
if (!toolStats[t.tool]) toolStats[t.tool] = 0;
toolStats[t.tool] += t.count;
});
const toolNames = Object.keys(toolStats).sort((a, b) => toolStats[a] - toolStats[b]); // Sort ascending for bar chart
const values = toolNames.map(t => toolStats[t]);
const option = {
backgroundColor: 'transparent',
tooltip: { trigger: 'axis' },
grid: { left: '3%', right: '4%', bottom: '5%', top: '5%', containLabel: true },
xAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
yAxis: { type: 'category', data: toolNames, axisLabel: { color: colors.text } },
series: [
{
name: 'Count',
type: 'bar',
data: values,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#83bff6' },
{ offset: 0.5, color: '#188df0' },
{ offset: 1, color: '#188df0' }
]),
borderRadius: [0, 4, 4, 0]
}
}
]
};
chart.setOption(option);
state.activeCharts.push(chart);
} else {
document.getElementById('chart-tools').innerHTML = '<p class="loading">No tool usage data available.</p>';
}
// --- 4. Findings Chart ---
if (timeline.findings && timeline.findings.length) {
const chart = echarts.init(document.getElementById('chart-findings'), isDark ? 'dark' : null);
const dates = [...new Set(timeline.findings.map(f => f.day))].sort();
const severities = ['low', 'medium', 'high', 'critical'];
const computedStyle = getComputedStyle(document.body);
const severityColors = {
'low': computedStyle.getPropertyValue('--sev-low').trim() || '#FBC02D',
'medium': computedStyle.getPropertyValue('--sev-medium').trim() || '#FF9800',
'high': computedStyle.getPropertyValue('--sev-high').trim() || '#FF5722',
'critical': computedStyle.getPropertyValue('--sev-critical').trim() || '#B71C1C'
};
const series = severities.map(sev => {
return {
name: sev.charAt(0).toUpperCase() + sev.slice(1),
type: 'line',
smooth: true,
showSymbol: false,
itemStyle: { color: severityColors[sev] },
data: dates.map(d => {
const entry = timeline.findings.find(f => f.day === d && f.severity === sev);
return entry ? entry.count : 0;
})
};
});
const option = {
backgroundColor: 'transparent',
tooltip: { trigger: 'axis' },
legend: { data: severities.map(s => s.charAt(0).toUpperCase() + s.slice(1)), bottom: 0 },
grid: { left: '3%', right: '4%', bottom: '15%', top: '10%', containLabel: true },
xAxis: { type: 'category', data: dates, splitLine: { show: false }, axisLine: { lineStyle: { color: themeAxis } } },
yAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
series: series
};
chart.setOption(option);
state.activeCharts.push(chart);
} else {
document.getElementById('chart-findings').innerHTML = '<p class="loading">No findings data available.</p>';
}
} catch (e) {
console.error(e);
document.getElementById('stats-content').innerHTML = `<div class="error">Error loading stats: ${escapeHtml(e.message)}</div>`;
}
}
loadSettings();
fetchConfig().then(router);
</script>
</body>
</html>