bugpatrol 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bugpatrol",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "AI agents that explore your app, find bugs, and fix them.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/ui/app.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Bugpatrol dashboard. Vanilla ES modules on purpose: no build step means the UI
2
2
  // is served straight from source, so `bugpatrol dashboard` works from a clone with
3
3
  // nothing compiled but the CLI itself.
4
+ import { parseCast } from './cast.js';
4
5
  import { layoutGraph } from './graph-layout.js';
5
6
 
6
7
  const view = document.getElementById('view');
@@ -20,12 +21,16 @@ const state = {
20
21
  sessions: [],
21
22
  selectedSessionId: null,
22
23
  sessionDetail: null,
24
+ flow: null,
23
25
  appmap: null,
24
26
  selectedScreenId: null,
25
27
  routines: [],
26
28
  memory: { lessons: [] },
27
29
  runs: [],
28
30
  selectedRunId: null,
31
+ reviews: [],
32
+ selectedReview: null,
33
+ reviewDetail: null,
29
34
  detail: null,
30
35
  live: null,
31
36
  root: '',
@@ -39,6 +44,7 @@ try {
39
44
  }
40
45
  const graphCamera = { box: null, key: '' };
41
46
  let showBackLinks = false;
47
+ let renderedReviews = '';
42
48
 
43
49
  // ---------------------------------------------------------------- data
44
50
 
@@ -103,6 +109,15 @@ async function refresh({ keepSelection = true } = {}) {
103
109
  state.sessionDetail = null;
104
110
  }
105
111
  }
112
+ if (state.view === 'flow') {
113
+ state.sessions = await getJson('/api/sessions');
114
+ if (!state.sessions.some((session) => session.id === state.selectedSessionId)) {
115
+ state.selectedSessionId = state.sessions[0]?.id ?? null;
116
+ }
117
+ state.flow = state.selectedSessionId
118
+ ? await getJson(`/api/flow/${encodeURIComponent(state.selectedSessionId)}`).catch(() => null)
119
+ : null;
120
+ }
106
121
  if (state.view === 'screens') {
107
122
  state.appmap = await getJson('/api/appmap');
108
123
  state.issues = await getJson('/api/issues');
@@ -113,6 +128,19 @@ async function refresh({ keepSelection = true } = {}) {
113
128
  }
114
129
  }
115
130
  if (state.view === 'memory') state.memory = await getJson('/api/memory');
131
+ if (state.view === 'reviews') {
132
+ const reviews = await getJson('/api/reviews');
133
+ if (!reviews.some((review) => review.pr.number === state.selectedReview)) {
134
+ state.selectedReview = reviews[0]?.pr.number ?? null;
135
+ }
136
+ const detail = state.selectedReview ? await getJson(`/api/reviews/${state.selectedReview}`) : null;
137
+ // A rebuilt page restarts a playing video, so skip the render when the review did not change.
138
+ const key = JSON.stringify([reviews, detail]);
139
+ if (key === renderedReviews && view.querySelector('.review-page')) return;
140
+ renderedReviews = key;
141
+ state.reviews = reviews;
142
+ state.reviewDetail = detail;
143
+ }
116
144
  render();
117
145
  }
118
146
 
@@ -143,7 +171,9 @@ function render() {
143
171
  overview: renderOverview,
144
172
  issues: renderIssues,
145
173
  activity: renderActivity,
174
+ flow: renderFlow,
146
175
  screens: renderScreens,
176
+ reviews: renderReviews,
147
177
  memory: renderMemory,
148
178
  checks: renderRuns,
149
179
  };
@@ -1159,6 +1189,78 @@ function renderEventRow(event) {
1159
1189
  ]);
1160
1190
  }
1161
1191
 
