mirror of
https://github.com/block/buzz.git
synced 2026-11-02 23:41:08 +00:00
Redesign project review proposals
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
32
desktop/src/features/messages/lib/parseDiff.test.mjs
Normal file
32
desktop/src/features/messages/lib/parseDiff.test.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { getShortestUniquePathLabels } from "./parseDiff.ts";
|
||||
|
||||
test("uses filenames when diff paths have distinct basenames", () => {
|
||||
assert.deepEqual(
|
||||
getShortestUniquePathLabels([
|
||||
"desktop/src/features/channels/ui/ChannelPane.tsx",
|
||||
"desktop/src/features/messages/ui/MessageTimeline.tsx",
|
||||
]),
|
||||
["ChannelPane.tsx", "MessageTimeline.tsx"],
|
||||
);
|
||||
});
|
||||
|
||||
test("adds only enough parent directories to disambiguate duplicate names", () => {
|
||||
assert.deepEqual(
|
||||
getShortestUniquePathLabels([
|
||||
"desktop/src/features/channels/ui/index.ts",
|
||||
"desktop/src/features/messages/ui/index.ts",
|
||||
"desktop/src/features/messages/lib/parseDiff.ts",
|
||||
]),
|
||||
["channels/ui/index.ts", "messages/ui/index.ts", "parseDiff.ts"],
|
||||
);
|
||||
});
|
||||
|
||||
test("expands the longer path when one path is another path's suffix", () => {
|
||||
assert.deepEqual(
|
||||
getShortestUniquePathLabels(["src/shared/index.ts", "shared/index.ts"]),
|
||||
["src/shared/index.ts", "shared/index.ts"],
|
||||
);
|
||||
});
|
||||
@@ -55,6 +55,49 @@ export function getDiffFileLabel(
|
||||
return newPath || oldPath || fallbackFilePath || "diff";
|
||||
}
|
||||
|
||||
/** Return the resulting file path, preferring the new side of a rename. */
|
||||
export function getDiffFilePath(
|
||||
file: FileData,
|
||||
fallbackFilePath?: string,
|
||||
): string {
|
||||
const oldPath = file.oldPath === "/dev/null" ? undefined : file.oldPath;
|
||||
const newPath = file.newPath === "/dev/null" ? undefined : file.newPath;
|
||||
return newPath || oldPath || fallbackFilePath || "diff";
|
||||
}
|
||||
|
||||
/** Reduce paths to the shortest unique suffix, usually just the filename. */
|
||||
export function getShortestUniquePathLabels(paths: string[]): string[] {
|
||||
const pathParts = paths.map((path) => {
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
return parts.length > 0 ? parts : [path];
|
||||
});
|
||||
const depths = pathParts.map(() => 1);
|
||||
|
||||
while (true) {
|
||||
const labels = pathParts.map((parts, index) =>
|
||||
parts.slice(-depths[index]).join("/"),
|
||||
);
|
||||
const collisions = new Set<string>();
|
||||
const counts = new Map<string, number>();
|
||||
for (const label of labels) {
|
||||
counts.set(label, (counts.get(label) ?? 0) + 1);
|
||||
}
|
||||
for (const [label, count] of counts) {
|
||||
if (count > 1) collisions.add(label);
|
||||
}
|
||||
if (collisions.size === 0) return labels;
|
||||
|
||||
let expanded = false;
|
||||
for (const [index, label] of labels.entries()) {
|
||||
if (collisions.has(label) && depths[index] < pathParts[index].length) {
|
||||
depths[index] += 1;
|
||||
expanded = true;
|
||||
}
|
||||
}
|
||||
if (!expanded) return labels;
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldShowDiffFileHeader(
|
||||
label: string,
|
||||
fileCount: number,
|
||||
|
||||
@@ -21,6 +21,8 @@ type DiffViewerProps = {
|
||||
viewType?: ViewType;
|
||||
className?: string;
|
||||
searchQuery?: string;
|
||||
fileIndex?: number;
|
||||
hideFileHeader?: boolean;
|
||||
};
|
||||
|
||||
function FileChangeBadge({
|
||||
@@ -50,11 +52,15 @@ export function DiffViewer({
|
||||
viewType = "unified",
|
||||
className,
|
||||
searchQuery,
|
||||
fileIndex,
|
||||
hideFileHeader = false,
|
||||
}: DiffViewerProps) {
|
||||
const { files, parseError } = useMemo(
|
||||
() => parseUnifiedDiff(content),
|
||||
[content],
|
||||
);
|
||||
const visibleFiles =
|
||||
fileIndex === undefined ? files : files.slice(fileIndex, fileIndex + 1);
|
||||
const searchPreview = searchQuery
|
||||
? buildSearchResultPreview(content, searchQuery, 160)
|
||||
: null;
|
||||
@@ -89,15 +95,13 @@ export function DiffViewer({
|
||||
</pre>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
{files.map((file) => {
|
||||
{visibleFiles.map((file) => {
|
||||
const label = getDiffFileLabel(file, fallbackFilePath);
|
||||
const { additions, deletions } = countDiffFileChanges(file);
|
||||
const diffType = normalizeDiffType(file.type);
|
||||
const showFileHeader = shouldShowDiffFileHeader(
|
||||
label,
|
||||
files.length,
|
||||
fallbackFilePath,
|
||||
);
|
||||
const showFileHeader =
|
||||
!hideFileHeader &&
|
||||
shouldShowDiffFileHeader(label, files.length, fallbackFilePath);
|
||||
const fileKey = [
|
||||
file.oldPath || "",
|
||||
file.newPath || "",
|
||||
|
||||
@@ -14,9 +14,17 @@ export type ProjectReviewCheckFinding = {
|
||||
line: number | null;
|
||||
};
|
||||
|
||||
export type ProjectReviewCheckProposalReference = {
|
||||
title: string;
|
||||
summary: string;
|
||||
diffEventId: string;
|
||||
};
|
||||
|
||||
export type ProjectReviewCheckResult = {
|
||||
requestId?: string;
|
||||
/** Legacy single-proposal reference. New results use `proposals`. */
|
||||
diffEventId?: string;
|
||||
proposals?: ProjectReviewCheckProposalReference[];
|
||||
conclusion: ProjectReviewCheckConclusion;
|
||||
summary: string;
|
||||
findings: ProjectReviewCheckFinding[];
|
||||
@@ -32,6 +40,12 @@ export type ProjectReviewCheckDiffProposal = {
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export type ProjectReviewCheckResolvedProposal =
|
||||
ProjectReviewCheckDiffProposal & {
|
||||
title: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export const PROJECT_REVIEW_CHECK_RESULT_MARKER: string;
|
||||
export const DEFAULT_PROJECT_REVIEW_CHECKS: ProjectReviewCheckDefinition[];
|
||||
|
||||
|
||||
@@ -75,6 +75,22 @@ function normalizedText(value, maxLength) {
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizedDecisionCopy(value) {
|
||||
const text = normalizedText(value, 1_000)?.replace(/\s+/g, " ");
|
||||
if (!text) return null;
|
||||
|
||||
const sentenceBounded = text
|
||||
.split(/(?<=[.!?])\s+/)
|
||||
.slice(0, 2)
|
||||
.join(" ");
|
||||
if (sentenceBounded.length <= 240) return sentenceBounded;
|
||||
|
||||
const visible = sentenceBounded.slice(0, 239);
|
||||
const wordBoundary = visible.lastIndexOf(" ");
|
||||
const cutoff = wordBoundary >= 160 ? wordBoundary : visible.length;
|
||||
return `${visible.slice(0, cutoff).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function normalizedConclusion(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
@@ -117,6 +133,18 @@ function normalizedFinding(value) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedProposalReference(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const title = normalizedText(value.title, 160);
|
||||
const summary = normalizedDecisionCopy(value.summary);
|
||||
const diffEventId = normalizedEventId(
|
||||
value.diff_event_id ?? value.diffEventId,
|
||||
);
|
||||
return title && summary && diffEventId
|
||||
? { title, summary, diffEventId }
|
||||
: null;
|
||||
}
|
||||
|
||||
function jsonObjectsAfterMarker(content) {
|
||||
const markerIndex = content.lastIndexOf(PROJECT_REVIEW_CHECK_RESULT_MARKER);
|
||||
if (markerIndex < 0) return [];
|
||||
@@ -147,7 +175,7 @@ export function parseProjectReviewCheckResult(content) {
|
||||
continue;
|
||||
}
|
||||
const conclusion = normalizedConclusion(parsed.conclusion);
|
||||
const summary = normalizedText(parsed.summary, 4_000);
|
||||
const summary = normalizedDecisionCopy(parsed.summary);
|
||||
if (!conclusion || !summary) continue;
|
||||
const requestId = normalizedText(
|
||||
parsed.request_id ?? parsed.requestId,
|
||||
@@ -155,11 +183,23 @@ export function parseProjectReviewCheckResult(content) {
|
||||
);
|
||||
const rawDiffEventId = parsed.diff_event_id ?? parsed.diffEventId;
|
||||
const diffEventId = normalizedEventId(rawDiffEventId);
|
||||
const rawProposals = parsed.proposals;
|
||||
if (rawProposals !== undefined && !Array.isArray(rawProposals)) {
|
||||
continue;
|
||||
}
|
||||
const proposalCandidates = Array.isArray(rawProposals)
|
||||
? rawProposals.slice(0, 4).map(normalizedProposalReference)
|
||||
: [];
|
||||
const proposals = proposalCandidates.filter(Boolean);
|
||||
if (
|
||||
(rawDiffEventId !== undefined &&
|
||||
rawDiffEventId !== null &&
|
||||
!diffEventId) ||
|
||||
(conclusion === "approved" && diffEventId)
|
||||
proposals.length !== proposalCandidates.length ||
|
||||
new Set(proposals.map((proposal) => proposal.diffEventId)).size !==
|
||||
proposals.length ||
|
||||
(diffEventId && proposals.length > 0) ||
|
||||
(conclusion === "approved" && (diffEventId || proposals.length > 0))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -171,6 +211,9 @@ export function parseProjectReviewCheckResult(content) {
|
||||
...(conclusion === "fix-recommended" && diffEventId
|
||||
? { diffEventId }
|
||||
: {}),
|
||||
...(conclusion === "fix-recommended" && proposals.length > 0
|
||||
? { proposals }
|
||||
: {}),
|
||||
conclusion,
|
||||
summary,
|
||||
findings,
|
||||
@@ -306,8 +349,19 @@ export function buildProjectReviewCheckPrompt({
|
||||
JSON.stringify({
|
||||
request_id: requestId,
|
||||
conclusion: "fix-recommended",
|
||||
summary: "short explanation",
|
||||
diff_event_id: null,
|
||||
summary: "one short sentence framing the decision",
|
||||
proposals: [
|
||||
{
|
||||
title: "first viable direction",
|
||||
summary: "one plain-language sentence about the outcome",
|
||||
diff_event_id: null,
|
||||
},
|
||||
{
|
||||
title: "countering viable direction",
|
||||
summary: "one plain-language sentence about the tradeoff",
|
||||
diff_event_id: null,
|
||||
},
|
||||
],
|
||||
findings: [
|
||||
{
|
||||
title: "concise actionable fix",
|
||||
@@ -321,15 +375,19 @@ export function buildProjectReviewCheckPrompt({
|
||||
"Return an empty findings array when approved. For every recommended fix, return one finding object.",
|
||||
"Use null for file or line when the finding is not tied to a precise source location.",
|
||||
"Use approved only when no material fix is recommended. Use fix-recommended otherwise.",
|
||||
"Treat a fix-recommended result as a decision to negotiate, not a verdict. Frame the concern neutrally and look for countering viewpoints that could each be a defensible implementation.",
|
||||
"When code can safely express alternatives, return two genuinely distinct proposal directions. Do not manufacture a second option when only one is safe.",
|
||||
"DECISION COPY CONTRACT: The top-level summary and each proposal summary MUST be no more than two short sentences and 240 characters total; prefer one sentence. Proposal titles must be action-oriented and no more than eight words. State only the user-visible outcome or essential tradeoff in plain language. Put implementation details, identifiers, evidence, and reasoning in the finding or diff, never in decision copy.",
|
||||
"A human may reply in this request's thread with their reasoning. Treat that as a continuation of this review negotiation: reconsider the concern, inspect any newly suggested direction, and publish a revised structured result in the same thread with the same request_id. Create new diff events for revised code proposals; never reuse an earlier diff event id.",
|
||||
...(repoUrl && commit
|
||||
? [
|
||||
"When recommending a fix and you can express it safely as code, create one valid unified diff against the exact commit without modifying or committing the review branch.",
|
||||
`Pipe that patch into this command before the final result to publish one native Buzz diff message (kind 40008) in this request's thread: buzz messages send-diff --channel ${JSON.stringify(channelId)} --diff - --repo ${JSON.stringify(repoUrl)} --commit ${JSON.stringify(commit)} --description ${JSON.stringify(`Review check ${requestId}`)} --reply-to <THIS_REQUEST_EVENT_ID>`,
|
||||
"Only after send-diff succeeds, copy its exact 64-character event_id into diff_event_id in the final JSON. Never invent or reuse an event id. Do not paste the patch into the JSON.",
|
||||
"Use diff_event_id: null when there is no safe code patch. Approved results must use diff_event_id: null.",
|
||||
"For each proposed direction you can express safely as code, create a valid unified diff against the exact commit without modifying or committing the review branch.",
|
||||
`Pipe each patch into this command before the final result to publish a native Buzz diff message (kind 40008) in this request's thread: buzz messages send-diff --channel ${JSON.stringify(channelId)} --diff - --repo ${JSON.stringify(repoUrl)} --commit ${JSON.stringify(commit)} --description <PROPOSAL_TITLE> --reply-to <THIS_REQUEST_EVENT_ID>`,
|
||||
"Only after each send-diff succeeds, copy its exact 64-character event_id into that proposal's diff_event_id in the final JSON. Never invent or reuse an event id. Do not paste patches into the JSON.",
|
||||
"Omit any proposal that has no safe code patch. Return proposals: [] when no direction can be expressed safely as code. Approved results must return proposals: [].",
|
||||
]
|
||||
: [
|
||||
"Use diff_event_id: null because this review does not expose the repository URL and exact commit required for a native diff proposal.",
|
||||
"Return proposals: [] because this review does not expose the repository URL and exact commit required for native diff proposals.",
|
||||
]),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -150,6 +150,94 @@ test("parses structured findings with bounded source locations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("parses competing proposal directions with exact diff references", () => {
|
||||
const firstDiffEventId = "d".repeat(64);
|
||||
const secondDiffEventId = "e".repeat(64);
|
||||
const result = parseProjectReviewCheckResult(
|
||||
`${PROJECT_REVIEW_CHECK_RESULT_MARKER}\n${JSON.stringify({
|
||||
request_id: "request-123",
|
||||
conclusion: "fix-recommended",
|
||||
summary: "Choose where the read boundary should live.",
|
||||
proposals: [
|
||||
{
|
||||
title: "Follow the rendered timeline",
|
||||
summary: "Only mark messages read once they are visible.",
|
||||
diff_event_id: firstDiffEventId,
|
||||
},
|
||||
{
|
||||
title: "Follow the loaded query",
|
||||
summary: "Treat loaded messages as read at the physical bottom.",
|
||||
diff_event_id: secondDiffEventId,
|
||||
},
|
||||
],
|
||||
findings: [],
|
||||
})}`,
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
requestId: "request-123",
|
||||
conclusion: "fix-recommended",
|
||||
summary: "Choose where the read boundary should live.",
|
||||
proposals: [
|
||||
{
|
||||
title: "Follow the rendered timeline",
|
||||
summary: "Only mark messages read once they are visible.",
|
||||
diffEventId: firstDiffEventId,
|
||||
},
|
||||
{
|
||||
title: "Follow the loaded query",
|
||||
summary: "Treat loaded messages as read at the physical bottom.",
|
||||
diffEventId: secondDiffEventId,
|
||||
},
|
||||
],
|
||||
findings: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("bounds proposal copy to two short sentences", () => {
|
||||
const result = parseProjectReviewCheckResult(`
|
||||
${PROJECT_REVIEW_CHECK_RESULT_MARKER}
|
||||
${JSON.stringify({
|
||||
request_id: "request-123",
|
||||
conclusion: "fix-recommended",
|
||||
summary: "Choose a read boundary.",
|
||||
proposals: [
|
||||
{
|
||||
title: "Use rendered rows",
|
||||
summary:
|
||||
"Keep unread state tied to what the user can see. This preserves the navigation signal. This implementation detail should not appear in the decision copy.",
|
||||
diff_event_id: "a".repeat(64),
|
||||
},
|
||||
],
|
||||
findings: [],
|
||||
})}
|
||||
`);
|
||||
|
||||
assert.equal(
|
||||
result?.proposals?.[0]?.summary,
|
||||
"Keep unread state tied to what the user can see. This preserves the navigation signal.",
|
||||
);
|
||||
});
|
||||
|
||||
test("bounds the shared decision summary", () => {
|
||||
const result = parseProjectReviewCheckResult(`
|
||||
${PROJECT_REVIEW_CHECK_RESULT_MARKER}
|
||||
${JSON.stringify({
|
||||
request_id: "request-123",
|
||||
conclusion: "fix-recommended",
|
||||
summary:
|
||||
"Choose whether visibility or loaded state owns the read boundary. Both preserve valid behavior. This extra analysis belongs in the finding.",
|
||||
proposals: [],
|
||||
findings: [],
|
||||
})}
|
||||
`);
|
||||
|
||||
assert.equal(
|
||||
result?.summary,
|
||||
"Choose whether visibility or loaded state owns the read boundary. Both preserve valid behavior.",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolves only the exact agent diff for the reviewed repository and commit", () => {
|
||||
const eventId = "d".repeat(64);
|
||||
const agentPubkey = "a".repeat(64);
|
||||
@@ -204,6 +292,45 @@ test("resolves only the exact agent diff for the reviewed repository and commit"
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
parseProjectReviewCheckResult(
|
||||
`${PROJECT_REVIEW_CHECK_RESULT_MARKER}\n${JSON.stringify({
|
||||
conclusion: "fix-recommended",
|
||||
summary: "Proposal references must be unique.",
|
||||
proposals: [
|
||||
{
|
||||
title: "First",
|
||||
summary: "First direction.",
|
||||
diff_event_id: "f".repeat(64),
|
||||
},
|
||||
{
|
||||
title: "Second",
|
||||
summary: "Second direction.",
|
||||
diff_event_id: "f".repeat(64),
|
||||
},
|
||||
],
|
||||
findings: [],
|
||||
})}`,
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
parseProjectReviewCheckResult(
|
||||
`${PROJECT_REVIEW_CHECK_RESULT_MARKER}\n${JSON.stringify({
|
||||
conclusion: "fix-recommended",
|
||||
summary: "Every proposal needs a verified patch reference.",
|
||||
proposals: [
|
||||
{
|
||||
title: "Missing reference",
|
||||
summary: "This direction cannot be correlated to a patch.",
|
||||
diff_event_id: null,
|
||||
},
|
||||
],
|
||||
findings: [],
|
||||
})}`,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not treat ordinary agent prose as a completed check", () => {
|
||||
@@ -275,7 +402,13 @@ test("builds a review-only prompt pinned to the exact commit", () => {
|
||||
);
|
||||
assert.match(prompt, /buzz messages send-diff/);
|
||||
assert.match(prompt, /send-diff[^\n]+--reply-to <THIS_REQUEST_EVENT_ID>/);
|
||||
assert.match(prompt, /"diff_event_id":null/);
|
||||
assert.match(prompt, /"proposals":\[/);
|
||||
assert.match(prompt, /countering viable direction/);
|
||||
assert.match(prompt, /two genuinely distinct proposal directions/);
|
||||
assert.match(prompt, /DECISION COPY CONTRACT/);
|
||||
assert.match(prompt, /no more than two short sentences and 240 characters/);
|
||||
assert.match(prompt, /implementation details, identifiers, evidence/);
|
||||
assert.match(prompt, /continuation of this review negotiation/);
|
||||
assert.match(prompt, /copy its exact 64-character event_id/);
|
||||
assert.match(prompt, new RegExp(PROJECT_REVIEW_CHECK_RESULT_MARKER));
|
||||
assert.doesNotMatch(prompt, /Superseded request id:/);
|
||||
|
||||
@@ -1,179 +1,411 @@
|
||||
import { Check, CheckCircle2, CircleAlert, FileDiff } from "lucide-react";
|
||||
import { Check, ChevronRight, FileDiff, LoaderCircle, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
countDiffFileChanges,
|
||||
getDiffFilePath,
|
||||
getShortestUniquePathLabels,
|
||||
parseUnifiedDiff,
|
||||
} from "@/features/messages/lib/parseDiff";
|
||||
import { DiffViewer } from "@/features/messages/ui/DiffViewer";
|
||||
import type {
|
||||
ProjectReviewCheckDiffProposal,
|
||||
ProjectReviewCheckResolvedProposal,
|
||||
ProjectReviewCheckResult,
|
||||
} from "@/features/projects/lib/projectReviewChecks.mjs";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
|
||||
const DiffMessage = React.lazy(
|
||||
() => import("@/features/messages/ui/DiffMessage"),
|
||||
);
|
||||
const DiffMessageExpanded = React.lazy(
|
||||
() => import("@/features/messages/ui/DiffMessageExpanded"),
|
||||
);
|
||||
function ProposalDiffFiles({
|
||||
files,
|
||||
parseError,
|
||||
proposal,
|
||||
}: {
|
||||
files: ReturnType<typeof parseUnifiedDiff>["files"];
|
||||
parseError: boolean;
|
||||
proposal: ProjectReviewCheckResolvedProposal;
|
||||
}) {
|
||||
if (parseError || files.length === 0) {
|
||||
const label = getShortestUniquePathLabels([
|
||||
proposal.filePath ?? "Proposed patch",
|
||||
])[0];
|
||||
|
||||
function ReviewCheckDiffProposal({
|
||||
return (
|
||||
<details className="group overflow-hidden rounded-lg border border-border/60 bg-background/50">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-xs [&::-webkit-details-marker]:hidden">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
|
||||
<FileDiff className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate font-mono text-foreground/85">
|
||||
{label}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="overflow-auto border-t border-border/50 p-2">
|
||||
<DiffViewer
|
||||
content={proposal.content}
|
||||
fallbackFilePath={proposal.filePath ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
const labels = getShortestUniquePathLabels(
|
||||
files.map((file) => getDiffFilePath(file, proposal.filePath ?? undefined)),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{files.map((file, index) => {
|
||||
const label = labels[index];
|
||||
const { additions, deletions } = countDiffFileChanges(file);
|
||||
const key = `${file.oldPath ?? ""}:${file.newPath ?? ""}:${index}`;
|
||||
|
||||
return (
|
||||
<details
|
||||
className="group overflow-hidden rounded-lg border border-border/60 bg-background/50"
|
||||
data-testid="project-review-check-proposal-file"
|
||||
key={key}
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-xs [&::-webkit-details-marker]:hidden">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-foreground/85">
|
||||
{label}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5 font-mono text-2xs font-semibold">
|
||||
<span className="text-status-added">+{additions}</span>
|
||||
<span className="text-status-deleted">-{deletions}</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="max-h-[24rem] overflow-auto border-t border-border/50 p-2">
|
||||
<DiffViewer
|
||||
content={proposal.content}
|
||||
fallbackFilePath={proposal.filePath ?? undefined}
|
||||
fileIndex={index}
|
||||
hideFileHeader
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProposalOption({
|
||||
accepted,
|
||||
canAccept,
|
||||
index,
|
||||
onAccept,
|
||||
proposal,
|
||||
}: {
|
||||
accepted: boolean;
|
||||
canAccept: boolean;
|
||||
index: number;
|
||||
onAccept: () => void;
|
||||
proposal: ProjectReviewCheckDiffProposal;
|
||||
proposal: ProjectReviewCheckResolvedProposal;
|
||||
}) {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const acceptEnabled = canAccept && !proposal.truncated;
|
||||
const parsedDiff = React.useMemo(
|
||||
() => parseUnifiedDiff(proposal.content),
|
||||
[proposal.content],
|
||||
);
|
||||
const totals = parsedDiff.files.reduce(
|
||||
(sum, file) => {
|
||||
const changes = countDiffFileChanges(file);
|
||||
return {
|
||||
additions: sum.additions + changes.additions,
|
||||
deletions: sum.deletions + changes.deletions,
|
||||
};
|
||||
},
|
||||
{ additions: 0, deletions: 0 },
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-3 border-t border-border/55 px-4 py-4"
|
||||
data-testid="project-review-check-diff-proposal"
|
||||
<article
|
||||
className={cn(
|
||||
"space-y-3 rounded-xl p-3.5",
|
||||
accepted ? "bg-emerald-500/12" : "bg-emerald-500/8",
|
||||
)}
|
||||
data-testid="project-review-check-proposal"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileDiff className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<h6 className="text-xs font-semibold text-foreground">
|
||||
Proposed code changes
|
||||
</h6>
|
||||
<p className="truncate text-2xs text-muted-foreground">
|
||||
Native Buzz diff · {proposal.commitSha.slice(0, 7)}
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-2xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
|
||||
Direction {index + 1}
|
||||
</p>
|
||||
<h6 className="mt-0.5 line-clamp-2 text-sm font-semibold text-foreground">
|
||||
{proposal.title}
|
||||
</h6>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{!parsedDiff.parseError && parsedDiff.files.length > 0 ? (
|
||||
<span className="flex items-center gap-1.5 font-mono text-2xs font-semibold">
|
||||
<span className="text-status-added">+{totals.additions}</span>
|
||||
<span className="text-status-deleted">-{totals.deletions}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
aria-pressed={accepted}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
accepted
|
||||
? "border border-emerald-500/35 bg-emerald-500/10 text-emerald-700 shadow-none hover:bg-emerald-500/15 dark:text-emerald-300"
|
||||
: "bg-emerald-600 text-white hover:bg-emerald-600/90",
|
||||
)}
|
||||
disabled={accepted || !acceptEnabled}
|
||||
onClick={onAccept}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={accepted ? "secondary" : "default"}
|
||||
>
|
||||
{accepted ? <Check /> : null}
|
||||
{accepted ? "Accepted" : "Accept"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details
|
||||
className="group"
|
||||
data-testid="project-review-check-proposal-details"
|
||||
>
|
||||
<summary className="flex w-fit cursor-pointer list-none items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden">
|
||||
<ChevronRight className="h-3.5 w-3.5 transition-transform group-open:rotate-90" />
|
||||
<span className="group-open:hidden">Read more</span>
|
||||
<span className="hidden group-open:inline">Hide details</span>
|
||||
</summary>
|
||||
<div className="space-y-3 pt-3">
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{proposal.summary}
|
||||
</p>
|
||||
{proposal.truncated || !canAccept ? (
|
||||
<p className="text-2xs text-muted-foreground">
|
||||
{proposal.truncated
|
||||
? "The patch is incomplete and cannot be selected."
|
||||
: "Run this check again to select a current proposal."}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<ProposalDiffFiles
|
||||
files={parsedDiff.files}
|
||||
parseError={parsedDiff.parseError}
|
||||
proposal={proposal}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewResponse({
|
||||
canRespond,
|
||||
onRespond,
|
||||
}: {
|
||||
canRespond: boolean;
|
||||
onRespond?: (response: string) => Promise<void>;
|
||||
}) {
|
||||
const [response, setResponse] = React.useState("");
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const [isSending, setIsSending] = React.useState(false);
|
||||
const responseId = React.useId();
|
||||
const canSubmit =
|
||||
canRespond && Boolean(onRespond) && Boolean(response.trim());
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<article
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-xl bg-muted/20 p-3.5"
|
||||
data-testid="project-review-check-response"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Propose another direction
|
||||
</p>
|
||||
<p className="text-2xs text-muted-foreground">
|
||||
Suggest a different approach for the agent to consider.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
disabled={accepted || !acceptEnabled}
|
||||
onClick={onAccept}
|
||||
size="sm"
|
||||
disabled={!canRespond || !onRespond}
|
||||
onClick={() => setExpanded(true)}
|
||||
type="button"
|
||||
variant={accepted ? "secondary" : "default"}
|
||||
variant="secondary"
|
||||
>
|
||||
{accepted ? <Check /> : null}
|
||||
{accepted ? "Accepted" : "Accept changes"}
|
||||
Propose
|
||||
</Button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-2 rounded-xl bg-muted/20 p-3.5"
|
||||
data-testid="project-review-check-response"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit || !onRespond) return;
|
||||
setIsSending(true);
|
||||
void onRespond(response.trim())
|
||||
.then(() => {
|
||||
setResponse("");
|
||||
setExpanded(false);
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setIsSending(false));
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
className="text-sm font-semibold text-foreground"
|
||||
htmlFor={responseId}
|
||||
>
|
||||
Propose another direction
|
||||
</label>
|
||||
<p className="text-2xs text-muted-foreground">
|
||||
Suggest a different approach for the agent to consider.
|
||||
</p>
|
||||
</div>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="p-3 text-xs text-muted-foreground">
|
||||
Loading proposed changes…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DiffMessage
|
||||
commitSha={proposal.commitSha}
|
||||
content={proposal.content}
|
||||
description={proposal.description ?? undefined}
|
||||
filePath={proposal.filePath ?? undefined}
|
||||
onExpand={() => setExpanded(true)}
|
||||
repoUrl={proposal.repoUrl}
|
||||
truncated={proposal.truncated}
|
||||
<div className="flex items-end gap-2">
|
||||
<Textarea
|
||||
className="min-h-16 resize-none text-sm"
|
||||
disabled={!canRespond || isSending}
|
||||
id={responseId}
|
||||
onChange={(event) => setResponse(event.target.value)}
|
||||
placeholder="Share your reasoning or suggest a different fix…"
|
||||
rows={2}
|
||||
value={response}
|
||||
/>
|
||||
</React.Suspense>
|
||||
<p className="text-2xs text-muted-foreground">
|
||||
{accepted
|
||||
? "Accepted for this prototype. No repository files or review state were changed."
|
||||
: proposal.truncated
|
||||
? "This proposal is truncated and cannot be accepted. No code was changed."
|
||||
: acceptEnabled
|
||||
? "Preview only. Accepting records your choice in this dev build and does not change code."
|
||||
: "This proposal is outdated. Run the check again before accepting it. No code was changed."}
|
||||
</p>
|
||||
{expanded ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<DiffMessageExpanded
|
||||
content={proposal.content}
|
||||
filePath={proposal.filePath ?? undefined}
|
||||
onClose={() => setExpanded(false)}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
disabled={isSending}
|
||||
onClick={() => setExpanded(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canSubmit || isSending}
|
||||
size="sm"
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
>
|
||||
{isSending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
Respond
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectReviewCheckResultCard({
|
||||
acceptedProposalEventId,
|
||||
canAcceptProposal,
|
||||
canDecide,
|
||||
keptCurrentApproach,
|
||||
onAcceptProposal,
|
||||
proposal,
|
||||
onKeepCurrentApproach,
|
||||
onRespond,
|
||||
proposals,
|
||||
result,
|
||||
}: {
|
||||
acceptedProposalEventId?: string;
|
||||
canAcceptProposal: boolean;
|
||||
canDecide: boolean;
|
||||
keptCurrentApproach: boolean;
|
||||
onAcceptProposal: (eventId: string) => void;
|
||||
proposal?: ProjectReviewCheckDiffProposal;
|
||||
onKeepCurrentApproach: () => void;
|
||||
onRespond?: (response: string) => Promise<void>;
|
||||
proposals: ProjectReviewCheckResolvedProposal[];
|
||||
result: ProjectReviewCheckResult;
|
||||
}) {
|
||||
const isApproved = result.conclusion === "approved";
|
||||
const findingCount = result.findings.length;
|
||||
const title = isApproved
|
||||
? "Approved"
|
||||
: findingCount === 0
|
||||
? "Fix recommended"
|
||||
: `${findingCount} ${findingCount === 1 ? "fix" : "fixes"} recommended`;
|
||||
? "Aligned"
|
||||
: proposals.length > 0
|
||||
? "Choose a direction"
|
||||
: "Review the concern";
|
||||
|
||||
return (
|
||||
<section
|
||||
className="overflow-hidden rounded-xl border border-border/70 bg-background/70"
|
||||
data-testid="project-review-check-result"
|
||||
>
|
||||
<div className="flex items-start gap-3 px-4 py-3.5">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted/70">
|
||||
{isApproved ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
||||
) : (
|
||||
<CircleAlert className="h-5 w-5 text-amber-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h5 className="text-sm font-semibold text-foreground">{title}</h5>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
{result.summary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{findingCount > 0 ? (
|
||||
<div className="divide-y divide-border/55 border-t border-border/55">
|
||||
<section className="space-y-3" data-testid="project-review-check-result">
|
||||
<header>
|
||||
<h5 className="text-sm font-semibold text-foreground">{title}</h5>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{result.summary}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{!isApproved && proposals.length === 0 && result.findings.length > 0 ? (
|
||||
<div className="divide-y divide-border/45 border-t border-border/55 px-4">
|
||||
{result.findings.map((finding) => (
|
||||
<article
|
||||
className="px-4 py-3"
|
||||
<div
|
||||
className="py-2.5"
|
||||
data-testid="project-review-check-finding"
|
||||
key={JSON.stringify(finding)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<p className="min-w-0 flex-1 text-xs font-medium text-foreground">
|
||||
{finding.title}
|
||||
</p>
|
||||
{finding.file ? (
|
||||
<span
|
||||
className="max-w-[45%] truncate font-mono text-2xs text-muted-foreground"
|
||||
title={`${finding.file}${finding.line ? `:${finding.line}` : ""}`}
|
||||
>
|
||||
{finding.file}
|
||||
{finding.line ? `:${finding.line}` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{finding.title}
|
||||
</p>
|
||||
{finding.detail ? (
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{finding.detail}
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{proposal ? (
|
||||
<ReviewCheckDiffProposal
|
||||
accepted={acceptedProposalEventId === proposal.eventId}
|
||||
canAccept={canAcceptProposal}
|
||||
onAccept={() => onAcceptProposal(proposal.eventId)}
|
||||
proposal={proposal}
|
||||
/>
|
||||
|
||||
{proposals.length > 0 ? (
|
||||
<>
|
||||
<p className="text-2xs text-muted-foreground">
|
||||
Preview only — selecting a direction records the decision without
|
||||
changing code.
|
||||
</p>
|
||||
{proposals.map((proposal, index) => (
|
||||
<ProposalOption
|
||||
accepted={acceptedProposalEventId === proposal.eventId}
|
||||
canAccept={canDecide}
|
||||
index={index}
|
||||
key={proposal.eventId}
|
||||
onAccept={() => onAcceptProposal(proposal.eventId)}
|
||||
proposal={proposal}
|
||||
/>
|
||||
))}
|
||||
|
||||
<article
|
||||
className={cn(
|
||||
"flex flex-wrap items-center justify-between gap-3 rounded-xl bg-muted/20 p-3.5",
|
||||
keptCurrentApproach && "bg-muted/35",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Keep it as-is
|
||||
</p>
|
||||
<p className="text-2xs text-muted-foreground">
|
||||
Reject the proposals and leave the review unchanged.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
aria-pressed={keptCurrentApproach}
|
||||
className={cn(
|
||||
"border-rose-500/35 text-rose-600 hover:bg-rose-500/10 hover:text-rose-700 dark:text-rose-400",
|
||||
keptCurrentApproach && "bg-rose-500/10",
|
||||
)}
|
||||
disabled={!canDecide || keptCurrentApproach}
|
||||
onClick={onKeepCurrentApproach}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{keptCurrentApproach ? <Check /> : <X />}
|
||||
{keptCurrentApproach ? "Keeping it as-is" : "Keep it as-is"}
|
||||
</Button>
|
||||
</article>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!isApproved ? (
|
||||
<ReviewResponse canRespond={canDecide} onRespond={onRespond} />
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
readProjectReviewCheckRuns,
|
||||
writeProjectReviewCheckRuns,
|
||||
type ProjectReviewCheckDefinition,
|
||||
type ProjectReviewCheckDiffProposal,
|
||||
type ProjectReviewCheckResolvedProposal,
|
||||
type ProjectReviewCheckResult,
|
||||
} from "@/features/projects/lib/projectReviewChecks.mjs";
|
||||
import { isAtOrAfterConversationOpener } from "@/features/projects/lib/projectAgentConversation";
|
||||
@@ -63,8 +63,10 @@ type ReviewCheckRun = {
|
||||
channelId?: string;
|
||||
opener?: ProjectsConversationOpener;
|
||||
result?: ProjectReviewCheckResult;
|
||||
proposal?: ProjectReviewCheckDiffProposal;
|
||||
proposals?: ProjectReviewCheckResolvedProposal[];
|
||||
acceptedProposalEventId?: string;
|
||||
keptCurrentApproach?: boolean;
|
||||
resultEventIds?: string[];
|
||||
resultCreatedAt?: number;
|
||||
error?: string;
|
||||
};
|
||||
@@ -83,7 +85,9 @@ function isReviewCheckStatus(value: unknown): value is ReviewCheckStatus {
|
||||
function normalizeStoredProposal(
|
||||
value: unknown,
|
||||
agentPubkey: string | null,
|
||||
): ProjectReviewCheckDiffProposal | undefined {
|
||||
fallbackTitle: string,
|
||||
fallbackSummary: string,
|
||||
): ProjectReviewCheckResolvedProposal | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -106,23 +110,33 @@ function normalizeStoredProposal(
|
||||
: []),
|
||||
...(raw.truncated === true ? [["truncated", "true"]] : []),
|
||||
];
|
||||
return (
|
||||
parseProjectReviewCheckDiffEvent(
|
||||
{
|
||||
id: raw.eventId,
|
||||
pubkey: agentPubkey,
|
||||
kind: KIND_STREAM_MESSAGE_DIFF,
|
||||
content: raw.content,
|
||||
tags,
|
||||
},
|
||||
{
|
||||
eventId: raw.eventId,
|
||||
agentPubkey,
|
||||
repoUrl: raw.repoUrl,
|
||||
commit: raw.commitSha,
|
||||
},
|
||||
) ?? undefined
|
||||
const proposal = parseProjectReviewCheckDiffEvent(
|
||||
{
|
||||
id: raw.eventId,
|
||||
pubkey: agentPubkey,
|
||||
kind: KIND_STREAM_MESSAGE_DIFF,
|
||||
content: raw.content,
|
||||
tags,
|
||||
},
|
||||
{
|
||||
eventId: raw.eventId,
|
||||
agentPubkey,
|
||||
repoUrl: raw.repoUrl,
|
||||
commit: raw.commitSha,
|
||||
},
|
||||
);
|
||||
if (!proposal) return undefined;
|
||||
return {
|
||||
...proposal,
|
||||
title:
|
||||
typeof raw.title === "string" && raw.title.trim()
|
||||
? raw.title.trim().slice(0, 160)
|
||||
: fallbackTitle,
|
||||
summary:
|
||||
typeof raw.summary === "string" && raw.summary.trim()
|
||||
? raw.summary.trim().slice(0, 1_000)
|
||||
: fallbackSummary,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStoredRuns(value: Record<string, unknown>): ReviewCheckRuns {
|
||||
@@ -138,13 +152,35 @@ function normalizeStoredRuns(value: Record<string, unknown>): ReviewCheckRuns {
|
||||
const raw = candidate as Record<string, unknown>;
|
||||
const agentPubkey =
|
||||
typeof raw.agentPubkey === "string" ? raw.agentPubkey : null;
|
||||
const proposal = normalizeStoredProposal(raw.proposal, agentPubkey);
|
||||
const status = isReviewCheckStatus(raw.status) ? raw.status : "idle";
|
||||
const parsedResult = parseProjectReviewCheckResult(
|
||||
raw.result && typeof raw.result === "object"
|
||||
? `BUZZ_CHECK_RESULT_V1\n${JSON.stringify(raw.result)}`
|
||||
: "",
|
||||
);
|
||||
const fallbackTitle =
|
||||
parsedResult?.findings[0]?.title ?? "Apply the recommended change";
|
||||
const fallbackSummary =
|
||||
parsedResult?.findings[0]?.detail ??
|
||||
parsedResult?.summary ??
|
||||
"Apply the agent's proposed implementation.";
|
||||
const storedProposals = Array.isArray(raw.proposals)
|
||||
? raw.proposals
|
||||
: raw.proposal
|
||||
? [raw.proposal]
|
||||
: [];
|
||||
const proposals = storedProposals
|
||||
.map((proposal) =>
|
||||
normalizeStoredProposal(
|
||||
proposal,
|
||||
agentPubkey,
|
||||
fallbackTitle,
|
||||
fallbackSummary,
|
||||
),
|
||||
)
|
||||
.filter((proposal): proposal is ProjectReviewCheckResolvedProposal =>
|
||||
Boolean(proposal),
|
||||
);
|
||||
const opener =
|
||||
raw.opener &&
|
||||
typeof raw.opener === "object" &&
|
||||
@@ -169,9 +205,22 @@ function normalizeStoredRuns(value: Record<string, unknown>): ReviewCheckRuns {
|
||||
: {}),
|
||||
...(opener ? { opener } : {}),
|
||||
...(parsedResult ? { result: parsedResult } : {}),
|
||||
...(proposal ? { proposal } : {}),
|
||||
...(proposal && raw.acceptedProposalEventId === proposal.eventId
|
||||
? { acceptedProposalEventId: proposal.eventId }
|
||||
...(proposals.length > 0 ? { proposals } : {}),
|
||||
...(proposals.some(
|
||||
(proposal) => raw.acceptedProposalEventId === proposal.eventId,
|
||||
)
|
||||
? { acceptedProposalEventId: raw.acceptedProposalEventId as string }
|
||||
: {}),
|
||||
...(raw.keptCurrentApproach === true
|
||||
? { keptCurrentApproach: true }
|
||||
: {}),
|
||||
...(Array.isArray(raw.resultEventIds)
|
||||
? {
|
||||
resultEventIds: raw.resultEventIds.filter(
|
||||
(eventId): eventId is string =>
|
||||
typeof eventId === "string" && /^[0-9a-f]{64}$/i.test(eventId),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(typeof raw.resultCreatedAt === "number"
|
||||
? { resultCreatedAt: raw.resultCreatedAt }
|
||||
@@ -204,6 +253,7 @@ function ReviewCheckResultObserver({
|
||||
opener,
|
||||
repoUrl,
|
||||
requestId,
|
||||
resultEventIds,
|
||||
targetCommit,
|
||||
}: {
|
||||
agentPubkey: string;
|
||||
@@ -213,11 +263,13 @@ function ReviewCheckResultObserver({
|
||||
checkId: string,
|
||||
result: ProjectReviewCheckResult,
|
||||
createdAt: number,
|
||||
proposal?: ProjectReviewCheckDiffProposal,
|
||||
resultEventId: string,
|
||||
proposals: ProjectReviewCheckResolvedProposal[],
|
||||
) => void;
|
||||
opener: ProjectsConversationOpener;
|
||||
repoUrl: string | null;
|
||||
requestId: string | null;
|
||||
resultEventIds: string[];
|
||||
targetCommit: string | null;
|
||||
}) {
|
||||
useChannelSubscription(channel);
|
||||
@@ -245,7 +297,11 @@ function ReviewCheckResultObserver({
|
||||
getThreadReference(event.tags).rootId === opener.eventId,
|
||||
);
|
||||
const resultEvents = agentEvents
|
||||
.filter((event) => isAgentMessage(event, agentPubkey))
|
||||
.filter(
|
||||
(event) =>
|
||||
isAgentMessage(event, agentPubkey) &&
|
||||
!resultEventIds.includes(event.id),
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.created_at - right.created_at || right.id.localeCompare(left.id),
|
||||
@@ -253,27 +309,55 @@ function ReviewCheckResultObserver({
|
||||
for (const event of resultEvents) {
|
||||
const result = parseProjectReviewCheckResult(event.content);
|
||||
if (result && (!requestId || result.requestId === requestId)) {
|
||||
let proposal: ProjectReviewCheckDiffProposal | undefined;
|
||||
if (result.diffEventId) {
|
||||
if (!repoUrl) continue;
|
||||
const proposalReferences =
|
||||
result.proposals ??
|
||||
(result.diffEventId
|
||||
? [
|
||||
{
|
||||
diffEventId: result.diffEventId,
|
||||
title:
|
||||
result.findings[0]?.title ?? "Apply the recommended change",
|
||||
summary: result.findings[0]?.detail ?? result.summary,
|
||||
},
|
||||
]
|
||||
: []);
|
||||
const proposals: ProjectReviewCheckResolvedProposal[] = [];
|
||||
if (proposalReferences.length > 0 && !repoUrl) continue;
|
||||
for (const reference of proposalReferences) {
|
||||
const diffEvent = agentEvents.find(
|
||||
(candidate) =>
|
||||
candidate.kind === KIND_STREAM_MESSAGE_DIFF &&
|
||||
candidate.id.toLowerCase() === result.diffEventId,
|
||||
candidate.id.toLowerCase() === reference.diffEventId,
|
||||
);
|
||||
proposal = diffEvent
|
||||
? (parseProjectReviewCheckDiffEvent(diffEvent, {
|
||||
eventId: result.diffEventId,
|
||||
agentPubkey,
|
||||
repoUrl,
|
||||
commit: targetCommit,
|
||||
}) ?? undefined)
|
||||
: undefined;
|
||||
// The agent is instructed to publish the native diff before its
|
||||
// result. Keep observing if relay delivery arrives out of order.
|
||||
if (!proposal) continue;
|
||||
const parsedProposal =
|
||||
diffEvent && repoUrl
|
||||
? parseProjectReviewCheckDiffEvent(diffEvent, {
|
||||
eventId: reference.diffEventId,
|
||||
agentPubkey,
|
||||
repoUrl,
|
||||
commit: targetCommit,
|
||||
})
|
||||
: null;
|
||||
if (!parsedProposal) {
|
||||
// Each diff is published before the structured result, but relay
|
||||
// delivery may arrive out of order. Keep observing until every
|
||||
// referenced alternative is available and verified.
|
||||
proposals.length = 0;
|
||||
break;
|
||||
}
|
||||
proposals.push({
|
||||
...parsedProposal,
|
||||
title: reference.title,
|
||||
summary: reference.summary,
|
||||
});
|
||||
}
|
||||
return { createdAt: event.created_at, proposal, result };
|
||||
if (proposals.length !== proposalReferences.length) continue;
|
||||
return {
|
||||
createdAt: event.created_at,
|
||||
proposals,
|
||||
result,
|
||||
resultEventId: event.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -283,6 +367,7 @@ function ReviewCheckResultObserver({
|
||||
opener,
|
||||
repoUrl,
|
||||
requestId,
|
||||
resultEventIds,
|
||||
targetCommit,
|
||||
threadReplies.events,
|
||||
]);
|
||||
@@ -293,7 +378,8 @@ function ReviewCheckResultObserver({
|
||||
checkId,
|
||||
observedResult.result,
|
||||
observedResult.createdAt,
|
||||
observedResult.proposal,
|
||||
observedResult.resultEventId,
|
||||
observedResult.proposals,
|
||||
);
|
||||
}
|
||||
}, [checkId, observedResult, onResult]);
|
||||
@@ -325,7 +411,7 @@ function CheckStatus({
|
||||
return <Badge variant="success">Approved</Badge>;
|
||||
}
|
||||
if (run.result?.conclusion === "fix-recommended") {
|
||||
return <Badge variant="warning">Fix recommended</Badge>;
|
||||
return <Badge variant="warning">Proposal ready</Badge>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -412,7 +498,8 @@ export function ProjectReviewChecks({
|
||||
checkId: string,
|
||||
result: ProjectReviewCheckResult,
|
||||
createdAt: number,
|
||||
proposal?: ProjectReviewCheckDiffProposal,
|
||||
resultEventId: string,
|
||||
proposals: ProjectReviewCheckResolvedProposal[],
|
||||
) => {
|
||||
const expectedStorageKey = storageKeyRef.current;
|
||||
if (!expectedStorageKey) return;
|
||||
@@ -420,15 +507,19 @@ export function ProjectReviewChecks({
|
||||
...current,
|
||||
error: undefined,
|
||||
acceptedProposalEventId: undefined,
|
||||
proposal,
|
||||
keptCurrentApproach: undefined,
|
||||
proposals,
|
||||
result,
|
||||
resultEventIds: [
|
||||
...new Set([...(current.resultEventIds ?? []), resultEventId]),
|
||||
],
|
||||
resultCreatedAt: createdAt,
|
||||
status: "completed",
|
||||
}));
|
||||
toast.success(
|
||||
result.conclusion === "approved"
|
||||
? "Review check approved."
|
||||
: "Review check recommends a fix.",
|
||||
: "Review proposals are ready.",
|
||||
);
|
||||
},
|
||||
[updateRun],
|
||||
@@ -439,8 +530,12 @@ export function ProjectReviewChecks({
|
||||
const expectedStorageKey = storageKeyRef.current;
|
||||
if (!expectedStorageKey) return;
|
||||
updateRun(expectedStorageKey, checkId, (current) =>
|
||||
current.proposal?.eventId === eventId
|
||||
? { ...current, acceptedProposalEventId: eventId }
|
||||
current.proposals?.some((proposal) => proposal.eventId === eventId)
|
||||
? {
|
||||
...current,
|
||||
acceptedProposalEventId: eventId,
|
||||
keptCurrentApproach: undefined,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
toast.success("Proposal accepted locally. No code was changed.");
|
||||
@@ -448,6 +543,72 @@ export function ProjectReviewChecks({
|
||||
[updateRun],
|
||||
);
|
||||
|
||||
const keepCurrentApproach = React.useCallback(
|
||||
(checkId: string) => {
|
||||
const expectedStorageKey = storageKeyRef.current;
|
||||
if (!expectedStorageKey) return;
|
||||
updateRun(expectedStorageKey, checkId, (current) => ({
|
||||
...current,
|
||||
acceptedProposalEventId: undefined,
|
||||
keptCurrentApproach: true,
|
||||
}));
|
||||
toast.success("The current approach is being kept locally.");
|
||||
},
|
||||
[updateRun],
|
||||
);
|
||||
|
||||
const respondToCheck = React.useCallback(
|
||||
async (checkId: string, response: string) => {
|
||||
const expectedStorageKey = storageKeyRef.current;
|
||||
const run = runs[checkId];
|
||||
const trimmed = response.trim();
|
||||
if (
|
||||
!expectedStorageKey ||
|
||||
!trimmed ||
|
||||
!run?.channelId ||
|
||||
!run.opener ||
|
||||
!run.agentPubkey ||
|
||||
!relayScope ||
|
||||
!signerScope
|
||||
) {
|
||||
throw new Error("This review conversation is no longer available.");
|
||||
}
|
||||
|
||||
try {
|
||||
await sendChannelMessage(
|
||||
run.channelId,
|
||||
trimmed,
|
||||
run.opener.eventId,
|
||||
undefined,
|
||||
[run.agentPubkey],
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
relayScope,
|
||||
signerScope,
|
||||
run.opener.eventId,
|
||||
);
|
||||
updateRun(expectedStorageKey, checkId, (current) => ({
|
||||
...current,
|
||||
acceptedProposalEventId: undefined,
|
||||
keptCurrentApproach: undefined,
|
||||
status: "running",
|
||||
}));
|
||||
toast.success("Reasoning sent. Waiting for revised proposals.");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to continue the review conversation.";
|
||||
toast.error(message);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[relayScope, runs, signerScope, updateRun],
|
||||
);
|
||||
|
||||
const runCheck = React.useCallback(
|
||||
async (
|
||||
check: ProjectReviewCheckDefinition,
|
||||
@@ -561,8 +722,10 @@ export function ProjectReviewChecks({
|
||||
},
|
||||
requestId,
|
||||
acceptedProposalEventId: undefined,
|
||||
proposal: undefined,
|
||||
keptCurrentApproach: undefined,
|
||||
proposals: undefined,
|
||||
result: undefined,
|
||||
resultEventIds: [],
|
||||
resultCreatedAt: undefined,
|
||||
status: "running",
|
||||
targetCommit,
|
||||
@@ -716,11 +879,14 @@ export function ProjectReviewChecks({
|
||||
{run.result ? (
|
||||
<ProjectReviewCheckResultCard
|
||||
acceptedProposalEventId={run.acceptedProposalEventId}
|
||||
canAcceptProposal={!isStale}
|
||||
canDecide={!isStale && run.status === "completed"}
|
||||
keptCurrentApproach={run.keptCurrentApproach === true}
|
||||
onAcceptProposal={(eventId) =>
|
||||
acceptProposal(check.id, eventId)
|
||||
}
|
||||
proposal={run.proposal}
|
||||
onKeepCurrentApproach={() => keepCurrentApproach(check.id)}
|
||||
onRespond={(response) => respondToCheck(check.id, response)}
|
||||
proposals={run.proposals ?? []}
|
||||
result={run.result}
|
||||
/>
|
||||
) : null}
|
||||
@@ -733,6 +899,7 @@ export function ProjectReviewChecks({
|
||||
opener={run.opener}
|
||||
repoUrl={diffRepoUrl}
|
||||
requestId={run.requestId ?? null}
|
||||
resultEventIds={run.resultEventIds ?? []}
|
||||
targetCommit={run.targetCommit ?? targetCommit}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -12,6 +12,8 @@ const RELAY_REVIEW_AGENT_PUBKEY =
|
||||
const LOCAL_REVIEW_AGENT_PUBKEY = "c".repeat(64);
|
||||
const STOPPED_LOCAL_REVIEW_AGENT_PUBKEY = "d".repeat(64);
|
||||
const PROPOSED_DIFF_EVENT_ID = "e".repeat(64);
|
||||
const ALTERNATE_DIFF_EVENT_ID = "f".repeat(64);
|
||||
const REVISED_DIFF_EVENT_ID = "1".repeat(64);
|
||||
|
||||
async function expectSinglePrimaryTextColumn(row: Locator) {
|
||||
const primary = row.locator('[data-projects-text-priority="primary"]');
|
||||
@@ -227,6 +229,7 @@ test("review checks dispatch to an agent authorized for the relay identity", asy
|
||||
await page.evaluate(
|
||||
({
|
||||
agentPubkey,
|
||||
alternateDiffEventId,
|
||||
diffEventId,
|
||||
diffRepoUrl,
|
||||
openerEventId,
|
||||
@@ -256,25 +259,57 @@ test("review checks dispatch to an agent authorized for the relay identity", asy
|
||||
parentEventId: openerEventId,
|
||||
pubkey: agentPubkey,
|
||||
});
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "DM",
|
||||
content: [
|
||||
"diff --git a/desktop/src/shared/ui/button.tsx b/desktop/src/shared/ui/button.tsx",
|
||||
"--- a/desktop/src/shared/ui/button.tsx",
|
||||
"+++ b/desktop/src/shared/ui/button.tsx",
|
||||
"@@ -1,3 +1,3 @@",
|
||||
"-const focusStyle = 'outline-none';",
|
||||
"+const focusStyle = 'outline-offset-2';",
|
||||
" export { focusStyle };",
|
||||
].join("\n"),
|
||||
createdAt: Math.floor(Date.now() / 1_000) + 1,
|
||||
extraTags: [
|
||||
["repo", diffRepoUrl],
|
||||
["commit", reviewedCommit],
|
||||
["file", "desktop/src/shared/ui/button.tsx"],
|
||||
["description", "Preserve the custom control"],
|
||||
],
|
||||
id: alternateDiffEventId,
|
||||
kind: 40008,
|
||||
parentEventId: openerEventId,
|
||||
pubkey: agentPubkey,
|
||||
});
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "DM",
|
||||
content: `BUZZ_CHECK_RESULT_V1\n${JSON.stringify({
|
||||
request_id: requestId,
|
||||
conclusion: "fix-recommended",
|
||||
summary: "Two interface fixes are needed before approval.",
|
||||
diff_event_id: diffEventId,
|
||||
findings: [
|
||||
summary:
|
||||
"The focus behavior can follow the shared primitive or preserve the custom control's semantics.",
|
||||
proposals: [
|
||||
{
|
||||
title: "Use the shared button primitive",
|
||||
detail: "The custom control misses the standard focus treatment.",
|
||||
file: "desktop/src/features/projects/ui/ProjectReviewChecks.tsx",
|
||||
line: 742,
|
||||
title: "Use the shared focus treatment",
|
||||
summary:
|
||||
"Align this control with the standard keyboard focus behavior.",
|
||||
diff_event_id: diffEventId,
|
||||
},
|
||||
{
|
||||
title: "Cover the disabled interaction state",
|
||||
detail: "Add a UI assertion for the pending check state.",
|
||||
file: "desktop/tests/e2e/project-pr-review.spec.ts",
|
||||
line: 118,
|
||||
title: "Preserve the custom control",
|
||||
summary:
|
||||
"Keep the specialized control and make its focus boundary explicit.",
|
||||
diff_event_id: alternateDiffEventId,
|
||||
},
|
||||
],
|
||||
findings: [
|
||||
{
|
||||
title: "Choose a consistent focus boundary",
|
||||
detail:
|
||||
"The current control has no visible keyboard focus treatment.",
|
||||
file: "desktop/src/features/projects/ui/ProjectReviewChecks.tsx",
|
||||
line: 742,
|
||||
},
|
||||
],
|
||||
})}`,
|
||||
@@ -286,6 +321,7 @@ test("review checks dispatch to an agent authorized for the relay identity", asy
|
||||
},
|
||||
{
|
||||
agentPubkey: RELAY_REVIEW_AGENT_PUBKEY,
|
||||
alternateDiffEventId: ALTERNATE_DIFF_EVENT_ID,
|
||||
diffEventId: PROPOSED_DIFF_EVENT_ID,
|
||||
diffRepoUrl: diffRepoUrl ?? "",
|
||||
openerEventId,
|
||||
@@ -297,37 +333,154 @@ test("review checks dispatch to an agent authorized for the relay identity", asy
|
||||
const result = interfaceCheck.getByTestId("project-review-check-result");
|
||||
await expect(
|
||||
result.getByRole("heading", {
|
||||
name: "2 fixes recommended",
|
||||
name: "Choose a direction",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(result).toContainText(
|
||||
"Two interface fixes are needed before approval.",
|
||||
"The focus behavior can follow the shared primitive or preserve the custom control's semantics.",
|
||||
);
|
||||
await expect(result.getByTestId("project-review-check-finding")).toHaveCount(
|
||||
2,
|
||||
const proposals = result.getByTestId("project-review-check-proposal");
|
||||
await expect(proposals).toHaveCount(2);
|
||||
const sharedDirection = proposals.nth(0);
|
||||
const customDirection = proposals.nth(1);
|
||||
await expect(sharedDirection).toContainText("Use the shared focus treatment");
|
||||
await expect(customDirection).toContainText("Preserve the custom control");
|
||||
const sharedFile = sharedDirection.getByTestId(
|
||||
"project-review-check-proposal-file",
|
||||
);
|
||||
await expect(result).toContainText("ProjectReviewChecks.tsx:742");
|
||||
const proposal = result.getByTestId("project-review-check-diff-proposal");
|
||||
await expect(proposal).toContainText("Proposed code changes");
|
||||
await expect(proposal).toContainText("button.tsx");
|
||||
await expect(proposal).toContainText("Use the shared focus treatment");
|
||||
await expect(proposal).toContainText("focus-visible:ring-1");
|
||||
await expect(sharedDirection).toContainText("+1");
|
||||
await expect(sharedDirection).toContainText("-1");
|
||||
const sharedDetails = sharedDirection.getByTestId(
|
||||
"project-review-check-proposal-details",
|
||||
);
|
||||
await expect(sharedDetails).not.toHaveAttribute("open", "");
|
||||
await sharedDetails.getByText("Read more").click();
|
||||
await expect(sharedDetails).toHaveAttribute("open", "");
|
||||
await expect(sharedFile).toContainText("button.tsx");
|
||||
await expect(sharedFile).not.toHaveAttribute("open", "");
|
||||
await sharedFile.locator("summary").click();
|
||||
await expect(sharedFile).toHaveAttribute("open", "");
|
||||
await expect(
|
||||
sharedDirection.getByText("const focusStyle = 'focus-visible:ring-1';"),
|
||||
).toBeVisible();
|
||||
const commandCountBeforeAccept = await page.evaluate(
|
||||
() => window.__BUZZ_E2E_COMMANDS__?.length ?? 0,
|
||||
);
|
||||
await proposal.getByRole("button", { name: "Accept changes" }).click();
|
||||
await sharedDirection
|
||||
.getByRole("button", { name: "Accept", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
proposal.getByRole("button", { name: "Accepted" }),
|
||||
sharedDirection.getByRole("button", { name: "Accepted" }),
|
||||
).toBeVisible();
|
||||
await expect(proposal).toContainText(
|
||||
"No repository files or review state were changed.",
|
||||
);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__BUZZ_E2E_COMMANDS__?.length ?? 0))
|
||||
.toBe(commandCountBeforeAccept);
|
||||
await waitForAnimations(page);
|
||||
await result.screenshot({ path: `${SHOTS}/review-check-result.png` });
|
||||
|
||||
await result.getByRole("button", { name: "Keep it as-is" }).click();
|
||||
await expect(
|
||||
result.getByRole("button", { name: "Keeping it as-is" }),
|
||||
).toBeVisible();
|
||||
|
||||
await result.getByRole("button", { name: "Propose", exact: true }).click();
|
||||
await result
|
||||
.getByRole("textbox", { name: "Propose another direction" })
|
||||
.fill("Can we keep the custom control but use the shared focus token?");
|
||||
await result.getByRole("button", { name: "Respond" }).click();
|
||||
await expect(interfaceCheck).toContainText("Running");
|
||||
const response = await page.evaluate(() => {
|
||||
const entry = window.__BUZZ_E2E_COMMAND_PAYLOADS__?.findLast(
|
||||
(candidate) =>
|
||||
candidate.command === "send_channel_message" &&
|
||||
(candidate.payload as { content?: unknown })?.content ===
|
||||
"Can we keep the custom control but use the shared focus token?",
|
||||
);
|
||||
return entry?.payload as
|
||||
| {
|
||||
mentionPubkeys?: string[];
|
||||
parentEventId?: string;
|
||||
rootEventId?: string;
|
||||
}
|
||||
| undefined;
|
||||
});
|
||||
expect(response?.mentionPubkeys).toContain(RELAY_REVIEW_AGENT_PUBKEY);
|
||||
expect(response?.parentEventId).toBe(openerEventId);
|
||||
expect(response?.rootEventId).toBe(openerEventId);
|
||||
|
||||
await page.evaluate(
|
||||
({
|
||||
agentPubkey,
|
||||
diffEventId,
|
||||
diffRepoUrl,
|
||||
openerEventId,
|
||||
requestId,
|
||||
reviewedCommit,
|
||||
}) => {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "DM",
|
||||
content: [
|
||||
"diff --git a/desktop/src/shared/ui/button.tsx b/desktop/src/shared/ui/button.tsx",
|
||||
"--- a/desktop/src/shared/ui/button.tsx",
|
||||
"+++ b/desktop/src/shared/ui/button.tsx",
|
||||
"@@ -1,3 +1,3 @@",
|
||||
"-const focusStyle = 'outline-none';",
|
||||
"+const focusStyle = sharedFocusToken;",
|
||||
" export { focusStyle };",
|
||||
].join("\n"),
|
||||
createdAt: Math.floor(Date.now() / 1_000) + 4,
|
||||
extraTags: [
|
||||
["repo", diffRepoUrl],
|
||||
["commit", reviewedCommit],
|
||||
["file", "desktop/src/shared/ui/button.tsx"],
|
||||
["description", "Use the shared token in the custom control"],
|
||||
],
|
||||
id: diffEventId,
|
||||
kind: 40008,
|
||||
parentEventId: openerEventId,
|
||||
pubkey: agentPubkey,
|
||||
});
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "DM",
|
||||
content: `BUZZ_CHECK_RESULT_V1\n${JSON.stringify({
|
||||
request_id: requestId,
|
||||
conclusion: "fix-recommended",
|
||||
summary:
|
||||
"Your hybrid direction preserves the custom control while sharing the established focus token.",
|
||||
proposals: [
|
||||
{
|
||||
title: "Share the focus token",
|
||||
summary:
|
||||
"Keep the specialized control and reuse the common visual contract.",
|
||||
diff_event_id: diffEventId,
|
||||
},
|
||||
],
|
||||
findings: [],
|
||||
})}`,
|
||||
createdAt: Math.floor(Date.now() / 1_000) + 5,
|
||||
kind: 9,
|
||||
parentEventId: openerEventId,
|
||||
pubkey: agentPubkey,
|
||||
});
|
||||
},
|
||||
{
|
||||
agentPubkey: RELAY_REVIEW_AGENT_PUBKEY,
|
||||
diffEventId: REVISED_DIFF_EVENT_ID,
|
||||
diffRepoUrl: diffRepoUrl ?? "",
|
||||
openerEventId,
|
||||
requestId,
|
||||
reviewedCommit: reviewedCommit ?? "",
|
||||
},
|
||||
);
|
||||
await expect(interfaceCheck).toContainText("Proposal ready");
|
||||
await expect(result).toContainText(
|
||||
"Your hybrid direction preserves the custom control while sharing the established focus token.",
|
||||
);
|
||||
await expect(result.getByTestId("project-review-check-proposal")).toHaveCount(
|
||||
1,
|
||||
);
|
||||
await expect(result).toContainText("Share the focus token");
|
||||
});
|
||||
|
||||
test("review checks dispatch to a running local managed agent", async ({
|
||||
|
||||
Reference in New Issue
Block a user