Skip to content

Commit 4b9f330

Browse files
authored
AI run filtering (#2285)
* Queue in run table and filtering * Debounce the filter changes * Remove console log * Added machine filtering * Added version filtering * Filter by version in the db * Removed duplicate classes * Version filtering hasFilters consistency * Added queues and machines to the bulk action summary * runs.list filtering for queue and machine * Fix for machine errors * Input field now has accessory instead of shortcut * First experiments with the UI * Got the fake filtering working * AI filtering is working pretty well ✨ * Started working on tool calling * Tool calling is working * Styling progress * Working on the error * Errors work, improved the styling * Nice glow effect * Tweak the darkness of the text field * Re-ordered the UI, set AI settings to use system prompt and telemetry * Refactored to make it testable * Added basic evals * Better time inputs and evals * Removed some code comments * Remove unused useSearchParam change * Tidy imports * If no OpenAI API key send json back * Fix for merge conflict with duplicate query filters * Another conflict resolved * Another merge conflict resolved * Pass the model in, allow changing it
1 parent a90b73c commit 4b9f330

16 files changed

+1734
-107
lines changed

apps/webapp/app/components/primitives/Input.tsx

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,86 @@
11
import * as React from "react";
22
import { useImperativeHandle, useRef } from "react";
33
import { cn } from "~/utils/cn";
4-
import { Icon, RenderIcon } from "./Icon";
4+
import { Icon, type RenderIcon } from "./Icon";
55

66
const containerBase =
77
"has-[:focus-visible]:outline-none has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-charcoal-650 has-[:focus-visible]:ring-offset-0 has-[:focus]:border-ring has-[:focus]:outline-none has-[:focus]:ring-1 has-[:focus]:ring-ring has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 ring-offset-background transition cursor-text";
88

99
const inputBase =
1010
"h-full w-full text-text-bright bg-transparent file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed outline-none ring-0 border-none";
1111

12-
const shortcutBase =
13-
"grid h-fit place-content-center border border-dimmed/40 font-normal text-text-dimmed";
14-
1512
const variants = {
1613
large: {
1714
container:
1815
"px-1 w-full h-10 rounded-[3px] border border-charcoal-800 bg-charcoal-750 hover:border-charcoal-600 hover:bg-charcoal-650",
1916
input: "px-2 text-sm",
2017
iconSize: "size-4 ml-1",
21-
shortcut: "mr-1 min-w-[22px] rounded-sm py-[3px] px-[5px] text-[0.6rem] select-none",
18+
accessory: "pr-1",
2219
},
2320
medium: {
2421
container:
2522
"px-1 h-8 w-full rounded border border-charcoal-800 bg-charcoal-750 hover:border-charcoal-600 hover:bg-charcoal-650",
2623
input: "px-1.5 rounded text-sm",
2724
iconSize: "size-4 ml-0.5",
28-
shortcut: "min-w-[22px] rounded-sm py-[3px] px-[5px] text-[0.6rem]",
25+
accessory: "pr-1",
2926
},
3027
small: {
3128
container:
3229
"px-1 h-6 w-full rounded border border-charcoal-800 bg-charcoal-750 hover:border-charcoal-600 hover:bg-charcoal-650",
3330
input: "px-1 rounded text-xs",
3431
iconSize: "size-3 ml-0.5",
35-
shortcut: "min-w-[22px] rounded-[2px] py-px px-[3px] text-[0.5rem]",
32+
accessory: "pr-0.5",
3633
},
3734
tertiary: {
3835
container: "px-1 h-6 w-full rounded hover:bg-charcoal-750",
3936
input: "px-1 rounded text-xs",
4037
iconSize: "size-3 ml-0.5",
41-
shortcut: "min-w-[22px] rounded-[2px] py-px px-[3px] text-[0.5rem]",
38+
accessory: "pr-0.5",
39+
},
40+
"secondary-small": {
41+
container:
42+
"px-1 h-6 w-full rounded border border-charcoal-600 hover:border-charcoal-550 bg-grid-dimmed hover:bg-charcoal-650",
43+
input: "px-1 rounded text-xs",
44+
iconSize: "size-3 ml-0.5",
45+
accessory: "pr-0.5",
4246
},
4347
};
4448

4549
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
4650
variant?: keyof typeof variants;
4751
icon?: RenderIcon;
48-
shortcut?: string;
52+
accessory?: React.ReactNode;
4953
fullWidth?: boolean;
54+
containerClassName?: string;
5055
};
5156

