Add per-snapshot review link and snapshot picker to Reviews tab

This commit is contained in:
Ian Gulliver
2026-10-15 17:46:41 -07:00
parent 525f11923d
commit a19067a665
11 changed files with 105 additions and 32 deletions
+27 -8
View File
@@ -1,7 +1,7 @@
import { get } from "./api.js";
import { ago, errorText, h, replace } from "./dom.js";
import { markdown } from "./markdown.js";
import { onEach, state } from "./state.js";
import { onEach, pickReview, state } from "./state.js";
const severities = ["critical", "high", "medium", "low", "info"];
export function isReviewEvent(ev) {
return ev.kind.startsWith("review.");
@@ -38,7 +38,8 @@ function reviewResult(r, taskId) {
: null);
}
export function mountReviews(root) {
const list = h("div", { class: "review-list" });
const head = h("header", { class: "reviews-header" });
const body = h("div", { class: "review-list" });
let reviews;
let error = "";
let target = 0;
@@ -69,23 +70,40 @@ export function mountReviews(root) {
};
const render = () => {
const taskId = state.selectedTaskId;
replace(head);
head.hidden = true;
if (!taskId) {
replace(list, h("div", { class: "empty" }, "No task selected."));
replace(body, h("div", { class: "empty" }, "No task selected."));
return;
}
if (error) {
replace(list, h("div", { class: "empty error-text" }, error));
replace(body, h("div", { class: "empty error-text" }, error));
return;
}
if (!reviews) {
replace(list, h("div", { class: "empty" }, "Loading…"));
replace(body, h("div", { class: "empty" }, "Loading…"));
return;
}
if (reviews.length === 0) {
replace(list, h("div", { class: "empty" }, "No reviews yet. Each new snapshot of the task is reviewed by an agent from the other vendor."));
replace(body, h("div", { class: "empty" }, "No reviews yet. Each new snapshot of the task is reviewed by an agent from the other vendor."));
return;
}
replace(list, ...[...reviews].reverse().map((r) => reviewCard(r, taskId)));
const n = state.reviewed ?? reviews.at(-1).snapshot_n;
const shown = reviews.findLast((r) => r.snapshot_n === n);
const ns = [...new Set([n, ...reviews.map((r) => r.snapshot_n)])].sort((a, b) => b - a);
const picker = h("select", { title: "Snapshot whose review to show" }, ...ns.map((s) => h("option", { value: String(s) }, `snapshot #${s}`)));
picker.value = String(n);
picker.onchange = () => pickReview(Number(picker.value));
const step = (to, label) => {
const b = h("button", { type: "button", class: "snap-step", disabled: !ns.includes(to) }, label);
b.onclick = () => pickReview(to);
return b;
};
const next = ns.filter((s) => s > n).at(-1);
const prev = ns.find((s) => s < n);
head.hidden = false;
replace(head, picker, step(prev ?? n - 1, `← #${prev ?? n - 1}`), step(next ?? n + 1, `#${next ?? n + 1} →`));
replace(body, shown ? reviewCard(shown, taskId) : h("div", { class: "empty" }, `No review of snapshot #${n} yet.`));
};
const offs = [
onEach(["selection"], () => {
@@ -100,8 +118,9 @@ export function mountReviews(root) {
void load();
}
}),
onEach(["review"], render),
];
replace(root, h("div", { class: "reviews-view" }, list));
replace(root, h("div", { class: "reviews-view" }, head, body));
render();
void load();
return () => {
+1 -1
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -9,6 +9,7 @@ export const state = {
selectedTaskId: undefined,
transcript: [],
pinned: undefined,
reviewed: undefined,
connected: false,
focus: { tabs: ["transcript"], tab: "transcript" },
leadSessions: [],
@@ -62,6 +63,14 @@ export function pinSnapshot(n) {
state.pinned = n;
notify("pin");
}
export function pickReview(n) {
state.reviewed = n;
notify("review");
}
export function showReview(n) {
pickReview(n);
focusTab("reviews");
}
export async function loadMe() {
state.me = await get("/api/me");
notify("me");
@@ -203,6 +212,7 @@ export function selectTask(taskId) {
taskFeed.reset();
state.selectedTaskId = taskId;
state.pinned = undefined;
state.reviewed = undefined;
notify("selection");
if (!taskId) {
return;
+1 -1
View File
File diff suppressed because one or more lines are too long
+5 -5
View File
@@ -3,7 +3,7 @@ import { ago, copyable, errorText, h, replace, showAlert, showConfirm } from "./
import { markdown } from "./markdown.js";
import { previewURL } from "./preview.js";
import { reviewCounts } from "./reviews.js";
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, state } from "./state.js";
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, showReview, state } from "./state.js";
import { taskStyle } from "./taskcolor.js";
export const models = [
"claude-opus-5-5",
@@ -776,8 +776,10 @@ function snapRow(snap, taskId) {
void showAlert(errorText(err));
}
};
const review = h("button", { type: "button", title: `Show the review of snapshot ${snap.n} in the Reviews tab` }, "review");
review.onclick = () => showReview(snap.n);
const time = new Date(snap.created_at).toLocaleTimeString([], { hour12: false });
return h("div", { class: "snap-row", title: `${snap.trigger.replace("_", " ")} · ${time}` }, `snapshot #${snap.n}`, changes, fork);
return h("div", { class: "snap-row", title: `${snap.trigger.replace("_", " ")} · ${time}` }, `snapshot #${snap.n}`, changes, fork, review);
}
function runSummary(calls) {
const counts = new Map();
@@ -890,10 +892,8 @@ function renderEvent(ev, ctx) {
return h("div", { class: "note" }, `${ev.actor} set the merge commit title to "${String(p["merge_title"])}" `, when);
case "review.finished": {
const r = p;
const open = h("button", { type: "button", title: "Show the task's reviews" }, "reviews");
open.onclick = () => focusTab("reviews");
const outcome = r.state === "failed" ? `failed: ${r.error}` : reviewCounts(r);
return h("div", { class: `note${r.state === "failed" ? " error" : ""}` }, `review of snapshot #${r.snapshot_n}: ${outcome} `, when, " ", open);
return h("div", { class: `note${r.state === "failed" ? " error" : ""}` }, `review of snapshot #${r.snapshot_n}: ${outcome} `, when);
}
case "task.merge_queued":
return h("div", { class: "note" }, `${ev.actor} asked to merge; held until the merge in progress in this project is done `, when);
+1 -1
View File
File diff suppressed because one or more lines are too long
+27 -8
View File
@@ -1,7 +1,7 @@
import { get, type ByreEvent, type Schemas } from "./api.js";
import { ago, errorText, h, replace } from "./dom.js";
import { markdown } from "./markdown.js";
import { onEach, state } from "./state.js";
import { onEach, pickReview, state } from "./state.js";
type Review = Schemas["Review"];
@@ -80,7 +80,8 @@ function reviewResult(r: Review, taskId: string | undefined): HTMLElement {
}
export function mountReviews(root: HTMLElement): () => void {
const list = h("div", { class: "review-list" });
const head = h("header", { class: "reviews-header" });
const body = h("div", { class: "review-list" });
let reviews: Review[] | undefined;
let error = "";
let target = 0;
@@ -112,23 +113,40 @@ export function mountReviews(root: HTMLElement): () => void {
const render = () => {
const taskId = state.selectedTaskId;
replace(head);
head.hidden = true;
if (!taskId) {
replace(list, h("div", { class: "empty" }, "No task selected."));
replace(body, h("div", { class: "empty" }, "No task selected."));
return;
}
if (error) {
replace(list, h("div", { class: "empty error-text" }, error));
replace(body, h("div", { class: "empty error-text" }, error));
return;
}
if (!reviews) {
replace(list, h("div", { class: "empty" }, "Loading…"));
replace(body, h("div", { class: "empty" }, "Loading…"));
return;
}
if (reviews.length === 0) {
replace(list, h("div", { class: "empty" }, "No reviews yet. Each new snapshot of the task is reviewed by an agent from the other vendor."));
replace(body, h("div", { class: "empty" }, "No reviews yet. Each new snapshot of the task is reviewed by an agent from the other vendor."));
return;
}
replace(list, ...[...reviews].reverse().map((r) => reviewCard(r, taskId)));
const n = state.reviewed ?? reviews.at(-1)!.snapshot_n;
const shown = reviews.findLast((r) => r.snapshot_n === n);
const ns = [...new Set([n, ...reviews.map((r) => r.snapshot_n)])].sort((a, b) => b - a);
const picker = h("select", { title: "Snapshot whose review to show" }, ...ns.map((s) => h("option", { value: String(s) }, `snapshot #${s}`)));
picker.value = String(n);
picker.onchange = () => pickReview(Number(picker.value));
const step = (to: number, label: string) => {
const b = h("button", { type: "button", class: "snap-step", disabled: !ns.includes(to) }, label);
b.onclick = () => pickReview(to);
return b;
};
const next = ns.filter((s) => s > n).at(-1);
const prev = ns.find((s) => s < n);
head.hidden = false;
replace(head, picker, step(prev ?? n - 1, `← #${prev ?? n - 1}`), step(next ?? n + 1, `#${next ?? n + 1} →`));
replace(body, shown ? reviewCard(shown, taskId) : h("div", { class: "empty" }, `No review of snapshot #${n} yet.`));
};
const offs = [
@@ -144,9 +162,10 @@ export function mountReviews(root: HTMLElement): () => void {
void load();
}
}),
onEach(["review"], render),
];
replace(root, h("div", { class: "reviews-view" }, list));
replace(root, h("div", { class: "reviews-view" }, head, body));
render();
void load();
return () => {
+13
View File
@@ -12,6 +12,7 @@ export type Topic =
| "connection"
| "event"
| "pin"
| "review"
| "focus"
| "lead.sessions"
| "lead.transcript"
@@ -28,6 +29,7 @@ export const state = {
selectedTaskId: undefined as string | undefined,
transcript: [] as ByreEvent[],
pinned: undefined as number | undefined,
reviewed: undefined as number | undefined,
connected: false,
focus: { tabs: ["transcript"], tab: "transcript" } as Focus,
leadSessions: [] as AgentSession[],
@@ -92,6 +94,16 @@ export function pinSnapshot(n: number | undefined): void {
notify("pin");
}
export function pickReview(n: number): void {
state.reviewed = n;
notify("review");
}
export function showReview(n: number): void {
pickReview(n);
focusTab("reviews");
}
export async function loadMe(): Promise<void> {
state.me = await get("/api/me");
notify("me");
@@ -254,6 +266,7 @@ export function selectTask(taskId: string | undefined): void {
taskFeed.reset();
state.selectedTaskId = taskId;
state.pinned = undefined;
state.reviewed = undefined;
notify("selection");
if (!taskId) {
+5 -5
View File
@@ -3,7 +3,7 @@ import { ago, copyable, errorText, h, replace, showAlert, showConfirm } from "./
import { markdown } from "./markdown.js";
import { previewURL } from "./preview.js";
import { reviewCounts } from "./reviews.js";
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, state, type Topic } from "./state.js";
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, showReview, state, type Topic } from "./state.js";
import { taskStyle } from "./taskcolor.js";
export type Usage = { used: number; window: number; model: string };
@@ -907,8 +907,10 @@ function snapRow(snap: Snapshot, taskId: string | undefined): HTMLElement {
void showAlert(errorText(err));
}
};
const review = h("button", { type: "button", title: `Show the review of snapshot ${snap.n} in the Reviews tab` }, "review");
review.onclick = () => showReview(snap.n);
const time = new Date(snap.created_at).toLocaleTimeString([], { hour12: false });
return h("div", { class: "snap-row", title: `${snap.trigger.replace("_", " ")} · ${time}` }, `snapshot #${snap.n}`, changes, fork);
return h("div", { class: "snap-row", title: `${snap.trigger.replace("_", " ")} · ${time}` }, `snapshot #${snap.n}`, changes, fork, review);
}
function runSummary(calls: Call[]): HTMLElement {
@@ -1052,10 +1054,8 @@ function renderEvent(ev: ByreEvent, ctx: Ctx): HTMLElement | null {
case "review.finished": {
const r = p as unknown as Review;
const open = h("button", { type: "button", title: "Show the task's reviews" }, "reviews");
open.onclick = () => focusTab("reviews");
const outcome = r.state === "failed" ? `failed: ${r.error}` : reviewCounts(r);
return h("div", { class: `note${r.state === "failed" ? " error" : ""}` }, `review of snapshot #${r.snapshot_n}: ${outcome} `, when, " ", open);
return h("div", { class: `note${r.state === "failed" ? " error" : ""}` }, `review of snapshot #${r.snapshot_n}: ${outcome} `, when);
}
case "task.merge_queued":
+12
View File
@@ -1612,6 +1612,18 @@ pre.file-text .line::before {
background: var(--raised);
}
.reviews-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid var(--border);
}
.reviews-header[hidden] {
display: none;
}
.card .review {
background: var(--surface);
}