Skip to content

Commit 459d771

Browse files
ymansurozerclaude
andauthored
feat: make review progress visible, animated, and always-on (#12)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 9833468 commit 459d771

6 files changed

Lines changed: 221 additions & 46 deletions

File tree

src/ui/decisions.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { S, D, toast, api, persist } from "./store";
2-
import { currentFile, applyDecisionToDiff, fileObjections } from "./changes";
2+
import { currentFile, applyDecisionToDiff, fileObjections, fileReviewState } from "./changes";
33
import { render, deferRender } from "./render";
4-
import { nextFileIndex } from "./guide";
4+
import { nextFileIndex, guideProgress } from "./guide";
55
import type { ChangeState, Decision } from "./types";
66

77
// The explicit decision record is the source of truth for accept/reject (decoupled
@@ -63,14 +63,13 @@ export async function approveCurrentFile() {
6363
S.promptFinish?.();
6464
return;
6565
}
66+
// Each sign-off toasts the running score ("7 of 12 files · 58%") so progress is felt at the
67+
// moment it moves, not just visible in the bar. % matches the strip (LOC-weighted by default).
68+
const done = S.state.files.filter((f) => fileReviewState(f.path) !== "pending").length;
69+
toast(`${label}${done} of ${S.state.files.length} files · ${guideProgress().pct}%`);
6670
const next = nextFileIndex(S.fileIndex);
67-
if (next !== null && S.selectFile) {
68-
toast(`${label} — next file`);
69-
S.selectFile(next);
70-
} else {
71-
toast(`${label} — last file`);
72-
render();
73-
}
71+
if (next !== null && S.selectFile) S.selectFile(next);
72+
else render();
7473
}
7574

7675
// Undo a file's review: clear hunk decisions, the finished/sign-off marker + its hash, and unstage.

src/ui/index.html

Lines changed: 95 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,9 @@
171171
.app {
172172
height: 100%;
173173
display: grid;
174-
grid-template-rows: 42px 1fr
174+
/* Tall enough that the topbar buttons keep clear air above the progress strip riding
175+
the bar's bottom edge. */
176+
grid-template-rows: 48px 1fr
175177
}
176178

177179
.top,
@@ -186,11 +188,55 @@
186188
}
187189

188190
.top {
191+
position: relative;
189192
grid-template-columns: 1fr auto 1fr;
190193
gap: 12px;
191194
border-bottom: 1px solid var(--line)
192195
}
193196

197+
/* Review progress: a fill strip riding the topbar's bottom edge (covers the border as it
198+
advances) + the "% reviewed" label beside the actions. progress.ts drives both. */
199+
.top-progress {
200+
position: absolute;
201+
left: 0;
202+
right: 0;
203+
bottom: -1px;
204+
height: 3px;
205+
pointer-events: none
206+
}
207+
208+
.top-progress>i {
209+
display: block;
210+
height: 100%;
211+
width: 0;
212+
background: var(--green);
213+
border-radius: 0 2px 2px 0;
214+
transition: width .45s cubic-bezier(.22, .9, .34, 1)
215+
}
216+
217+
.top-progress.pulse>i {
218+
animation: prog-pulse .8s ease-out
219+
}
220+
221+
@keyframes prog-pulse {
222+
0% {
223+
filter: brightness(1.7);
224+
box-shadow: 0 0 10px 0 var(--green)
225+
}
226+
227+
100% {
228+
filter: brightness(1);
229+
box-shadow: 0 0 0 0 transparent
230+
}
231+
}
232+
233+
.top-pct {
234+
color: var(--muted);
235+
font-size: var(--text-xs);
236+
font-variant-numeric: tabular-nums;
237+
white-space: nowrap
238+
}
239+
194240
.bottom {
195241
grid-template-columns: 1fr auto;
196242
gap: 12px;
@@ -323,7 +369,51 @@
323369

324370
.tree {
325371
border-right: 1px solid var(--line);
326-
padding-top: 8px
372+
padding-top: 8px;
373+
display: flex;
374+
flex-direction: column;
375+
overflow: hidden
376+
}
377+
378+
#files {
379+
flex: 1 1 auto;
380+
min-height: 0;
381+
overflow: auto
382+
}
383+
384+
/* Settings lives docked at the sidebar's far bottom; the topbar twin (.top-settings)
385+
shows only when the tree is hidden (single-column / file mode) so it stays reachable. */
386+
.tree-settings {
387+
flex: 0 0 auto;
388+
display: flex;
389+
align-items: center;
390+
gap: 8px;
391+
margin: auto -10px -10px;
392+
padding: 10px 14px;
393+
border: 0;
394+
border-top: 1px solid var(--line);
395+
background: none;
396+
color: var(--muted);
397+
font-size: var(--text-sm);
398+
text-align: left
399+
}
400+
401+
.tree-settings:hover {
402+
color: var(--ink);
403+
background: var(--surface-raised)
404+
}
405+
406+
.tree-settings kbd {
407+
margin-left: auto
408+
}
409+
410+
.actions .top-settings {
411+
display: none
412+
}
413+
414+
body.single .actions .top-settings,
415+
body.file-mode .actions .top-settings {
416+
display: inline-flex
327417
}
328418

