@frixaco/hbench 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -0
- package/bin/hbench.js +59 -0
- package/bun-env.d.ts +17 -0
- package/lib/.gitkeep +0 -0
- package/package.json +74 -0
- package/server/build.ts +172 -0
- package/server/index.ts +539 -0
- package/server/review.ts +162 -0
- package/server/tsconfig.json +9 -0
- package/tsconfig.base.json +25 -0
- package/tsconfig.json +4 -0
- package/ui/app.tsx +15 -0
- package/ui/components/button.tsx +57 -0
- package/ui/components/cmd-bar.tsx +131 -0
- package/ui/components/diff-view.tsx +51 -0
- package/ui/components/input.tsx +18 -0
- package/ui/components/review-sheet.tsx +261 -0
- package/ui/components/review-view.tsx +40 -0
- package/ui/components/select.tsx +199 -0
- package/ui/components/sheet.tsx +131 -0
- package/ui/components/sonner.tsx +41 -0
- package/ui/components/tui.tsx +313 -0
- package/ui/ghostty-web.tsx +138 -0
- package/ui/index.html +13 -0
- package/ui/index.tsx +20 -0
- package/ui/lib/agent-patterns.ts +127 -0
- package/ui/lib/diff-client.ts +38 -0
- package/ui/lib/models.json +8 -0
- package/ui/lib/reviewer.ts +82 -0
- package/ui/lib/store.ts +90 -0
- package/ui/lib/utils.ts +7 -0
- package/ui/lib/websocket.tsx +144 -0
- package/ui/styles.css +89 -0
- package/ui/tsconfig.json +8 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "Preserve",
|
|
5
|
+
"moduleDetection": "force",
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"allowJs": true,
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"strict": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"noFallthroughCasesInSwitch": true,
|
|
15
|
+
"noUncheckedIndexedAccess": true,
|
|
16
|
+
"noImplicitOverride": true,
|
|
17
|
+
"paths": {
|
|
18
|
+
"@/*": ["./ui/*", "./lib/*"]
|
|
19
|
+
},
|
|
20
|
+
"noUnusedLocals": false,
|
|
21
|
+
"noUnusedParameters": false,
|
|
22
|
+
"noPropertyAccessFromIndexSignature": false
|
|
23
|
+
},
|
|
24
|
+
"exclude": ["dist", "node_modules"]
|
|
25
|
+
}
|
package/tsconfig.json
ADDED
package/ui/app.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function App() {
|
|
2
|
+
return (
|
|
3
|
+
<WebSocketProvider>
|
|
4
|
+
<Dashboard />
|
|
5
|
+
<Toaster />
|
|
6
|
+
</WebSocketProvider>
|
|
7
|
+
);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default App;
|
|
11
|
+
|
|
12
|
+
import "./styles.css";
|
|
13
|
+
import { Dashboard } from "./ghostty-web";
|
|
14
|
+
import { WebSocketProvider } from "./lib/websocket";
|
|
15
|
+
import { Toaster } from "./components/sonner";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const buttonVariants = cva(
|
|
2
|
+
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
|
3
|
+
{
|
|
4
|
+
variants: {
|
|
5
|
+
variant: {
|
|
6
|
+
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
|
7
|
+
outline:
|
|
8
|
+
"border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
|
9
|
+
secondary:
|
|
10
|
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
|
11
|
+
ghost:
|
|
12
|
+
"hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
|
13
|
+
destructive:
|
|
14
|
+
"bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
|
15
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
16
|
+
},
|
|
17
|
+
size: {
|
|
18
|
+
default:
|
|
19
|
+
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
20
|
+
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
21
|
+
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
22
|
+
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
|
23
|
+
icon: "size-8",
|
|
24
|
+
"icon-xs":
|
|
25
|
+
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
|
26
|
+
"icon-sm":
|
|
27
|
+
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
|
28
|
+
"icon-lg": "size-9",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
defaultVariants: {
|
|
32
|
+
variant: "default",
|
|
33
|
+
size: "default",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
function Button({
|
|
39
|
+
className,
|
|
40
|
+
variant = "default",
|
|
41
|
+
size = "default",
|
|
42
|
+
...props
|
|
43
|
+
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
|
44
|
+
return (
|
|
45
|
+
<ButtonPrimitive
|
|
46
|
+
data-slot="button"
|
|
47
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
48
|
+
{...props}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { Button, buttonVariants };
|
|
54
|
+
|
|
55
|
+
import { Button as ButtonPrimitive } from "@base-ui/react/button";
|
|
56
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
57
|
+
import { cn } from "@/lib/utils";
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
export function CommandBar() {
|
|
2
|
+
const ws = useWS();
|
|
3
|
+
const trimmedRepoUrlInput = useDashboardStore(selectTrimmedRepoUrlInput);
|
|
4
|
+
const isRepoReady = useDashboardStore(selectIsRepoReady);
|
|
5
|
+
const launchedAgentCount = useDashboardStore(selectLaunchedAgentCount);
|
|
6
|
+
const prompt = useDashboardStore(selectPrompt);
|
|
7
|
+
const isStoppingAgents = useDashboardStore(selectIsStoppingAgents);
|
|
8
|
+
const trimmedPrompt = useDashboardStore(selectTrimmedPrompt);
|
|
9
|
+
|
|
10
|
+
const handleLaunchAllAgents = useCallback(() => {
|
|
11
|
+
agents.forEach((agent) => ws.send(agent));
|
|
12
|
+
launchAllAgents();
|
|
13
|
+
}, [ws]);
|
|
14
|
+
|
|
15
|
+
const runPromptOnAllAgents = useCallback(() => {
|
|
16
|
+
if (!trimmedPrompt) return;
|
|
17
|
+
|
|
18
|
+
agents.forEach((agent) => {
|
|
19
|
+
ws.send(
|
|
20
|
+
JSON.stringify({
|
|
21
|
+
type: "input",
|
|
22
|
+
agent,
|
|
23
|
+
data: trimmedPrompt,
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
window.setTimeout(() => {
|
|
27
|
+
ws.send(
|
|
28
|
+
JSON.stringify({
|
|
29
|
+
type: "input",
|
|
30
|
+
agent,
|
|
31
|
+
data: "\r",
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
}, 250);
|
|
35
|
+
});
|
|
36
|
+
}, [trimmedPrompt, ws]);
|
|
37
|
+
|
|
38
|
+
const stopAllAgents = useCallback(async () => {
|
|
39
|
+
if (isStoppingAgents) return;
|
|
40
|
+
setIsStoppingAgents(true);
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetch("/api/stop", {
|
|
43
|
+
method: "POST",
|
|
44
|
+
});
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
const message = (await response.text()).trim();
|
|
47
|
+
throw new Error(message || "Failed to stop agents");
|
|
48
|
+
}
|
|
49
|
+
toast.success("Stopped all agents");
|
|
50
|
+
} catch (error) {
|
|
51
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
52
|
+
toast.error("Stop failed", { description: message });
|
|
53
|
+
} finally {
|
|
54
|
+
resetRunRequested();
|
|
55
|
+
setIsStoppingAgents(false);
|
|
56
|
+
}
|
|
57
|
+
}, [isStoppingAgents]);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="sticky top-11 z-20 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/80">
|
|
61
|
+
<div className="mx-auto flex max-w-480 items-center gap-2 px-4 py-1.5">
|
|
62
|
+
<input
|
|
63
|
+
value={prompt}
|
|
64
|
+
onChange={(e) => setPrompt(e.currentTarget.value)}
|
|
65
|
+
onKeyDown={(e) => {
|
|
66
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
runPromptOnAllAgents();
|
|
69
|
+
}
|
|
70
|
+
}}
|
|
71
|
+
placeholder="Broadcast prompt to all agents…"
|
|
72
|
+
className="h-7 flex-1 rounded-md border border-input bg-transparent px-2.5 font-mono text-xs outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 dark:bg-input/30"
|
|
73
|
+
/>
|
|
74
|
+
<Button
|
|
75
|
+
size="xs"
|
|
76
|
+
disabled={!isRepoReady || trimmedPrompt.length === 0}
|
|
77
|
+
onClick={runPromptOnAllAgents}
|
|
78
|
+
>
|
|
79
|
+
<Send /> Send
|
|
80
|
+
</Button>
|
|
81
|
+
|
|
82
|
+
<div className="mx-1 h-4 w-px bg-border" />
|
|
83
|
+
|
|
84
|
+
<Button
|
|
85
|
+
size="xs"
|
|
86
|
+
variant="outline"
|
|
87
|
+
disabled={!isRepoReady}
|
|
88
|
+
onClick={handleLaunchAllAgents}
|
|
89
|
+
>
|
|
90
|
+
<Play /> All
|
|
91
|
+
</Button>
|
|
92
|
+
<Button
|
|
93
|
+
size="xs"
|
|
94
|
+
variant="destructive"
|
|
95
|
+
disabled={isStoppingAgents || launchedAgentCount === 0}
|
|
96
|
+
onClick={() => void stopAllAgents()}
|
|
97
|
+
>
|
|
98
|
+
<Square /> Stop
|
|
99
|
+
</Button>
|
|
100
|
+
|
|
101
|
+
<div className="mx-1 h-4 w-px bg-border" />
|
|
102
|
+
|
|
103
|
+
<ReviewSheet
|
|
104
|
+
isRepoReady={isRepoReady}
|
|
105
|
+
repoUrlInput={trimmedRepoUrlInput}
|
|
106
|
+
/>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
import { useCallback } from "react";
|
|
113
|
+
import { Play, Send, Square } from "lucide-react";
|
|
114
|
+
import { toast } from "sonner";
|
|
115
|
+
import { Button } from "./button";
|
|
116
|
+
import { ReviewSheet } from "./review-sheet";
|
|
117
|
+
import {
|
|
118
|
+
agents,
|
|
119
|
+
launchAllAgents,
|
|
120
|
+
resetRunRequested,
|
|
121
|
+
selectIsRepoReady,
|
|
122
|
+
selectIsStoppingAgents,
|
|
123
|
+
selectPrompt,
|
|
124
|
+
selectLaunchedAgentCount,
|
|
125
|
+
selectTrimmedPrompt,
|
|
126
|
+
selectTrimmedRepoUrlInput,
|
|
127
|
+
setIsStoppingAgents,
|
|
128
|
+
setPrompt,
|
|
129
|
+
useDashboardStore,
|
|
130
|
+
} from "@/lib/store";
|
|
131
|
+
import { useWS } from "@/lib/websocket";
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export function DiffView({
|
|
2
|
+
loading,
|
|
3
|
+
error,
|
|
4
|
+
patch,
|
|
5
|
+
}: {
|
|
6
|
+
loading: boolean;
|
|
7
|
+
error: string | null;
|
|
8
|
+
patch: string | null;
|
|
9
|
+
}) {
|
|
10
|
+
const files = useMemo(
|
|
11
|
+
() =>
|
|
12
|
+
patch
|
|
13
|
+
? parsePatchFiles(patch).flatMap((parsedPatch) => parsedPatch.files)
|
|
14
|
+
: [],
|
|
15
|
+
[patch],
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
if (loading) {
|
|
19
|
+
return <p className="text-muted-foreground">Loading diff...</p>;
|
|
20
|
+
}
|
|
21
|
+
if (error) {
|
|
22
|
+
return <p className="text-destructive">{error}</p>;
|
|
23
|
+
}
|
|
24
|
+
if (!patch) {
|
|
25
|
+
return <p className="text-muted-foreground">No changes yet.</p>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (files.length === 0) {
|
|
29
|
+
return (
|
|
30
|
+
<pre className="max-h-full overflow-auto rounded bg-background/60 p-3 text-xs">
|
|
31
|
+
{patch}
|
|
32
|
+
</pre>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="space-y-4">
|
|
38
|
+
{files.map((file, index) => (
|
|
39
|
+
<FileDiff
|
|
40
|
+
key={file.cacheKey ?? `${file.name}-${index}`}
|
|
41
|
+
fileDiff={file}
|
|
42
|
+
options={{ theme: "pierre-dark" }}
|
|
43
|
+
/>
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
import { useMemo } from "react";
|
|
50
|
+
import { parsePatchFiles } from "@pierre/diffs";
|
|
51
|
+
import { FileDiff } from "@pierre/diffs/react";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
2
|
+
return (
|
|
3
|
+
<BaseInput
|
|
4
|
+
type={type}
|
|
5
|
+
data-slot="input"
|
|
6
|
+
className={cn(
|
|
7
|
+
"dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
|
8
|
+
className,
|
|
9
|
+
)}
|
|
10
|
+
{...props}
|
|
11
|
+
/>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export { Input };
|
|
16
|
+
|
|
17
|
+
import { cn } from "@/lib/utils";
|
|
18
|
+
import { Input as BaseInput } from "@base-ui/react/input";
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
export function ReviewSheet({
|
|
2
|
+
isRepoReady,
|
|
3
|
+
repoUrlInput,
|
|
4
|
+
}: {
|
|
5
|
+
isRepoReady: boolean;
|
|
6
|
+
repoUrlInput: string;
|
|
7
|
+
}) {
|
|
8
|
+
const [open, setOpen] = useState(false);
|
|
9
|
+
const [collectingDiffs, setCollectingDiffs] = useState(false);
|
|
10
|
+
const [error, setError] = useState<string | null>(null);
|
|
11
|
+
const [reviewModel, setReviewModel] = useState(
|
|
12
|
+
reviewModelOptions[0]?.id ?? "",
|
|
13
|
+
);
|
|
14
|
+
const [reviewApiKey, setReviewApiKey] = useState("");
|
|
15
|
+
|
|
16
|
+
const reviewAbortRef = useRef<AbortController | null>(null);
|
|
17
|
+
const requestInFlightRef = useRef(false);
|
|
18
|
+
|
|
19
|
+
const {
|
|
20
|
+
completion,
|
|
21
|
+
complete,
|
|
22
|
+
error: completionError,
|
|
23
|
+
isLoading,
|
|
24
|
+
setCompletion,
|
|
25
|
+
stop,
|
|
26
|
+
} = useCompletion({
|
|
27
|
+
api: "/api/review",
|
|
28
|
+
streamProtocol: "text",
|
|
29
|
+
experimental_throttle: 60,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const loading = collectingDiffs || isLoading;
|
|
33
|
+
const markdown = completion.length > 0 ? completion : null;
|
|
34
|
+
|
|
35
|
+
const abortDiffCollection = useCallback(() => {
|
|
36
|
+
reviewAbortRef.current?.abort();
|
|
37
|
+
reviewAbortRef.current = null;
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
const stopReview = useCallback(() => {
|
|
41
|
+
stop();
|
|
42
|
+
}, [stop]);
|
|
43
|
+
|
|
44
|
+
useEffect(
|
|
45
|
+
() => () => {
|
|
46
|
+
abortDiffCollection();
|
|
47
|
+
stopReview();
|
|
48
|
+
},
|
|
49
|
+
[abortDiffCollection, stopReview],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!completionError) return;
|
|
54
|
+
setError(`Review stream failed: ${completionError.message}`);
|
|
55
|
+
}, [completionError]);
|
|
56
|
+
|
|
57
|
+
const requestReview = useCallback(async () => {
|
|
58
|
+
if (requestInFlightRef.current) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
requestInFlightRef.current = true;
|
|
62
|
+
|
|
63
|
+
abortDiffCollection();
|
|
64
|
+
stopReview();
|
|
65
|
+
|
|
66
|
+
const controller = new AbortController();
|
|
67
|
+
reviewAbortRef.current = controller;
|
|
68
|
+
|
|
69
|
+
setOpen(true);
|
|
70
|
+
setCollectingDiffs(true);
|
|
71
|
+
setError(null);
|
|
72
|
+
setCompletion("");
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const requestIdSeed = Date.now();
|
|
76
|
+
const diffResults = await Promise.all(
|
|
77
|
+
agents.map(async (agent, index) => {
|
|
78
|
+
let body: string;
|
|
79
|
+
try {
|
|
80
|
+
body = await fetchAgentDiff({
|
|
81
|
+
agent,
|
|
82
|
+
repoUrlInput,
|
|
83
|
+
signal: controller.signal,
|
|
84
|
+
requestId: `${requestIdSeed}-${index}`,
|
|
85
|
+
});
|
|
86
|
+
} catch (error) {
|
|
87
|
+
const message =
|
|
88
|
+
error instanceof Error ? error.message : "Failed to load diff";
|
|
89
|
+
throw new Error(`${agent}: ${message}`, { cause: error });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
agent,
|
|
94
|
+
diff: body.trim(),
|
|
95
|
+
};
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const diffs = diffResults.filter((entry) => entry.diff.length > 0);
|
|
100
|
+
if (diffs.length === 0) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
"No agent diffs found. Run agents and make changes first.",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const selectedModel =
|
|
107
|
+
reviewModelOptions.find((option) => option.id === reviewModel) ??
|
|
108
|
+
reviewModelOptions[0];
|
|
109
|
+
|
|
110
|
+
if (!selectedModel) {
|
|
111
|
+
throw new Error("No review model configured");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
setCollectingDiffs(false);
|
|
115
|
+
const output = await complete(
|
|
116
|
+
buildReviewPrompt({ repoUrl: repoUrlInput, diffs }),
|
|
117
|
+
{
|
|
118
|
+
body: {
|
|
119
|
+
model: selectedModel.id,
|
|
120
|
+
apiKey: reviewApiKey.trim() || undefined,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if (
|
|
126
|
+
!controller.signal.aborted &&
|
|
127
|
+
typeof output === "string" &&
|
|
128
|
+
output.trim().length === 0
|
|
129
|
+
) {
|
|
130
|
+
setCompletion("No review content returned.");
|
|
131
|
+
}
|
|
132
|
+
} catch (nextError) {
|
|
133
|
+
if (
|
|
134
|
+
controller.signal.aborted ||
|
|
135
|
+
(nextError instanceof Error && nextError.name === "AbortError")
|
|
136
|
+
) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const message =
|
|
141
|
+
nextError instanceof Error ? nextError.message : "Unknown error";
|
|
142
|
+
setError(`Review stream failed: ${message}`);
|
|
143
|
+
} finally {
|
|
144
|
+
if (reviewAbortRef.current === controller) {
|
|
145
|
+
reviewAbortRef.current = null;
|
|
146
|
+
}
|
|
147
|
+
setCollectingDiffs(false);
|
|
148
|
+
requestInFlightRef.current = false;
|
|
149
|
+
}
|
|
150
|
+
}, [
|
|
151
|
+
abortDiffCollection,
|
|
152
|
+
complete,
|
|
153
|
+
repoUrlInput,
|
|
154
|
+
reviewApiKey,
|
|
155
|
+
reviewModel,
|
|
156
|
+
setCompletion,
|
|
157
|
+
stopReview,
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<Sheet
|
|
162
|
+
open={open}
|
|
163
|
+
onOpenChange={(nextOpen) => {
|
|
164
|
+
setOpen(nextOpen);
|
|
165
|
+
if (!nextOpen) {
|
|
166
|
+
abortDiffCollection();
|
|
167
|
+
stopReview();
|
|
168
|
+
setCollectingDiffs(false);
|
|
169
|
+
requestInFlightRef.current = false;
|
|
170
|
+
}
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
<SheetTrigger
|
|
174
|
+
render={
|
|
175
|
+
<Button
|
|
176
|
+
size="xs"
|
|
177
|
+
variant="outline"
|
|
178
|
+
disabled={!isRepoReady || loading}
|
|
179
|
+
/>
|
|
180
|
+
}
|
|
181
|
+
>
|
|
182
|
+
{loading ? <Loader2 className="animate-spin" /> : <MessageSquareCode />}{" "}
|
|
183
|
+
Review
|
|
184
|
+
</SheetTrigger>
|
|
185
|
+
<SheetContent
|
|
186
|
+
side="right"
|
|
187
|
+
className="data-[side=right]:w-[96vw] data-[side=right]:sm:max-w-3xl"
|
|
188
|
+
>
|
|
189
|
+
<SheetHeader>
|
|
190
|
+
<div className="space-y-2">
|
|
191
|
+
<div className="flex items-center gap-2">
|
|
192
|
+
<Select
|
|
193
|
+
value={reviewModel}
|
|
194
|
+
onValueChange={(nextModel) =>
|
|
195
|
+
nextModel && setReviewModel(nextModel)
|
|
196
|
+
}
|
|
197
|
+
>
|
|
198
|
+
<SelectTrigger
|
|
199
|
+
size="sm"
|
|
200
|
+
className="h-6 gap-1 px-2 text-xs font-mono"
|
|
201
|
+
>
|
|
202
|
+
<SelectValue />
|
|
203
|
+
</SelectTrigger>
|
|
204
|
+
<SelectContent>
|
|
205
|
+
{reviewModelOptions.map((option) => (
|
|
206
|
+
<SelectItem key={option.id} value={option.id}>
|
|
207
|
+
{option.label}
|
|
208
|
+
</SelectItem>
|
|
209
|
+
))}
|
|
210
|
+
</SelectContent>
|
|
211
|
+
</Select>
|
|
212
|
+
<Button
|
|
213
|
+
size="xs"
|
|
214
|
+
disabled={loading}
|
|
215
|
+
onClick={() => void requestReview()}
|
|
216
|
+
>
|
|
217
|
+
{loading ? (
|
|
218
|
+
<Loader2 className="animate-spin" />
|
|
219
|
+
) : (
|
|
220
|
+
<RefreshCcw />
|
|
221
|
+
)}{" "}
|
|
222
|
+
Run
|
|
223
|
+
</Button>
|
|
224
|
+
</div>
|
|
225
|
+
<Input
|
|
226
|
+
value={reviewApiKey}
|
|
227
|
+
onChange={(event) => setReviewApiKey(event.currentTarget.value)}
|
|
228
|
+
type="password"
|
|
229
|
+
placeholder="OpenRouter API key (optional)"
|
|
230
|
+
className="h-7 font-mono text-xs"
|
|
231
|
+
/>
|
|
232
|
+
<p className="text-xs text-muted-foreground">
|
|
233
|
+
Empty = use <code>OPENROUTER_API_KEY</code> from shell env.
|
|
234
|
+
</p>
|
|
235
|
+
</div>
|
|
236
|
+
</SheetHeader>
|
|
237
|
+
<div className="flex-1 overflow-auto p-4">
|
|
238
|
+
<ReviewView loading={loading} error={error} markdown={markdown} />
|
|
239
|
+
</div>
|
|
240
|
+
</SheetContent>
|
|
241
|
+
</Sheet>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
246
|
+
import { useCompletion } from "@ai-sdk/react";
|
|
247
|
+
import { Loader2, MessageSquareCode, RefreshCcw } from "lucide-react";
|
|
248
|
+
import { Button } from "./button";
|
|
249
|
+
import { Input } from "./input";
|
|
250
|
+
import { ReviewView } from "./review-view";
|
|
251
|
+
import { Sheet, SheetContent, SheetHeader, SheetTrigger } from "./sheet";
|
|
252
|
+
import {
|
|
253
|
+
Select,
|
|
254
|
+
SelectContent,
|
|
255
|
+
SelectItem,
|
|
256
|
+
SelectTrigger,
|
|
257
|
+
SelectValue,
|
|
258
|
+
} from "./select";
|
|
259
|
+
import { fetchAgentDiff } from "@/lib/diff-client";
|
|
260
|
+
import { agents } from "@/lib/store";
|
|
261
|
+
import { buildReviewPrompt, reviewModelOptions } from "@/lib/reviewer";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function ReviewView({
|
|
2
|
+
loading,
|
|
3
|
+
error,
|
|
4
|
+
markdown,
|
|
5
|
+
}: {
|
|
6
|
+
loading: boolean;
|
|
7
|
+
error: string | null;
|
|
8
|
+
markdown: string | null;
|
|
9
|
+
}) {
|
|
10
|
+
if (!markdown && !loading && !error) {
|
|
11
|
+
return (
|
|
12
|
+
<p className="text-sm text-muted-foreground">
|
|
13
|
+
Click Review to compare all agent diffs.
|
|
14
|
+
</p>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
if (!markdown && loading) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
20
|
+
<Loader2 className="size-4 animate-spin" />
|
|
21
|
+
Collecting diffs and sending for review…
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
if (error && !markdown) {
|
|
26
|
+
return <p className="text-sm text-destructive">{error}</p>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="space-y-3">
|
|
31
|
+
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
|
32
|
+
<Streamdown isAnimating={loading} caret={loading ? "block" : undefined}>
|
|
33
|
+
{markdown ?? ""}
|
|
34
|
+
</Streamdown>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
import { Streamdown } from "streamdown";
|
|
40
|
+
import { Loader2 } from "lucide-react";
|