1192
+ // The flow view is what a human reads when triaging: the agent's actions, the
1193
+ // failed requests the app reported, and the backend logs that followed, merged
1194
+ // in time order. A log close behind a failure is marked correlated.
1195
+ function renderFlow() {
1196
+ const list = el('section', { class: 'card panel' }, [
1197
+ title('Flow'),
1198
+ ...state.sessions.map((session) =>
1199
+ el(
1200
+ 'button',
1201
+ {
1202
+ class: `list-row ${session.id === state.selectedSessionId ? 'active' : ''}`,
1203
+ onclick: async () => {
1204
+ state.selectedSessionId = session.id;
1205
+ await refresh();
1206
+ },
1207
+ },
1208
+ [
1209
+ roleIcon(session.role),
1210
+ el('span', { class: 'grow' }, [
1211
+ el('strong', { text: firstSentence(session.summary) || `${capital(session.role)} session` }),
1212
+ el('small', {
1213
+ class: 'muted',
1214
+ text: `${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)}`,
1215
+ }),
1216
+ ]),
1217
+ ],
1218
+ ),
1219
+ ),
1220
+ ]);
1221
+ const flow = state.flow;
1222
+ if (!flow) {
1223
+ return el('div', { class: 'master-detail' }, [
1224
+ list,
1225
+ el('section', {
1226
+ class: 'card panel empty',
1227
+ text: 'No flow for this session. Add a `logs:` source to bugpatrol.yml and run a patrol.',
1228
+ }),
1229
+ ]);
1230
+ }
1231
+ const sources = flow.sources.length
1232
+ ? flow.sources.map((source) =>
1233
+ el('div', {
1234
+ text: `${source.name} (${source.kind}): ${source.collected} line${source.collected === 1 ? '' : 's'}`,
1235
+ }),
1236
+ )
1237
+ : [
1238
+ el('div', {
1239
+ text: 'No log sources configured. Add `logs:` to bugpatrol.yml to see backend logs beside each action.',
1240
+ }),
1241
+ ];
1242
+ return el('div', { class: 'master-detail' }, [
1243
+ list,
1244
+ el('section', { class: 'card panel' }, [
1245
+ title('Flow', `${capital(flow.role)} · ${relativeTime(flow.startedAt)}`),
1246
+ el('div', { class: 'muted session-usage' }, sources),
1247
+ flow.entries.length
1248
+ ? el('div', { class: 'event-list' }, flow.entries.map(renderFlowRow))
1249
+ : el('div', { class: 'muted', text: 'This session recorded nothing.' }),
1250
+ ]),
1251
+ ]);
1252
+ }
1253
+
1254
+ function renderFlowRow(entry) {
1255
+ return el('div', { class: `event-row flow-row flow-${entry.kind}${entry.correlated ? ' correlated' : ''}` }, [
1256
+ el('time', { text: new Date(entry.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }),
1257
+ el('span', { class: 'flow-kind', text: entry.kind }),
1258
+ el('span', { class: 'grow truncate', text: entry.summary }),
1259
+ entry.level ? el('small', { class: 'muted', text: entry.level }) : null,
1260
+ entry.screenshot ? image(entry.screenshot, entry.summary, 'event-thumb') : null,
1261
+ ]);
1262
+ }
1263
+
1162
1264
  async function openScreen(id) {
1163
1265
  state.view = 'screens';
1164
1266
  state.selectedScreenId = id;
@@ -1527,6 +1629,303 @@ function renderScreenGraph(screens, allEdges) {
1527
1629
  ]);
1528
1630
  }
1529
1631
 