329419
.right {
@@ -413,30 +503,6 @@
413503
cursor: default
414504
}
415505

416-
.guidebar .gb-progress {
417-
flex: 0 0 auto;
418-
display: flex;
419-
align-items: center;
420-
gap: 7px;
421-
color: var(--muted);
422-
font-size: var(--text-xs)
423-
}
424-
425-
.guidebar .gb-bar {
426-
width: 60px;
427-
height: 3px;
428-
border-radius: var(--radius-xs);
429-
overflow: hidden;
430-
background: var(--line)
431-
}
432-
433-
.guidebar .gb-bar>i {
434-
display: block;
435-
height: 100%;
436-
background: var(--green);
437-
transition: width .25s
438-
}
439-
440506
.guidebar .gb-fill {
441507
flex: 1 1 auto
442508
}
@@ -2262,10 +2328,11 @@
22622328
:class="{active: $store.g.fileView==='source'}" @click="$store.g.setFileView('source')">Source</button>
22632329
</div>
22642330
</div>
2265-
<div class="actions"><button class="btn icon" data-tip="Settings (⇧,)" @click="$store.g.openSettings()"><svg class="ic"><use href="#gly-settings"></use></svg></button><button
2331+
<div class="actions"><span class="top-pct" id="progressPct" style="display:none"></span><button class="btn icon top-settings" data-tip="Settings (⇧,)" @click="$store.g.openSettings()"><svg class="ic"><use href="#gly-settings"></use></svg></button><button
22662332
class="btn danger" data-tip="Reset review (⇧R)" @click="$store.g.reset()">Reset Review</button><button class="btn primary"
22672333
:disabled="$store.g.awaitingAgent" @click="$store.g.send()"><span
22682334
x-text="$store.g.awaitingAgent ? 'Waiting for Agent…' : 'Send to Agent'">Send to Agent</span> <kbd>⇧S</kbd></button></div>
2335+
<div class="top-progress" id="progressStrip" style="display:none"><i id="progressFill"></i></div>
22692336
</header>
22702337
<main class="main">
22712338
<aside class="tree">
@@ -2292,6 +2359,7 @@
22922359
</span>
22932360
</div>
22942361
</template></div>
2362+
<button class="tree-settings" @click="$store.g.openSettings()"><svg class="ic"><use href="#gly-settings"></use></svg><span>Settings</span><kbd>⇧,</kbd></button>
22952363
</aside>
22962364
<div class="resizer" data-resize="left"></div>
22972365
<section class="center">
@@ -2306,10 +2374,6 @@
23062374
<span class="gb-stale" x-show="$store.g.guideStale && $store.g.guideStale()"
23072375
title="Guide generated for an earlier diff — regenerate and restart with --guide to refresh"><svg class="ic"><use href="#gly-warn"></use></svg> Guide Stale</span>
23082376
<span class="gb-fill"></span>
2309-
<span class="gb-progress" x-show="$store.g.guideProgress">
2310-
<span class="gb-bar"><i :style="`width:${$store.g.guideProgress().pct}%`"></i></span>
2311-
<span x-text="$store.g.guideProgress().pct + '% reviewed'"></span>
2312-
</span>
23132377
</div>
23142378
<div class="diff-area">
23152379
<div id="diff" x-ignore></div>

