Tighten review format, badge finding counts, add per-finding fix button
This commit is contained in:
+2
-2
@@ -241,10 +241,10 @@ The fork's session guidance gets one more line (`forkGuidance` in `internal/agen
|
||||
Every new snapshot of a task gets a review: a separate agent session on the vendor the task's agent doesn't use reads the change and reports on it (`internal/agents/review.go`, `internal/store/review.go`). A turn that changes no files makes no snapshot and so no review, and a snapshot whose change (the diff below) has no files, such as a task's first snapshot taken before its agent changed anything, gets no review either. Nothing else starts a review, and nothing waits for one: reviews ride along for the human to look at when they want.
|
||||
|
||||
- **Starting.** After `takeSnapshot` creates a snapshot, `afterSnapshot` runs `startReviewer`: it picks the vendor opposite the task's latest implementer session (`reviewVendor`) and that vendor's model in `reviewModels`, checks the snapshot's commit out detached at `worktrees/<project-id>/review-<review-id>`, creates an `agent_sessions` row with `role` `reviewer` on the task (created by the task's creator), a `reviews` row (`running`, one per snapshot), and sends `reviewPrompt` as its first turn (actor `byre`): the change as `git diff` from the previous snapshot (the snapshot commit's parent for the first) with its changed files, the merge-base diff of all the task's work for context, and the task's first message to its agent.
|
||||
- **Running.** Reviewer sessions get `reviewGuidance` and only the `submit_review` tool, whose arguments (`reviewSchema`: a Markdown `summary` and `findings`, each with `severity`, `category`, `title`, `detail`, `path` and `line`) become the review's `result` and mark it `done`. They run without the Stop-hook bounce, mid-turn delivery, end-of-turn commit, snapshots or reports, and their `user.message` events carry no `head`. When the reviewer's turn ends byre completes its session, kills its process and removes the worktree; a review still `running` then fails with why (no submission, with the final message; an error; an interrupt). A process that exits mid-turn fails the review the same way. Merging or discarding the task fails its running reviews ("the task was closed") and ends their reviewers. On startup, a running reviewer resumes like any session, and a running review whose reviewer isn't `running` fails.
|
||||
- **Running.** Reviewer sessions get `reviewGuidance` and only the `submit_review` tool, whose arguments (`reviewSchema`: a Markdown `summary`, which the guidance and schema require to be exactly "No significant findings." when there are no findings and otherwise a bulleted list with one line per finding, and `findings`, each with `severity`, `category`, `title`, `detail`, `path` and `line`) become the review's `result` and mark it `done`. They run without the Stop-hook bounce, mid-turn delivery, end-of-turn commit, snapshots or reports, and their `user.message` events carry no `head`. When the reviewer's turn ends byre completes its session, kills its process and removes the worktree; a review still `running` then fails with why (no submission, with the final message; an error; an interrupt). A process that exits mid-turn fails the review the same way. Merging or discarding the task fails its running reviews ("the task was closed") and ends their reviewers. On startup, a running reviewer resumes like any session, and a running review whose reviewer isn't `running` fails.
|
||||
- **Questions.** A question's `reviews` is the review of the first snapshot the task took after the question was filed, the one at the end of the turn that asked it, computed when the item is read; the Inbox tab shows it under the question once it starts.
|
||||
- **Events.** `review.started` and `review.finished` (state `done` or `failed`) carry the whole review, with the task's ids and no `agent_session_id`. A reviewer's own events carry its session id and the task's ids: the transcript skips every event of a session whose `session.created` has `role` `reviewer`, and draws each `review.finished` as a note with its outcome; the lead's `read_transcript` leaves the reviewer's events out and shows each `review.finished` with its summary and findings (`reviewLine`). `GET /api/tasks/{taskId}/sessions` and everything that looks for the task's agent (`ListSessions`) see only implementer sessions.
|
||||
- **UI.** The **Reviews** tab (`web/src/reviews.ts`) shows one review at a time, picked by snapshot (see "Snapshots" for the picker): its snapshot, state or severity counts, error, summary and findings. It loads when it opens, when the task changes, and on each `review.*` event in the task's stream. `GET /api/tasks/{taskId}/reviews` lists a task's reviews oldest first. `sqlite3 byre.db "SELECT id, task_id, snapshot_n, vendor, state, error FROM reviews ORDER BY created_at DESC LIMIT 20"` shows recent ones.
|
||||
- **UI.** The **Reviews** tab (`web/src/reviews.ts`) shows one review at a time, picked by snapshot (see "Snapshots" for the picker): its snapshot, state or severity counts, error, summary and findings. Each finding has a **fix** button (also on review cards in the inbox) that sends the finding's severity, title, location and detail to the task's agent as a message from the user: through `POST /api/sessions/{id}/messages` when its latest live session is idle, starting a session when it has none, and refused with an alert while the agent is mid-turn. It loads when it opens, when the task changes, and on each `review.*` event in the task's stream. When the finished review of the highest-numbered snapshot has findings, the tab (or the More menu holding it) is highlighted with a badge of the finding count. Each snapshot row's **review** button gets the same treatment from that snapshot's `review.finished`: highlighted, with the count in a badge over its top-right corner. `GET /api/tasks/{taskId}/reviews` lists a task's reviews oldest first. `sqlite3 byre.db "SELECT id, task_id, snapshot_n, vendor, state, error FROM reviews ORDER BY created_at DESC LIMIT 20"` shows recent ones.
|
||||
|
||||
## Events
|
||||
|
||||
|
||||
@@ -25,13 +25,14 @@ var severities = []string{"critical", "high", "medium", "low", "info"}
|
||||
const reviewGuidance = `You are a reviewer running inside byre. Another agent is doing a task; you review the change it just made, and a human may read your review in byre's web UI.
|
||||
- Your working directory is a detached checkout of the snapshot under review. Read, build and run whatever you need there, but don't edit, commit or push: the checkout is discarded when you finish, and the task's own worktree belongs to its agent.
|
||||
- Report with byre's submit_review tool, exactly once, then end your turn with one line. A review that isn't submitted is lost.
|
||||
- Each finding says what is wrong, where (path and line), why it matters and what to do about it. Leave out praise and nits. No findings is a fine result.`
|
||||
- Each finding says what is wrong, where (path and line), why it matters and what to do about it. Leave out praise, nits and any description of the change. No findings is a fine result.
|
||||
- With no findings, the summary is exactly "No significant findings." and nothing else. With findings, the summary is a bulleted Markdown list, one line per finding, and nothing else.`
|
||||
|
||||
func reviewSchema() json.RawMessage {
|
||||
b, err := json.Marshal(map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"summary": map[string]any{"type": "string", "description": "A few sentences on the change and what matters most, in Markdown."},
|
||||
"summary": map[string]any{"type": "string", "description": "Exactly \"No significant findings.\" when findings is empty; otherwise a bulleted Markdown list with one line per finding, and nothing else."},
|
||||
"findings": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{
|
||||
|
||||
Vendored
+11
-8
@@ -8,7 +8,7 @@ import { openSwitcher } from "./navbar.js";
|
||||
import { findTask, focusedProject, focusTab, on, onEach, state, tabs } from "./state.js";
|
||||
import { mountPreview } from "./preview.js";
|
||||
import { mountProject } from "./project.js";
|
||||
import { isReviewEvent, mountReviews } from "./reviews.js";
|
||||
import { isReviewEvent, latestFindingCount, mountReviews } from "./reviews.js";
|
||||
import { mountTranscript } from "./task.js";
|
||||
import { mountTerminal } from "./terminal.js";
|
||||
const views = {
|
||||
@@ -56,6 +56,9 @@ export function taskPane() {
|
||||
let buttons = [];
|
||||
let drawn = "";
|
||||
let waiting = 0;
|
||||
let findings = 0;
|
||||
const count = (t) => (t === "inbox" ? waiting : t === "reviews" ? findings : 0);
|
||||
const badge = (t) => count(t) > 0 && h("span", { class: "badge", title: t === "inbox" ? "inbox items" : "findings in the latest review" }, count(t));
|
||||
const layout = () => {
|
||||
for (const { button } of buttons) {
|
||||
button.hidden = false;
|
||||
@@ -85,11 +88,11 @@ export function taskPane() {
|
||||
for (const { tab, button } of buttons) {
|
||||
button.hidden = hidden.includes(tab);
|
||||
}
|
||||
const attention = hidden.includes("inbox");
|
||||
more.classList.toggle("attention", attention);
|
||||
replace(more, "More ▾", attention && h("span", { class: "badge", title: "inbox items" }, waiting));
|
||||
const flagged = hidden.filter((t) => count(t) > 0);
|
||||
more.classList.toggle("attention", flagged.length > 0);
|
||||
replace(more, "More ▾", ...flagged.map(badge));
|
||||
replace(menu, ...hidden.map((t) => {
|
||||
const b = h("button", { type: "button" }, views[t].label, t === "inbox" && h("span", { class: "badge", title: "inbox items" }, waiting));
|
||||
const b = h("button", { type: "button" }, views[t].label, badge(t));
|
||||
b.onclick = () => {
|
||||
menu.hidePopover();
|
||||
focusTab(t);
|
||||
@@ -116,13 +119,13 @@ export function taskPane() {
|
||||
const first = state.focus.tabs.filter((t, i, all) => tabs.includes(t) && all.indexOf(t) === i);
|
||||
const tab = state.focus.tab;
|
||||
waiting = state.inbox.filter((item) => item.task_id === state.selectedTaskId).length;
|
||||
findings = latestFindingCount(state.transcript);
|
||||
const order = [...first, ...tabs.filter((t) => !first.includes(t))].filter(available);
|
||||
const key = JSON.stringify([order, tab, waiting]);
|
||||
const key = JSON.stringify([order, tab, waiting, findings]);
|
||||
if (key !== drawn) {
|
||||
drawn = key;
|
||||
buttons = order.map((t) => {
|
||||
const attention = t === "inbox" && waiting > 0;
|
||||
const button = h("button", { class: [t === tab ? "active" : "", attention ? "attention" : ""].join(" ").trim() }, views[t].label, attention && h("span", { class: "badge", title: "inbox items" }, waiting));
|
||||
const button = h("button", { class: [t === tab ? "active" : "", count(t) > 0 ? "attention" : ""].join(" ").trim() }, views[t].label, badge(t));
|
||||
button.onclick = () => focusTab(t);
|
||||
return { tab: t, button };
|
||||
});
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+45
-6
@@ -1,14 +1,25 @@
|
||||
import { get } from "./api.js";
|
||||
import { ago, errorText, h, replace } from "./dom.js";
|
||||
import { get, post } from "./api.js";
|
||||
import { ago, errorText, h, replace, showAlert } from "./dom.js";
|
||||
import { markdown } from "./markdown.js";
|
||||
import { onEach, pickReview, state } from "./state.js";
|
||||
const severities = ["critical", "high", "medium", "low", "info"];
|
||||
export function isReviewEvent(ev) {
|
||||
return ev.kind.startsWith("review.");
|
||||
}
|
||||
export function reviewCounts(r) {
|
||||
function findingsOf(r) {
|
||||
const res = (r.result ?? {});
|
||||
const findings = Array.isArray(res["findings"]) ? res["findings"] : [];
|
||||
return Array.isArray(res["findings"]) ? res["findings"] : [];
|
||||
}
|
||||
export function findingCount(r) {
|
||||
return r.state === "done" ? findingsOf(r).length : 0;
|
||||
}
|
||||
export function latestFindingCount(events) {
|
||||
const finished = events.filter((ev) => ev.kind === "review.finished").map((ev) => ev.payload);
|
||||
const top = finished.reduce((best, r) => (!best || r.snapshot_n >= best.snapshot_n ? r : best), undefined);
|
||||
return top ? findingCount(top) : 0;
|
||||
}
|
||||
export function reviewCounts(r) {
|
||||
const findings = findingsOf(r);
|
||||
const parts = severities.map((s) => [s, findings.filter((f) => f["severity"] === s).length]).filter(([, n]) => n > 0);
|
||||
return parts.length > 0 ? parts.map(([s, n]) => `${n} ${s}`).join(", ") : "no findings";
|
||||
}
|
||||
@@ -27,16 +38,44 @@ export function reviewCard(r, taskId) {
|
||||
}
|
||||
function reviewResult(r, taskId) {
|
||||
const res = (r.result ?? {});
|
||||
const findings = Array.isArray(res["findings"]) ? res["findings"] : [];
|
||||
const findings = findingsOf(r);
|
||||
const md = { taskId };
|
||||
return h("div", { class: "review-result" }, markdown(String(res["summary"] ?? ""), "md review-summary", md), findings.length > 0
|
||||
? h("ol", { class: "findings" }, ...findings.map((f) => {
|
||||
const severity = String(f["severity"] ?? "");
|
||||
const where = typeof f["path"] === "string" && f["path"] ? `${f["path"]}${typeof f["line"] === "number" && f["line"] > 0 ? `:${f["line"]}` : ""}` : "";
|
||||
return h("li", { class: `finding ${severity}` }, h("div", { class: "finding-head" }, h("span", { class: `severity ${severity}` }, severity), typeof f["category"] === "string" && f["category"] ? h("span", { class: "finding-category" }, f["category"]) : null, h("span", { class: "finding-title" }, String(f["title"] ?? "")), where ? h("code", { class: "finding-where" }, where) : null), markdown(String(f["detail"] ?? ""), "md finding-detail", md));
|
||||
return h("li", { class: `finding ${severity}` }, h("div", { class: "finding-head" }, h("span", { class: `severity ${severity}` }, severity), typeof f["category"] === "string" && f["category"] ? h("span", { class: "finding-category" }, f["category"]) : null, h("span", { class: "finding-title" }, String(f["title"] ?? "")), where ? h("code", { class: "finding-where" }, where) : null, taskId ? fixButton(taskId, r, f, where) : null), markdown(String(f["detail"] ?? ""), "md finding-detail", md));
|
||||
}))
|
||||
: null);
|
||||
}
|
||||
function fixButton(taskId, r, f, where) {
|
||||
const button = h("button", { type: "button", class: "finding-fix", title: "Send this finding to the task's agent to fix" }, "fix");
|
||||
button.onclick = async () => {
|
||||
const text = `Fix this finding from the review of snapshot #${r.snapshot_n}:\n\n**[${String(f["severity"] ?? "")}] ${String(f["title"] ?? "")}**${where ? ` (\`${where}\`)` : ""}\n\n${String(f["detail"] ?? "")}`;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const sessions = await get("/api/tasks/{taskId}/sessions", { path: { taskId } });
|
||||
const live = sessions.findLast((s) => s.state !== "completed");
|
||||
if (live && live.state !== "idle") {
|
||||
button.disabled = false;
|
||||
void showAlert("The agent is mid-turn. Send this once its turn ends.");
|
||||
return;
|
||||
}
|
||||
if (live) {
|
||||
await post("/api/sessions/{sessionId}/messages", { path: { sessionId: live.id }, body: { text } });
|
||||
}
|
||||
else {
|
||||
await post("/api/tasks/{taskId}/sessions", { path: { taskId }, body: { prompt: text } });
|
||||
}
|
||||
replace(button, "sent");
|
||||
}
|
||||
catch (err) {
|
||||
button.disabled = false;
|
||||
void showAlert(errorText(err));
|
||||
}
|
||||
};
|
||||
return button;
|
||||
}
|
||||
export function mountReviews(root) {
|
||||
const head = h("header", { class: "reviews-header" });
|
||||
const body = h("div", { class: "review-list" });
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+17
-3
@@ -2,7 +2,7 @@ import { get, post } from "./api.js";
|
||||
import { ago, copyable, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
|
||||
import { markdown } from "./markdown.js";
|
||||
import { previewURL } from "./preview.js";
|
||||
import { reviewCounts } from "./reviews.js";
|
||||
import { findingCount, reviewCounts } from "./reviews.js";
|
||||
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, showReview, state } from "./state.js";
|
||||
import { taskStyle } from "./taskcolor.js";
|
||||
export const models = [
|
||||
@@ -179,6 +179,7 @@ export function mountChat(root, src, makeHead) {
|
||||
tools: new Map(),
|
||||
subagents: new Map(),
|
||||
reviewers: new Set(),
|
||||
reviewButtons: new Map(),
|
||||
run: undefined,
|
||||
lastMessage: undefined,
|
||||
held: undefined,
|
||||
@@ -396,9 +397,16 @@ export function mountChat(root, src, makeHead) {
|
||||
}
|
||||
if (ev.kind === "snapshot.created") {
|
||||
ctx.run = undefined;
|
||||
put(snapRow(p, ctx.taskId));
|
||||
put(snapRow(p, ctx));
|
||||
continue;
|
||||
}
|
||||
if (ev.kind === "review.finished") {
|
||||
const r = p;
|
||||
const button = ctx.reviewButtons.get(r.snapshot_n);
|
||||
if (button) {
|
||||
markFindings(button, findingCount(r));
|
||||
}
|
||||
}
|
||||
if (ev.kind === "agent.tool_call" && isSubagentCall(p)) {
|
||||
ctx.run = undefined;
|
||||
put(renderSubagent(p, ctx));
|
||||
@@ -755,7 +763,12 @@ function claudeTaskStatus(p, ctx) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
function snapRow(snap, taskId) {
|
||||
function markFindings(button, n) {
|
||||
replace(button, "review", n > 0 && h("span", { class: "badge", title: `${n} findings` }, n));
|
||||
button.classList.toggle("attention", n > 0);
|
||||
}
|
||||
function snapRow(snap, ctx) {
|
||||
const taskId = ctx.taskId;
|
||||
const changes = h("button", { type: "button", title: `Show snapshot ${snap.n} in the Changes tab` }, "changes");
|
||||
changes.onclick = () => {
|
||||
pinSnapshot(snap.n);
|
||||
@@ -778,6 +791,7 @@ function snapRow(snap, taskId) {
|
||||
};
|
||||
const review = h("button", { type: "button", title: `Show the review of snapshot ${snap.n} in the Reviews tab` }, "review");
|
||||
review.onclick = () => showReview(snap.n);
|
||||
ctx.reviewButtons.set(snap.n, review);
|
||||
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, review);
|
||||
}
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+12
-13
@@ -8,7 +8,7 @@ import { openSwitcher } from "./navbar.js";
|
||||
import { findTask, focusedProject, focusTab, on, onEach, state, tabs } from "./state.js";
|
||||
import { mountPreview } from "./preview.js";
|
||||
import { mountProject } from "./project.js";
|
||||
import { isReviewEvent, mountReviews } from "./reviews.js";
|
||||
import { isReviewEvent, latestFindingCount, mountReviews } from "./reviews.js";
|
||||
import { mountTranscript } from "./task.js";
|
||||
import { mountTerminal } from "./terminal.js";
|
||||
|
||||
@@ -60,6 +60,10 @@ export function taskPane(): HTMLElement {
|
||||
let buttons: { tab: TaskTab; button: HTMLButtonElement }[] = [];
|
||||
let drawn = "";
|
||||
let waiting = 0;
|
||||
let findings = 0;
|
||||
|
||||
const count = (t: TaskTab): number => (t === "inbox" ? waiting : t === "reviews" ? findings : 0);
|
||||
const badge = (t: TaskTab) => count(t) > 0 && h("span", { class: "badge", title: t === "inbox" ? "inbox items" : "findings in the latest review" }, count(t));
|
||||
|
||||
const layout = () => {
|
||||
for (const { button } of buttons) {
|
||||
@@ -90,13 +94,13 @@ export function taskPane(): HTMLElement {
|
||||
for (const { tab, button } of buttons) {
|
||||
button.hidden = hidden.includes(tab);
|
||||
}
|
||||
const attention = hidden.includes("inbox");
|
||||
more.classList.toggle("attention", attention);
|
||||
replace(more, "More ▾", attention && h("span", { class: "badge", title: "inbox items" }, waiting));
|
||||
const flagged = hidden.filter((t) => count(t) > 0);
|
||||
more.classList.toggle("attention", flagged.length > 0);
|
||||
replace(more, "More ▾", ...flagged.map(badge));
|
||||
replace(
|
||||
menu,
|
||||
...hidden.map((t) => {
|
||||
const b = h("button", { type: "button" }, views[t].label, t === "inbox" && h("span", { class: "badge", title: "inbox items" }, waiting));
|
||||
const b = h("button", { type: "button" }, views[t].label, badge(t));
|
||||
b.onclick = () => {
|
||||
menu.hidePopover();
|
||||
focusTab(t);
|
||||
@@ -126,18 +130,13 @@ export function taskPane(): HTMLElement {
|
||||
const first = state.focus.tabs.filter((t, i, all) => tabs.includes(t) && all.indexOf(t) === i);
|
||||
const tab = state.focus.tab;
|
||||
waiting = state.inbox.filter((item) => item.task_id === state.selectedTaskId).length;
|
||||
findings = latestFindingCount(state.transcript);
|
||||
const order = [...first, ...tabs.filter((t) => !first.includes(t))].filter(available);
|
||||
const key = JSON.stringify([order, tab, waiting]);
|
||||
const key = JSON.stringify([order, tab, waiting, findings]);
|
||||
if (key !== drawn) {
|
||||
drawn = key;
|
||||
buttons = order.map((t) => {
|
||||
const attention = t === "inbox" && waiting > 0;
|
||||
const button = h(
|
||||
"button",
|
||||
{ class: [t === tab ? "active" : "", attention ? "attention" : ""].join(" ").trim() },
|
||||
views[t].label,
|
||||
attention && h("span", { class: "badge", title: "inbox items" }, waiting),
|
||||
);
|
||||
const button = h("button", { class: [t === tab ? "active" : "", count(t) > 0 ? "attention" : ""].join(" ").trim() }, views[t].label, badge(t));
|
||||
button.onclick = () => focusTab(t);
|
||||
return { tab: t, button };
|
||||
});
|
||||
|
||||
+47
-5
@@ -1,5 +1,5 @@
|
||||
import { get, type ByreEvent, type Schemas } from "./api.js";
|
||||
import { ago, errorText, h, replace } from "./dom.js";
|
||||
import { get, post, type ByreEvent, type Schemas } from "./api.js";
|
||||
import { ago, errorText, h, replace, showAlert } from "./dom.js";
|
||||
import { markdown } from "./markdown.js";
|
||||
import { onEach, pickReview, state } from "./state.js";
|
||||
|
||||
@@ -11,9 +11,23 @@ export function isReviewEvent(ev: ByreEvent): boolean {
|
||||
return ev.kind.startsWith("review.");
|
||||
}
|
||||
|
||||
export function reviewCounts(r: Review): string {
|
||||
function findingsOf(r: Review): Record<string, unknown>[] {
|
||||
const res = (r.result ?? {}) as Record<string, unknown>;
|
||||
const findings = Array.isArray(res["findings"]) ? (res["findings"] as Record<string, unknown>[]) : [];
|
||||
return Array.isArray(res["findings"]) ? (res["findings"] as Record<string, unknown>[]) : [];
|
||||
}
|
||||
|
||||
export function findingCount(r: Review): number {
|
||||
return r.state === "done" ? findingsOf(r).length : 0;
|
||||
}
|
||||
|
||||
export function latestFindingCount(events: ByreEvent[]): number {
|
||||
const finished = events.filter((ev) => ev.kind === "review.finished").map((ev) => ev.payload as unknown as Review);
|
||||
const top = finished.reduce<Review | undefined>((best, r) => (!best || r.snapshot_n >= best.snapshot_n ? r : best), undefined);
|
||||
return top ? findingCount(top) : 0;
|
||||
}
|
||||
|
||||
export function reviewCounts(r: Review): string {
|
||||
const findings = findingsOf(r);
|
||||
const parts = severities.map((s) => [s, findings.filter((f) => f["severity"] === s).length] as const).filter(([, n]) => n > 0);
|
||||
return parts.length > 0 ? parts.map(([s, n]) => `${n} ${s}`).join(", ") : "no findings";
|
||||
}
|
||||
@@ -47,7 +61,7 @@ export function reviewCard(r: Review, taskId: string | undefined): HTMLElement {
|
||||
|
||||
function reviewResult(r: Review, taskId: string | undefined): HTMLElement {
|
||||
const res = (r.result ?? {}) as Record<string, unknown>;
|
||||
const findings = Array.isArray(res["findings"]) ? (res["findings"] as Record<string, unknown>[]) : [];
|
||||
const findings = findingsOf(r);
|
||||
const md = { taskId };
|
||||
return h(
|
||||
"div",
|
||||
@@ -70,6 +84,7 @@ function reviewResult(r: Review, taskId: string | undefined): HTMLElement {
|
||||
typeof f["category"] === "string" && f["category"] ? h("span", { class: "finding-category" }, f["category"]) : null,
|
||||
h("span", { class: "finding-title" }, String(f["title"] ?? "")),
|
||||
where ? h("code", { class: "finding-where" }, where) : null,
|
||||
taskId ? fixButton(taskId, r, f, where) : null,
|
||||
),
|
||||
markdown(String(f["detail"] ?? ""), "md finding-detail", md),
|
||||
);
|
||||
@@ -79,6 +94,33 @@ function reviewResult(r: Review, taskId: string | undefined): HTMLElement {
|
||||
);
|
||||
}
|
||||
|
||||
function fixButton(taskId: string, r: Review, f: Record<string, unknown>, where: string): HTMLElement {
|
||||
const button = h("button", { type: "button", class: "finding-fix", title: "Send this finding to the task's agent to fix" }, "fix");
|
||||
button.onclick = async () => {
|
||||
const text = `Fix this finding from the review of snapshot #${r.snapshot_n}:\n\n**[${String(f["severity"] ?? "")}] ${String(f["title"] ?? "")}**${where ? ` (\`${where}\`)` : ""}\n\n${String(f["detail"] ?? "")}`;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const sessions = await get("/api/tasks/{taskId}/sessions", { path: { taskId } });
|
||||
const live = sessions.findLast((s) => s.state !== "completed");
|
||||
if (live && live.state !== "idle") {
|
||||
button.disabled = false;
|
||||
void showAlert("The agent is mid-turn. Send this once its turn ends.");
|
||||
return;
|
||||
}
|
||||
if (live) {
|
||||
await post("/api/sessions/{sessionId}/messages", { path: { sessionId: live.id }, body: { text } });
|
||||
} else {
|
||||
await post("/api/tasks/{taskId}/sessions", { path: { taskId }, body: { prompt: text } });
|
||||
}
|
||||
replace(button, "sent");
|
||||
} catch (err) {
|
||||
button.disabled = false;
|
||||
void showAlert(errorText(err));
|
||||
}
|
||||
};
|
||||
return button;
|
||||
}
|
||||
|
||||
export function mountReviews(root: HTMLElement): () => void {
|
||||
const head = h("header", { class: "reviews-header" });
|
||||
const body = h("div", { class: "review-list" });
|
||||
|
||||
+19
-3
@@ -2,7 +2,7 @@ import { get, post, type AgentSession, type ByreEvent, type Participant, type Sc
|
||||
import { ago, copyable, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
|
||||
import { markdown } from "./markdown.js";
|
||||
import { previewURL } from "./preview.js";
|
||||
import { reviewCounts } from "./reviews.js";
|
||||
import { findingCount, reviewCounts } from "./reviews.js";
|
||||
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, showReview, state, type Topic } from "./state.js";
|
||||
import { taskStyle } from "./taskcolor.js";
|
||||
|
||||
@@ -68,6 +68,7 @@ type Ctx = {
|
||||
tools: Map<string, HTMLElement>;
|
||||
subagents: Map<string, Subagent>;
|
||||
reviewers: Set<string>;
|
||||
reviewButtons: Map<number, HTMLElement>;
|
||||
run: Run | undefined;
|
||||
root: () => string;
|
||||
taskId: string | undefined;
|
||||
@@ -251,6 +252,7 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
|
||||
tools: new Map(),
|
||||
subagents: new Map(),
|
||||
reviewers: new Set(),
|
||||
reviewButtons: new Map(),
|
||||
run: undefined,
|
||||
lastMessage: undefined,
|
||||
held: undefined,
|
||||
@@ -479,9 +481,16 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
|
||||
}
|
||||
if (ev.kind === "snapshot.created") {
|
||||
ctx.run = undefined;
|
||||
put(snapRow(p as unknown as Snapshot, ctx.taskId));
|
||||
put(snapRow(p as unknown as Snapshot, ctx));
|
||||
continue;
|
||||
}
|
||||
if (ev.kind === "review.finished") {
|
||||
const r = p as unknown as Review;
|
||||
const button = ctx.reviewButtons.get(r.snapshot_n);
|
||||
if (button) {
|
||||
markFindings(button, findingCount(r));
|
||||
}
|
||||
}
|
||||
if (ev.kind === "agent.tool_call" && isSubagentCall(p)) {
|
||||
ctx.run = undefined;
|
||||
put(renderSubagent(p, ctx));
|
||||
@@ -888,7 +897,13 @@ function claudeTaskStatus(p: Record<string, unknown>, ctx: Ctx): void {
|
||||
}
|
||||
}
|
||||
|
||||
function snapRow(snap: Snapshot, taskId: string | undefined): HTMLElement {
|
||||
function markFindings(button: HTMLElement, n: number): void {
|
||||
replace(button, "review", n > 0 && h("span", { class: "badge", title: `${n} findings` }, n));
|
||||
button.classList.toggle("attention", n > 0);
|
||||
}
|
||||
|
||||
function snapRow(snap: Snapshot, ctx: Ctx): HTMLElement {
|
||||
const taskId = ctx.taskId;
|
||||
const changes = h("button", { type: "button", title: `Show snapshot ${snap.n} in the Changes tab` }, "changes");
|
||||
changes.onclick = () => {
|
||||
pinSnapshot(snap.n);
|
||||
@@ -910,6 +925,7 @@ function snapRow(snap: Snapshot, taskId: string | undefined): HTMLElement {
|
||||
};
|
||||
const review = h("button", { type: "button", title: `Show the review of snapshot ${snap.n} in the Reviews tab` }, "review");
|
||||
review.onclick = () => showReview(snap.n);
|
||||
ctx.reviewButtons.set(snap.n, review);
|
||||
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, review);
|
||||
}
|
||||
|
||||
@@ -940,6 +940,21 @@ button.compact.danger {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.snap-row button.attention {
|
||||
color: var(--warn);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.snap-row button .badge {
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
right: -9px;
|
||||
padding: 0 5px;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.note.ask {
|
||||
@@ -1724,6 +1739,15 @@ pre.file-text .line::before {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.finding-fix {
|
||||
margin-left: auto;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.severity {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
|
||||
Reference in New Issue
Block a user