1632
+ // ---------------------------------------------------------------- reviews
1633
+
1634
+ const VERDICT_BADGE = { proven: 'pass', 'partly-proven': 'warn', 'not-proven': 'fail', untested: 'info' };
1635
+ const VERDICT_WORDS = {
1636
+ proven: 'Proven',
1637
+ 'partly-proven': 'Partly proven',
1638
+ 'not-proven': 'Not proven',
1639
+ untested: 'Untested',
1640
+ };
1641
+ const EVIDENCE_WORDS = { replay: 'replay', assertion: 'assertion', explored: 'explored', bench: 'benchmark' };
1642
+
1643
+ async function openReview(number) {
1644
+ state.selectedReview = number;
1645
+ await refresh();
1646
+ }
1647
+
1648
+ function renderReviews() {
1649
+ const list = el('div', { class: 'card panel' }, [
1650
+ title('Pull request reviews', `${state.reviews.length}`),
1651
+ state.reviews.length
1652
+ ? null
1653
+ : el('p', { class: 'empty', text: 'No reviews yet. Run bugpatrol review <pr> to review a pull request.' }),
1654
+ ...state.reviews.map((review) =>
1655
+ el(
1656
+ 'button',
1657
+ {
1658
+ class: `list-row ${review.pr.number === state.selectedReview ? 'active' : ''}`,
1659
+ onclick: () => openReview(review.pr.number),
1660
+ },
1661
+ [
1662
+ el('span', { class: 'grow' }, [
1663
+ el('strong', { text: `#${review.pr.number} ${review.pr.title ?? ''}` }),
1664
+ el('span', { class: 'row-meta' }, [
1665
+ el('small', {
1666
+ class: 'muted',
1667
+ text: `${reviewStatus(review)} · ${relativeTime(review.endedAt ?? review.startedAt)}`,
1668
+ }),
1669
+ ...Object.entries(review.verdicts ?? {})
1670
+ .filter(([, count]) => count)
1671
+ .map(([verdict, count]) =>
1672
+ el('span', { class: `badge ${VERDICT_BADGE[verdict]}`, text: `${count} ${verdict}` }),
1673
+ ),
1674
+ review.introduced ? el('span', { class: 'badge fail', text: `${review.introduced} introduced` }) : null,
1675
+ ]),
1676
+ ]),
1677
+ ],
1678
+ ),
1679
+ ),
1680
+ ]);
1681
+ return el('div', { class: 'master-detail review-page' }, [list, renderReviewDetail()]);
1682
+ }
1683
+
1684
+ function reviewStatus(review) {
1685
+ if (review.status === 'running') return 'Running';
1686
+ if (review.status === 'failed') return 'Failed';
1687
+ return review.posted ? 'Posted' : 'Finished';
1688
+ }
1689
+
1690
+ function link(href, text) {
1691
+ return el('a', { href, target: '_blank', rel: 'noopener noreferrer', text });
1692
+ }
1693
+
1694
+ function renderReviewDetail() {
1695
+ const detail = state.reviewDetail;
1696
+ if (!detail) return el('section', { class: 'card panel empty', text: 'Select a review.' });
1697
+ const { review, recordings } = detail;
1698
+ const ids = new Set((review.claims ?? []).map((finding) => finding.claim.id));
1699
+ const loose = recordings.filter((rec) => !rec.claimId || !ids.has(rec.claimId));
1700
+ const introduced = review.findings.filter((finding) => finding.verdict === 'introduced');
1701
+ const others = ['pre-existing', 'unclear', 'not-a-bug']
1702
+ .map((verdict) => [verdict, review.findings.filter((finding) => finding.verdict === verdict)])
1703
+ .filter(([, findings]) => findings.length);
1704
+ return el('article', { class: 'card panel review-detail' }, [
1705
+ el('div', { class: 'detail-heading' }, [
1706
+ el('h1', { text: `#${review.pr.number} ${review.pr.title ?? ''}` }),
1707
+ el('span', {
1708
+ class: `badge ${review.status === 'failed' ? 'fail' : review.status === 'running' ? 'warn' : 'info'}`,
1709
+ text: reviewStatus(review),
1710
+ }),
1711
+ ]),
1712
+ el('div', { class: 'kv' }, [
1713
+ review.pr.url ? link(review.pr.url, 'Pull request') : null,
1714
+ review.posted ? link(review.posted.url, 'Review on GitHub') : null,
1715
+ el('span', { class: 'mono', title: review.head, text: `head ${shortCommit(review.head)}` }),
1716
+ el('span', {
1717
+ class: 'mono',
1718
+ title: review.base,
1719
+ text: `base ${shortCommit(review.base)}${review.baseRef ? ` (${review.baseRef})` : ''}`,
1720
+ }),
1721
+ el('span', { text: `Started ${relativeTime(review.startedAt)}` }),
1722
+ review.costUsd ? el('span', { text: `$${review.costUsd.toFixed(3)}` }) : null,
1723
+ ]),
1724
+ review.error ? el('p', { class: 'review-error', text: review.error }) : null,
1725
+ review.claims
1726
+ ? el('section', {}, [
1727
+ title('Claims', `${review.claims.length}`),
1728
+ review.claims.length
1729
+ ? null
1730
+ : el('p', { class: 'muted', text: 'Bugpatrol found no claim in the pull request.' }),
1731
+ ...review.claims.map((finding) =>
1732
+ claimBlock(
1733
+ finding,
1734
+ recordings.filter((rec) => rec.claimId === finding.claim.id),
1735
+ ),
1736
+ ),
1737
+ ])
1738
+ : null,
1739
+ el('section', {}, [
1740
+ title('Introduced problems', `${introduced.length}`),
1741
+ introduced.length
1742
+ ? null
1743
+ : el('p', { class: 'muted', text: 'Bugpatrol found no problem that this pull request introduced.' }),
1744
+ ...introduced.map(findingBlock),
1745
+ ]),
1746
+ ...others.map(([verdict, findings]) =>
1747
+ el('details', { class: 'review-others' }, [
1748
+ el('summary', { text: `${capital(verdict.replace(/-/g, ' '))} (${findings.length})` }),
1749
+ ...findings.map(findingBlock),
1750
+ ]),
1751
+ ),
1752
+ loose.length
1753
+ ? el('section', {}, [
1754
+ title('Other recordings', `${loose.length}`),
1755
+ el(
1756
+ 'div',
1757
+ { class: 'retest-shots' },
1758
+ loose.map((rec) => recordingView(rec)),
1759
+ ),
1760
+ ])
1761
+ : null,
1762
+ review.tested ? el('section', {}, [title('What Bugpatrol tested'), markdown(review.tested)]) : null,
1763
+ ]);
1764
+ }
1765
+
1766
+ function claimSource(source) {
1767
+ if (source.kind === 'section') return 'the claims section';
1768
+ if (source.kind === 'commit') return `commit ${shortCommit(source.commit)}`;
1769
+ if (source.kind === 'issue') return `issue #${source.number}`;
1770
+ return `the ${source.kind}`;
1771
+ }
1772
+
1773
+ function claimBlock(finding, recordings) {
1774
+ const { claim } = finding;
1775
+ const tested = finding.head || finding.base || recordings.length;
1776
+ return el('div', { class: 'claim-block' }, [
1777
+ el('div', { class: 'detail-heading' }, [
1778
+ el('h3', { text: claim.text }),
1779
+ el('span', {
1780
+ class: `badge ${VERDICT_BADGE[finding.verdict] ?? 'info'}`,
1781
+ text: VERDICT_WORDS[finding.verdict] ?? finding.verdict,
1782
+ }),
1783
+ ]),
1784
+ el('div', { class: 'kv' }, [
1785
+ el('span', {
1786
+ text: `Evidence: ${finding.evidence ? (EVIDENCE_WORDS[finding.evidence] ?? finding.evidence) : 'none'}`,
1787
+ }),
1788
+ el('span', { text: `From ${claimSource(claim.source)}` }),
1789
+ el('span', { text: claim.platform }),
1790
+ ]),
1791
+ el('p', { text: finding.reason }),
1792
+ finding.saw ? el('p', {}, [el('strong', { text: 'Saw: ' }), finding.saw]) : null,
1793
+ finding.did ? el('p', { class: 'muted', text: finding.did }) : null,
1794
+ claim.untestable ? el('p', { class: 'muted', text: `Not testable: ${claim.untestable}` }) : null,
1795
+ tested
1796
+ ? el('div', { class: 'retest-shots' }, [
1797
+ buildEvidence(
1798
+ 'Base',
1799
+ finding.base,
1800
+ recordings.filter((rec) => rec.build === 'base'),
1801
+ ),
1802
+ buildEvidence(
1803
+ 'This pull request',
1804
+ finding.head,
1805
+ recordings.filter((rec) => rec.build === 'head'),
1806
+ ),
1807
+ ])
1808
+ : null,
1809
+ finding.steps?.length
1810
+ ? el('details', { class: 'recorded-path' }, [
1811
+ el('summary', { text: `Claim routine: ${finding.steps.length} step(s)` }),
1812
+ el(
1813
+ 'ol',
1814
+ {},
1815
+ finding.steps.map((step) => el('li', { text: step })),
1816
+ ),
1817
+ finding.routine ? el('code', { text: finding.routine }) : null,
1818
+ ])
1819
+ : null,
1820
+ ]);
1821
+ }
1822
+
1823
+ /**
1824
+ * One build's evidence for a claim. A full video plays when there is one, then
1825
+ * an animated recording, then a terminal cast. Without a recording the step
1826
+ * screenshots show instead.
1827
+ */
1828
+ function buildEvidence(label, replay, recordings) {
1829
+ const pick =
1830
+ recordings.find((rec) => rec.kind === 'video') ??
1831
+ recordings.find((rec) => rec.kind === 'image') ??
1832
+ recordings.find((rec) => rec.kind === 'cast');
1833
+ const stopped = replay?.failedStep !== undefined ? ` at step ${replay.failedStep + 1}` : '';
1834
+ const note = !replay
1835
+ ? 'Not replayed.'
1836
+ : replay.ok
1837
+ ? null
1838
+ : `Stopped${stopped}${replay.error ? `: ${replay.error}` : '.'}`;
1839
+ const shots = replay?.shots ?? [];
1840
+ const strip = () =>
1841
+ el(
1842
+ 'div',
1843
+ { class: 'shot-strip' },
1844
+ shots.map((path, index) => image(path, `${label}: ${index ? `after step ${index}` : 'before the first step'}`)),
1845
+ );
1846
+ return el('figure', { class: 'build-evidence' }, [
1847
+ pick ? recordingView(pick) : shots.length ? strip() : el('div', { class: 'image-empty', text: 'No capture' }),
1848
+ el('figcaption', {}, [label, note ? el('span', { class: 'build-note', text: ` · ${note}` }) : null]),
1849
+ pick && shots.length
1850
+ ? el('details', { class: 'build-shots' }, [el('summary', { text: `Screenshots (${shots.length})` }), strip()])
1851
+ : null,
1852
+ ]);
1853
+ }
1854
+
1855
+ function recordingView(rec) {
1856
+ const name = rec.path.split('/').pop();
1857
+ if (rec.kind === 'video')
1858
+ return el('div', { class: 'recording' }, [
1859
+ el('video', { src: artifact(rec.path), controls: '', preload: 'metadata', playsinline: '' }),
1860
+ el('small', { class: 'muted', text: name }),
1861
+ ]);
1862
+ if (rec.kind === 'image')
1863
+ return el('div', { class: 'recording' }, [image(rec.path, name), el('small', { class: 'muted', text: name })]);
1864
+ return castPlayer(rec.path);
1865
+ }
1866
+
1867
+ /** A terminal cast as plain text. It shows the end state; Play replays the output in time. */
1868
+ function castPlayer(path) {
1869
+ const screen = el('pre', { class: 'cast-screen', text: 'Loading the cast…' });
1870
+ const play = el('button', { type: 'button', class: 'filter', text: 'Play', disabled: '' });
1871
+ let cast;
1872
+ let timers = [];
1873
+ play.addEventListener('click', () => {
1874
+ for (const timer of timers) clearTimeout(timer);
1875
+ screen.textContent = '';
1876
+ // A pause longer than a second plays as one second, so a slow command does not stall the replay.
1877
+ let at = 0;
1878
+ let last = 0;
1879
+ timers = cast.events.map((event) => {
1880
+ at += Math.min(event.at - last, 1);
1881
+ last = event.at;
1882
+ return setTimeout(() => {
1883
+ screen.textContent += event.text;
1884
+ }, at * 1000);
1885
+ });
1886
+ });
1887
+ fetch(artifact(path))
1888
+ .then((response) => (response.ok ? response.text() : Promise.reject(new Error(String(response.status)))))
1889
+ .then((text) => {
1890
+ cast = parseCast(text);
1891
+ if (!cast) throw new Error('not an asciicast file');
1892
+ screen.textContent = cast.events.map((event) => event.text).join('');
1893
+ play.removeAttribute('disabled');
1894
+ })
1895
+ .catch((error) => {
1896
+ screen.textContent = `Could not read the cast: ${error.message}`;
1897
+ });
1898
+ return el('div', { class: 'recording' }, [
1899
+ screen,
1900
+ el('div', { class: 'kv' }, [play, el('small', { class: 'muted', text: path.split('/').pop() })]),
1901
+ ]);
1902
+ }
1903
+
1904
+ function findingBlock(finding) {
1905
+ return el('div', { class: 'claim-block' }, [
1906
+ el('div', { class: 'detail-heading' }, [el('h3', { text: finding.title }), severityChip(finding.severity)]),
1907
+ finding.file ? el('code', { text: `${finding.file}${finding.line ? `:${finding.line}` : ''}` }) : null,
1908
+ el('p', { text: finding.reason }),
1909
+ finding.head || finding.base
1910
+ ? el('div', { class: 'retest-shots' }, [
1911
+ el('figure', {}, [image(finding.base, 'Base'), el('figcaption', { text: 'Base' })]),
1912
+ el('figure', {}, [image(finding.head, 'This pull request'), el('figcaption', { text: 'This pull request' })]),
1913
+ ])
1914
+ : null,
1915
+ finding.baseNote ? el('p', { class: 'muted', text: finding.baseNote }) : null,
1916
+ finding.steps?.length
1917
+ ? el('details', { class: 'recorded-path' }, [
1918
+ el('summary', { text: `Steps (${finding.steps.length})` }),
1919
+ el(
1920
+ 'ol',
1921
+ {},
1922
+ finding.steps.map((step) => el('li', { text: step })),
1923
+ ),
1924
+ ])
1925
+ : null,
1926
+ ]);
1927
+ }
1928
+
1530
1929
  function renderMemory() {
1531
1930
  const lessons = state.memory?.lessons ?? [];
1532
1931
  const row = (lesson) =>
package/src/ui/cast.js ADDED
@@ -0,0 +1,32 @@
1
+ // Asciicast reader for the review page. Pure, so it is tested from Node.
2
+ // The page shows a cast as plain text, so colors and cursor moves are dropped.
3
+
4
+ // CSI sequences (colors, cursor moves), OSC sequences (titles), and other escapes.
5
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching terminal escapes is the point.
6
+ const ESCAPES = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]/g;
7
+
8
+ /** Reads an asciicast v2 or v3 file. v2 times are from the start; v3 times are from the previous event. */
9
+ export function parseCast(source) {
10
+ const rows = source.split('\n').flatMap((line) => {
11
+ try {
12
+ return line.trim() ? [JSON.parse(line)] : [];
13
+ } catch {
14
+ return [];
15
+ }
16
+ });
17
+ const header = rows[0];
18
+ if (!header || Array.isArray(header) || ![2, 3].includes(header.version)) return undefined;
19
+ let clock = 0;
20
+ const events = [];
21
+ for (const row of rows.slice(1)) {
22
+ if (!Array.isArray(row) || typeof row[0] !== 'number') continue;
23
+ clock = header.version === 3 ? clock + row[0] : row[0];
24
+ if (row[1] !== 'o' || typeof row[2] !== 'string') continue;
25
+ events.push({ at: Math.round(clock * 1000) / 1000, text: row[2].replace(ESCAPES, '').replace(/\r\n/g, '\n') });
26
+ }
27
+ return {
28
+ width: header.width ?? header.term?.cols ?? 80,
29
+ height: header.height ?? header.term?.rows ?? 24,
30
+ events,
31
+ };
32
+ }
package/src/ui/index.html CHANGED
@@ -15,7 +15,9 @@
15
15
  <nav class="tabs" id="tabs">