src/ui/main.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ import {
1919
guideStale,
2020
nextFileIndex,
2121
prevFileIndex,
22-
guideProgress,
2322
categorySteps,
2423
firstFileOfCategory,
2524
} from "./guide";
25+
import { setBaseTitle, reviewStats } from "./progress";
2626
import { installKeys, helpGroups, confirmYes, confirmNo, askConfirm } from "./keys";
2727
import { cursorReset, cursorSelection } from "./cursor";
2828
import type { ReviewState, FileRow, Settings, DiffStyle } from "./types";
@@ -237,10 +237,22 @@ S.treeStep = (dir) => {
237237
S.helpGroups = helpGroups;
238238
S.confirmYes = confirmYes;
239239
S.confirmNo = confirmNo;
240-
// Fired after the last file is approved — offer to send the finished review back to the agent.
241-
S.promptFinish = () =>
242-
askConfirm("You've reviewed every file. Send the review back to the agent?", () => S.send?.());
243-
S.guideProgress = guideProgress;
240+
// Fired after the last file is approved — a small receipt of the work done (files, lines,
241+
// comments, rejections) plus the offer to send the finished review back to the agent.
242+
S.promptFinish = () => {
243+
const { files, lines, comments, rejections } = reviewStats();
244+
const n = (c: number, w: string) => `${c} ${w}${c === 1 ? "" : "s"}`;
245+
const extras = [
246+
comments ? n(comments, "comment") : "",
247+
rejections ? n(rejections, "rejected hunk") : "",
248+
].filter(Boolean);
249+
const what = files === 1 ? "the file" : `all ${files} files`;
250+
const tail = extras.length ? extras.join(", ") : "all clean";
251+
askConfirm(
252+
`You've reviewed ${what}${n(lines, "changed line")}, ${tail}. Send the review back to the agent?`,
253+
() => S.send?.(),
254+
);
255+
};
244256
// Category stepper (count + fill): clicking a category jumps to its first unreviewed file.
245257
S.categorySteps = categorySteps;
246258
S.jumpToCategory = (cat) => {
@@ -386,6 +398,8 @@ S.lastBaseDiffHash = S.state.baseDiffHash;
386398
? ref.slice(0, 32) + (ref.length > 32 ? "…" : "")
387399
: base(S.state.root);
388400
if (name) document.title = `Galley — ${name}`;
401+
// progress.ts prefixes the title with the review % — hand it the base to prefix.
402+
setBaseTitle(document.title);
389403
}
390404
S.selected = {
391405
side: S.state.changes[0]?.side || "additions",

src/ui/progress.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { S, $ } from "./store";
2+
import { guideProgress } from "./guide";
3+
4+
// Persistent review-progress chrome: a full-width fill strip along the bottom edge of the
5+
// topbar plus a "% reviewed" label beside the actions, visible with or without a guide (the
6+
// guidebar used to be progress's only home, so guideless desks showed none). Imperative
7+
// rather than Alpine because the *moment* of progress is animated — the number counts up
8+
// odometer-style and the strip pulses when the bar advances — which is rAF work.
9+
10+
// Tab title carries progress too ("(58%) Galley — repo"), so it reads from other tabs.
11+
// main.ts names the base title at init; updateProgress stamps the prefix.
12+
let baseTitle = document.title;
13+
export function setBaseTitle(title: string) {
14+
baseTitle = title;
15+
}
16+
17+
let shownPct: number | null = null; // % the label currently shows; null until first paint
18+
let raf = 0;
19+
20+
// Count the label from `from` to `to` over ~450ms (ease-out) instead of jumping.
21+
function countUp(label: HTMLElement, from: number, to: number) {
22+
cancelAnimationFrame(raf);
23+
const start = performance.now();
24+
const tick = (now: number) => {
25+
const k = Math.min(1, (now - start) / 450);
26+
const eased = 1 - (1 - k) ** 3;
27+
label.textContent = `${Math.round(from + (to - from) * eased)}% reviewed`;
28+
if (k < 1) raf = requestAnimationFrame(tick);
29+
};
30+
raf = requestAnimationFrame(tick);
31+
}
32+
33+
// Restart the .pulse CSS animation even when the class is already on the element.
34+
function pulse(el: HTMLElement) {
35+
el.classList.remove("pulse");
36+
void el.offsetWidth;
37+
el.classList.add("pulse");
38+
}
39+
40+
// Called from render(): every state mutation that can move progress ends in a render, so
41+
// this is the single repaint point (and it must stay cheap — guideProgress is one pass).
42+
export function updateProgress() {
43+
const strip = $("progressStrip");
44+
const label = $("progressPct");
45+
if (!strip || !label) return;
46+
const hasFiles = !!S.state?.files?.length;
47+
strip.style.display = hasFiles ? "" : "none";
48+
label.style.display = hasFiles ? "" : "none";
49+
if (!hasFiles) {
50+
document.title = baseTitle;
51+
return;
52+
}
53+
const pct = guideProgress().pct;
54+
document.title = pct >= 100 ? `✓ ${baseTitle}` : pct > 0 ? `(${pct}%) ${baseTitle}` : baseTitle;
55+
($("progressFill") as HTMLElement).style.width = `${pct}%`; // CSS transition animates the fill
56+
if (shownPct === null || pct === shownPct) {
57+
// First paint, or no movement (a re-render that didn't change progress): no ceremony.
58+
label.textContent = `${pct}% reviewed`;
59+
shownPct = pct;
60+
return;
61+
}
62+
if (pct > shownPct) pulse(strip);
63+
countUp(label, shownPct, pct);
64+
shownPct = pct;
65+
}
66+
67+
// Whole-review numbers for the completion prompt — a small receipt of the work done.
68+
export function reviewStats(): {
69+
files: number;
70+
lines: number;
71+
comments: number;
72+
rejections: number;
73+
} {
74+
let lines = 0;
75+
for (const f of S.state?.files ?? [])
76+
for (const h of f.hunks ?? []) for (const l of h.lines) if (l.kind !== "context") lines++;
77+
return {
78+
files: S.state?.files?.length ?? 0,
79+
lines,
80+
comments: (S.state?.comments ?? []).filter((c) => c.role === "user" && c.status === "open")
81+
.length,
82+
rejections: (S.state?.changes ?? []).filter((c) => c.status === "rejected").length,
83+
};
84+
}

src/ui/render.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { isMarkdownPath, renderMarkdownFile } from "./mdfile";
2626
import { renderMarkdown } from "./markdown";
2727
import { cursorResync, cursorReset } from "./cursor";
2828
import { hasGuide, renderOverview, currentGuideEntry } from "./guide";
29+
import { updateProgress } from "./progress";
2930

3031
const SVG_NS = "http://www.w3.org/2000/svg";
3132
// Cheap stable hash (FNV-1a, base36) for a string — used as @pierre cacheKeys for the old
@@ -224,7 +225,21 @@ function renderOverviewRuler() {
224225
ruler.classList.add("show");
225226
}
226227

228+
// Every progress-moving mutation (decision, approval, reset, reload) funnels through render,
229+
// so it is the progress strip's single repaint point. It must run AFTER the render work has
230+
// PAINTED, not merely after renderCenter returns: the transition clock starts at style-commit,
231+
// and a file switch's first frame is spent tokenizing + laying out the new diff DOM — a bar
232+
// started before (or during) that frame lands already-finished, i.e. no visible motion. The
233+
// double rAF puts the width change on the first idle frame after that paint.
227234
export async function render() {
235+
try {
236+
await renderCenter();
237+
} finally {
238+
requestAnimationFrame(() => requestAnimationFrame(updateProgress));
239+
}
240+
}
241+
242+
async function renderCenter() {
228243
clearOverviewRuler();
229244
const host = $("diff");
230245
// Leaving the diff view (overview / markdown): just detach the active instance — its cached

src/ui/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,6 @@ export interface Store {
211211
guidePrev?: () => void;
212212
guideAtStart?: () => boolean;
213213
guideAtLast?: () => boolean;
214-
guideProgress?: () => { done: number; approved: number; total: number; pct: number };
215214
categorySteps?: () => CategoryStep[];
216215
jumpToCategory?: (category: string) => void;
217216
saveComment?: () => void;

0 commit comments

Comments
 (0)