5257
const Input = React.forwardRef<HTMLInputElement, InputProps>(
53-
({ className, type, shortcut, fullWidth = true, variant = "medium", icon, ...props }, ref) => {
58+
(
59+
{
60+
className,
61+
type,
62+
accessory,
63+
fullWidth = true,
64+
variant = "medium",
65+
icon,
66+
containerClassName,
67+
...props
68+
},
69+
ref
70+
) => {
5471
const innerRef = useRef<HTMLInputElement>(null);
5572
useImperativeHandle(ref, () => innerRef.current as HTMLInputElement);
5673

57-
const containerClassName = variants[variant].container;
74+
const variantContainerClassName = variants[variant].container;
5875
const inputClassName = variants[variant].input;
5976
const iconClassName = variants[variant].iconSize;
60-
const shortcutClassName = variants[variant].shortcut;
6177

6278
return (
6379
<div
6480
className={cn(
6581
"flex items-center",
6682
containerBase,
83+
variantContainerClassName,
6784
containerClassName,
6885
fullWidth ? "w-full" : "max-w-max"
6986
)}
@@ -80,7 +97,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
8097
ref={innerRef}
8198
{...props}
8299
/>
83-
{shortcut && <div className={cn(shortcutBase, shortcutClassName)}>{shortcut}</div>}
100+
{accessory && <div className={cn(variants[variant].accessory)}>{accessory}</div>}
84101
</div>
85102
);
86103
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { useFetcher, useNavigate } from "@remix-run/react";
2+
import { AnimatePresence, motion } from "framer-motion";
3+
import { useEffect, useRef, useState } from "react";
4+
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
5+
import { Input } from "~/components/primitives/Input";
6+
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
7+
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
8+
import { Spinner } from "~/components/primitives/Spinner";
9+
import { useEnvironment } from "~/hooks/useEnvironment";
10+
import { useOrganization } from "~/hooks/useOrganizations";
11+
import { useProject } from "~/hooks/useProject";
12+
import { cn } from "~/utils/cn";
13+
import { objectToSearchParams } from "~/utils/searchParams";
14+
import { type TaskRunListSearchFilters } from "./RunFilters";
15+
16+
type AIFilterResult =
17+
| {
18+
success: true;
19+
filters: TaskRunListSearchFilters;
20+
explanation?: string;
21+
}
22+
| {
23+
success: false;
24+
error: string;
25+
suggestions?: string[];
26+
};
27+
28+
export function AIFilterInput() {
29+
const [text, setText] = useState("");
30+
const [isFocused, setIsFocused] = useState(false);
31+
const navigate = useNavigate();
32+
const organization = useOrganization();
33+
const project = useProject();
34+
const environment = useEnvironment();
35+
const inputRef = useRef<HTMLInputElement>(null);
36+
const fetcher = useFetcher<AIFilterResult>();
37+
38+
useEffect(() => {
39+
if (fetcher.data?.success && fetcher.state === "loading") {
40+
setText("");
41+
setIsFocused(false);
42+
43+
const searchParams = objectToSearchParams(fetcher.data.filters);
44+
if (!searchParams) {
45+
return;
46+
}
47+
48+
navigate(`${location.pathname}?${searchParams.toString()}`, { replace: true });
49+
50+
if (inputRef.current) {
51+
inputRef.current.focus();
52+
}
53+
}
54+
}, [fetcher.data, navigate]);
55+
56+
const isLoading = fetcher.state === "submitting";
57+
58+
return (
59+
<fetcher.Form
60+
className="flex items-center gap-2"
61+
action={`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/ai-filter`}
62+
method="post"
63+
>
64+
<ErrorPopover error={fetcher.data?.success === false ? fetcher.data.error : undefined}>
65+
<motion.div
66+
initial={{ width: "auto" }}
67+
animate={{ width: isFocused && text.length > 0 ? "24rem" : "auto" }}
68+
transition={{
69+
type: "spring",
70+
stiffness: 300,
71+
damping: 30,
72+
}}
73+
className="relative h-6 min-w-44"
74+
>
75+
<AnimatePresence>
76+
{isFocused && (
77+
<motion.div
78+
initial={{ opacity: 0 }}
79+
animate={{ opacity: 1 }}
80+
exit={{ opacity: 0 }}
81+
transition={{ duration: 0.2, ease: "linear" }}
82+
className="animated-gradient-glow-small pointer-events-none absolute inset-0 h-6"
83+
/>
84+
)}
85+
</AnimatePresence>
86+
<div className="absolute inset-0 left-0 top-0 h-6">
87+
<Input
88+
type="text"
89+
name="text"
90+
variant="secondary-small"
91+
placeholder="Describe your filters…"
92+
value={text}
93+
onChange={(e) => setText(e.target.value)}
94+
disabled={isLoading}
95+
fullWidth
96+
ref={inputRef}
97+
className={cn(
98+
"disabled:text-text-dimmed/50",
99+
isFocused && "placeholder:text-text-dimmed/70"
100+
)}
101+
containerClassName="has-[:disabled]:opacity-100"
102+
onKeyDown={(e) => {
103+
if (e.key === "Enter" && text.trim() && !isLoading) {
104+
e.preventDefault();
105+
const form = e.currentTarget.closest("form");
106+
if (form) {
107+
form.requestSubmit();
108+
}
109+
}
110+
}}
111+
onFocus={() => setIsFocused(true)}
112+
onBlur={() => {
113+
if (text.length === 0 || !isLoading) {
114+
setIsFocused(false);
115+
}
116+
}}
117+
icon={<AISparkleIcon className="size-4" />}
118+
accessory={
119+
isLoading ? (
120+
<Spinner
121+
color={{
122+
background: "rgba(99, 102, 241, 1)",
123+
foreground: "rgba(217, 70, 239, 1)",
124+
}}
125+
className="size-4 opacity-80"
126+
/>
127+
) : text.length > 0 ? (
128+
<ShortcutKey
129+
shortcut={{ key: "enter" }}
130+
variant="small"
131+
className={cn("transition-opacity", text.length === 0 && "opacity-0")}
132+
/>
133+
) : undefined
134+
}
135+
/>
136+
</div>
137+
</motion.div>
138+
</ErrorPopover>
139+
</fetcher.Form>
140+
);
141+
}
142+
143+
function ErrorPopover({
144+
children,
145+
error,
146+
durationMs = 10_000,
147+
}: {
148+
children: React.ReactNode;
149+
error?: string;
150+
durationMs?: number;
151+
}) {
152+
const [isOpen, setIsOpen] = useState(false);
153+
const timeout = useRef<NodeJS.Timeout | undefined>();
154+
155+
useEffect(() => {
156+
if (error) {
157+
setIsOpen(true);
158+
}
159+
if (timeout.current) {
160+
clearTimeout(timeout.current);
161+
}
162+
timeout.current = setTimeout(() => {
163+
setIsOpen(false);
164+
}, durationMs);
165+
166+
return () => {
167+
if (timeout.current) {
168+
clearTimeout(timeout.current);
169+
}
170+
};
171+
}, [error, durationMs]);
172+
173+
return (
174+
<Popover open={isOpen}>
175+
<PopoverTrigger asChild>{children}</PopoverTrigger>
176+
<PopoverContent
177+
align="start"
178+
side="bottom"
179+
className="w-[var(--radix-popover-trigger-width)] min-w-[var(--radix-popover-trigger-width)] max-w-[var(--radix-popover-trigger-width)] border border-error/20 bg-[#2F1D24] px-3 py-2 text-xs text-text-dimmed"
180+
>
181+
{error}
182+
</PopoverContent>
183+
</Popover>
184+
);
185+
}

0 commit comments

Comments
 (0)