16
16
  <button type="button" data-view="overview" class="active">Overview</button>
17
17
  <button type="button" data-view="issues">Issues</button>
18
+ <button type="button" data-view="reviews">Reviews</button>
18
19
  <button type="button" data-view="activity">Activity</button>
20
+ <button type="button" data-view="flow">Flow</button>
19
21
  <button type="button" data-view="screens">Screens</button>
20
22
  <button type="button" data-view="memory">Memory</button>
21
23
  <button type="button" data-view="checks">Checks</button>
package/src/ui/style.css CHANGED
@@ -749,6 +749,29 @@ h1 {
749
749
  font-size: 0.75rem;
750
750
  white-space: nowrap;
751
751
  }
752
+ .flow-row {
753
+ gap: 0.6rem;
754
+ }
755
+ .flow-row.correlated {
756
+ background: color-mix(in srgb, #b93038 12%, transparent);
757
+ }
758
+ .flow-kind {
759
+ flex: none;
760
+ width: 4.2rem;
761
+ font-size: 0.72rem;
762
+ text-transform: uppercase;
763
+ letter-spacing: 0.04em;
764
+ color: var(--muted);
765
+ }
766
+ .flow-action .flow-kind {
767
+ color: #3b6ea5;
768
+ }
769
+ .flow-request .flow-kind {
770
+ color: #ae551c;
771
+ }
772
+ .flow-log .flow-kind {
773
+ color: #4f7a4f;
774
+ }
752
775
  .event-row details {
753
776
  flex: none;
754
777
  font-size: 0.75rem;
@@ -1228,3 +1251,77 @@ h1 {
1228
1251
  gap: 0.35rem 0.5rem;
1229
1252
  margin-top: 0.2rem;
1230
1253
  }
1254
+
1255
+ /* Pull request reviews: each claim, then its evidence on the base and the pull request side by side. */
1256
+ .claim-block {
1257
+ border-top: 1px solid var(--line);
1258
+ padding: 0.75rem 0;
1259
+ }
1260
+ .claim-block h3 {
1261
+ font-size: 0.95rem;
1262
+ margin: 0;
1263
+ }
1264
+ .claim-block p {
1265
+ margin: 0.35rem 0;
1266
+ }
1267
+ .build-evidence {
1268
+ margin: 0;
1269
+ min-width: 0;
1270
+ }
1271
+ .build-evidence figcaption {
1272
+ text-align: center;
1273
+ color: var(--muted);
1274
+ font-size: 0.75rem;
1275
+ }
1276
+ .build-note {
1277
+ color: var(--major);
1278
+ }
1279
+ .build-shots summary {
1280
+ cursor: pointer;
1281
+ color: var(--muted);
1282
+ font-size: 0.75rem;
1283
+ }
1284
+ .shot-strip {
1285
+ display: flex;
1286
+ gap: 0.4rem;
1287
+ overflow-x: auto;
1288
+ }
1289
+ .retest-shots .shot-strip img {
1290
+ flex: 0 0 auto;
1291
+ width: auto;
1292
+ max-width: 100%;
1293
+ height: 220px;
1294
+ }
1295
+ .recording {
1296
+ display: flex;
1297
+ flex-direction: column;
1298
+ gap: 0.25rem;
1299
+ min-width: 0;
1300
+ }
1301
+ .recording video {
1302
+ width: 100%;
1303
+ max-height: 360px;
1304
+ background: var(--panel-2);
1305
+ }
1306
+ .recording small {
1307
+ text-align: center;
1308
+ }
1309
+ .cast-screen {
1310
+ margin: 0;
1311
+ height: 220px;
1312
+ overflow: auto;
1313
+ padding: 0.5rem;
1314
+ background: #0b0d10;
1315
+ color: #e7e9ee;
1316
+ font-size: 0.75rem;
1317
+ white-space: pre-wrap;
1318
+ }
1319
+ .review-error {
1320
+ color: var(--critical);
1321
+ }
1322
+ .review-others summary {
1323
+ cursor: pointer;
1324
+ color: var(--muted);
1325
+ font-weight: 600;
1326
+ margin: 0.5rem 0;
1327
+ }