Skip to content

Commit 021afe2

Browse files
committed
feat: add file readiness summaries to sidebar
1 parent 2af2dee commit 021afe2

File tree

3 files changed

+159
-7
lines changed

3 files changed

+159
-7
lines changed

TODO.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
9191
52. [ ] Add comment grouping by unresolved, fixed, stale, and informational sections in `ReviewView`.
9292
53. [x] Add a "show only blockers" mode for large reviews.
9393
54. [ ] Add keyboard actions for thumbs, resolve, and jump-to-next-finding workflows.
94-
55. [ ] Add file-level readiness summaries in the diff sidebar.
94+
55. [x] Add file-level readiness summaries in the diff sidebar.
9595
56. [x] Add lifecycle-aware PR summaries that explain what still blocks merge.
9696
57. [ ] Add a "train the reviewer" callout when thumbs coverage on a review is low.
9797
58. [ ] Add review-change comparisons so users can diff one review run against the next on the same PR.
@@ -163,4 +163,5 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
163163
- [x] Surface lifecycle-aware PR readiness summaries in the GitHub PR detail workflow.
164164
- [x] Surface unresolved blocker counts in repo and PR GitHub discovery views.
165165
- [x] Add a blocker-only review mode that narrows large reviews to open Error and Warning findings.
166+
- [x] Add file-level readiness summaries to the review diff sidebar.
166167
- [ ] Commit and push each validated checkpoint before moving to the next epic.

web/src/components/FileSidebar.tsx

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ interface Props {
1010
onSelectFile: (file: string | null) => void
1111
}
1212

13+
type FileStats = {
14+
total: number
15+
worst: number
16+
openBlockers: number
17+
openInformational: number
18+
resolved: number
19+
dismissed: number
20+
}
21+
1322
const statusIcon = {
1423
added: FilePlus,
1524
deleted: FileX,
@@ -26,14 +35,71 @@ const statusDot = {
2635

2736
const sevRank: Record<Severity, number> = { Error: 0, Warning: 1, Info: 2, Suggestion: 3 }
2837

38+
function buildReadinessLabel(stats: FileStats): { label: string; tone: string } {
39+
if (stats.openBlockers > 0) {
40+
return {
41+
label: `${stats.openBlockers} blocker${stats.openBlockers === 1 ? '' : 's'}`,
42+
tone: 'text-sev-warning',
43+
}
44+
}
45+
46+
if (stats.openInformational > 0) {
47+
return {
48+
label: 'Info only',
49+
tone: 'text-accent',
50+
}
51+
}
52+
53+
return {
54+
label: 'Clear',
55+
tone: 'text-sev-suggestion',
56+
}
57+
}
58+
59+
function buildReadinessDetails(stats: FileStats): string | null {
60+
const details: string[] = []
61+
62+
if (stats.openBlockers > 0 && stats.openInformational > 0) {
63+
details.push(`${stats.openInformational} info`)
64+
}
65+
if (stats.resolved > 0) {
66+
details.push(`${stats.resolved} resolved`)
67+
}
68+
if (stats.dismissed > 0) {
69+
details.push(`${stats.dismissed} dismissed`)
70+
}
71+
72+
return details.length > 0 ? details.join(' • ') : null
73+
}
74+
2975
export function FileSidebar({ files, comments, selectedFile, onSelectFile }: Props) {
3076
const fileStats = useMemo(() => {
31-
const stats = new Map<string, { count: number; worst: number }>()
77+
const stats = new Map<string, FileStats>()
3278
for (const c of comments) {
3379
const path = c.file_path.replace(/^\.\//, '')
34-
const existing = stats.get(path) || { count: 0, worst: 4 }
35-
existing.count++
80+
const existing = stats.get(path) || {
81+
total: 0,
82+
worst: 4,
83+
openBlockers: 0,
84+
openInformational: 0,
85+
resolved: 0,
86+
dismissed: 0,
87+
}
88+
const lifecycle = c.status ?? 'Open'
89+
90+
existing.total++
3691
existing.worst = Math.min(existing.worst, sevRank[c.severity])
92+
93+
if (lifecycle === 'Resolved') {
94+
existing.resolved++
95+
} else if (lifecycle === 'Dismissed') {
96+
existing.dismissed++
97+
} else if (c.severity === 'Error' || c.severity === 'Warning') {
98+
existing.openBlockers++
99+
} else {
100+
existing.openInformational++
101+
}
102+
37103
stats.set(path, existing)
38104
}
39105
return stats
@@ -95,23 +161,35 @@ export function FileSidebar({ files, comments, selectedFile, onSelectFile }: Pro
95161
const Icon = statusIcon[file.status]
96162
const stats = fileStats.get(file.path)
97163
const isSelected = selectedFile === file.path
164+
const readiness = stats ? buildReadinessLabel(stats) : null
165+
const readinessDetails = stats ? buildReadinessDetails(stats) : null
98166

99167
return (
100168
<button
101169
key={file.path}
102170
onClick={() => onSelectFile(file.path)}
103-
className={`w-full text-left px-3 py-1 flex items-center gap-1.5 text-[12px] transition-colors ${
171+
className={`w-full text-left px-3 py-1.5 flex items-start gap-1.5 text-[12px] transition-colors ${
104172
isSelected
105173
? 'bg-accent/10 text-accent'
106174
: 'text-text-secondary hover:bg-surface-2 hover:text-text-primary'
107175
}`}
108176
>
109177
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${statusDot[file.status]}`} />
110178
<Icon size={12} className="shrink-0 text-text-muted" />
111-
<span className="truncate font-code">{getFileName(file.path)}</span>
179+
<div className="min-w-0 flex-1">
180+
<div className="truncate font-code">{getFileName(file.path)}</div>
181+
{readiness && (
182+
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[10px]">
183+
<span className={`font-medium ${readiness.tone}`}>{readiness.label}</span>
184+
{readinessDetails && (
185+
<span className="text-text-muted">{readinessDetails}</span>
186+
)}
187+
</div>
188+
)}
189+
</div>
112190
{stats && (
113191
<span className={`ml-auto shrink-0 text-[10px] text-white px-1 py-0 rounded ${countColor(stats.worst)}`}>
114-
{stats.count}
192+
{stats.total}
115193
</span>
116194
)}
117195
</button>
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { render, screen } from '@testing-library/react'
2+
import userEvent from '@testing-library/user-event'
3+
import { describe, expect, it, vi } from 'vitest'
4+
5+
import { FileSidebar } from '../FileSidebar'
6+
import type { Comment, DiffFile } from '../../api/types'
7+
8+
function makeFile(path: string): DiffFile {
9+
return {
10+
path,
11+
status: 'modified',
12+
hunks: [],
13+
}
14+
}
15+
16+
function makeComment(overrides: Partial<Comment> = {}): Comment {
17+
return {
18+
id: 'comment-1',
19+
file_path: 'src/a.ts',
20+
line_number: 1,
21+
content: 'A finding',
22+
severity: 'Error',
23+
category: 'Bug',
24+
confidence: 0.9,
25+
tags: [],
26+
fix_effort: 'Medium',
27+
status: 'Open',
28+
...overrides,
29+
}
30+
}
31+
32+
describe('FileSidebar', () => {
33+
it('renders file-level readiness summaries from comment lifecycle state', () => {
34+
render(
35+
<FileSidebar
36+
files={[makeFile('src/a.ts'), makeFile('src/b.ts'), makeFile('src/c.ts')]}
37+
comments={[
38+
makeComment(),
39+
makeComment({ id: 'comment-2', severity: 'Info' }),
40+
makeComment({ id: 'comment-3', severity: 'Warning', status: 'Resolved' }),
41+
makeComment({ id: 'comment-4', file_path: 'src/b.ts', severity: 'Info' }),
42+
makeComment({ id: 'comment-5', file_path: 'src/b.ts', severity: 'Suggestion', status: 'Dismissed' }),
43+
makeComment({ id: 'comment-6', file_path: 'src/c.ts', severity: 'Warning', status: 'Resolved' }),
44+
]}
45+
selectedFile={null}
46+
onSelectFile={() => {}}
47+
/>,
48+
)
49+
50+
expect(screen.getByText('1 blocker')).toBeInTheDocument()
51+
expect(screen.getByText('1 info • 1 resolved')).toBeInTheDocument()
52+
expect(screen.getByText('Info only')).toBeInTheDocument()
53+
expect(screen.getByText('1 dismissed')).toBeInTheDocument()
54+
expect(screen.getByText('Clear')).toBeInTheDocument()
55+
})
56+
57+
it('keeps file selection working', async () => {
58+
const user = userEvent.setup()
59+
const onSelectFile = vi.fn()
60+
61+
render(
62+
<FileSidebar
63+
files={[makeFile('src/a.ts')]}
64+
comments={[makeComment()]}
65+
selectedFile={null}
66+
onSelectFile={onSelectFile}
67+
/>,
68+
)
69+
70+
await user.click(screen.getByRole('button', { name: /a.ts/i }))
71+
expect(onSelectFile).toHaveBeenCalledWith('src/a.ts')
72+
})
73+
})

0 commit comments

Comments
 (0)