Skip to content

AI run filtering #2285

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 34 commits into from
Jul 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
34ea140
Queue in run table and filtering
matt-aitken Jul 17, 2025
471849a
Debounce the filter changes
matt-aitken Jul 17, 2025
f84131d
Remove console log
matt-aitken Jul 17, 2025
a1aac6b
Added machine filtering
matt-aitken Jul 17, 2025
f34f4c9
Added version filtering
matt-aitken Jul 17, 2025
314ecbf
Filter by version in the db
matt-aitken Jul 18, 2025
5c1d696
Removed duplicate classes
matt-aitken Jul 21, 2025
4f601f3
Version filtering hasFilters consistency
matt-aitken Jul 21, 2025
73ba30b
Added queues and machines to the bulk action summary
matt-aitken Jul 21, 2025
0d42fd1
runs.list filtering for queue and machine
matt-aitken Jul 21, 2025
77e9c93
Fix for machine errors
matt-aitken Jul 21, 2025
023d6fb
Input field now has accessory instead of shortcut
matt-aitken Jul 18, 2025
dc70047
First experiments with the UI
matt-aitken Jul 18, 2025
8491caa
Got the fake filtering working
matt-aitken Jul 18, 2025
c05b38c
AI filtering is working pretty well ✨
matt-aitken Jul 18, 2025
d86d165
Started working on tool calling
matt-aitken Jul 18, 2025
e19f2ce
Tool calling is working
matt-aitken Jul 18, 2025
8b24416
Styling progress
matt-aitken Jul 18, 2025
e7ac8f9
Working on the error
matt-aitken Jul 19, 2025
13735cc
Errors work, improved the styling
matt-aitken Jul 19, 2025
958395d
Nice glow effect
matt-aitken Jul 20, 2025
dfb0f86
Tweak the darkness of the text field
matt-aitken Jul 20, 2025
d4e332a
Re-ordered the UI, set AI settings to use system prompt and telemetry
matt-aitken Jul 21, 2025
f156160
Refactored to make it testable
matt-aitken Jul 21, 2025
be9351c
Added basic evals
matt-aitken Jul 21, 2025
80562a9
Better time inputs and evals
matt-aitken Jul 21, 2025
de42243
Removed some code comments
matt-aitken Jul 21, 2025
0b57867
Remove unused useSearchParam change
matt-aitken Jul 21, 2025
56b075d
Tidy imports
matt-aitken Jul 21, 2025
6192213
If no OpenAI API key send json back
matt-aitken Jul 21, 2025
0039452
Fix for merge conflict with duplicate query filters
matt-aitken Jul 21, 2025
beaadb9
Another conflict resolved
matt-aitken Jul 21, 2025
b467eba
Another merge conflict resolved
matt-aitken Jul 21, 2025
178aa90
Pass the model in, allow changing it
matt-aitken Jul 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 30 additions & 13 deletions apps/webapp/app/components/primitives/Input.tsx
Original file line number Diff line number Diff line change
@@ -1,69 +1,86 @@
import * as React from "react";
import { useImperativeHandle, useRef } from "react";
import { cn } from "~/utils/cn";
import { Icon, RenderIcon } from "./Icon";
import { Icon, type RenderIcon } from "./Icon";

const containerBase =
"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";

const inputBase =
"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";

const shortcutBase =
"grid h-fit place-content-center border border-dimmed/40 font-normal text-text-dimmed";

const variants = {
large: {
container:
"px-1 w-full h-10 rounded-[3px] border border-charcoal-800 bg-charcoal-750 hover:border-charcoal-600 hover:bg-charcoal-650",
input: "px-2 text-sm",
iconSize: "size-4 ml-1",
shortcut: "mr-1 min-w-[22px] rounded-sm py-[3px] px-[5px] text-[0.6rem] select-none",
accessory: "pr-1",
},
medium: {
container:
"px-1 h-8 w-full rounded border border-charcoal-800 bg-charcoal-750 hover:border-charcoal-600 hover:bg-charcoal-650",
input: "px-1.5 rounded text-sm",
iconSize: "size-4 ml-0.5",
shortcut: "min-w-[22px] rounded-sm py-[3px] px-[5px] text-[0.6rem]",
accessory: "pr-1",
},
small: {
container:
"px-1 h-6 w-full rounded border border-charcoal-800 bg-charcoal-750 hover:border-charcoal-600 hover:bg-charcoal-650",
input: "px-1 rounded text-xs",
iconSize: "size-3 ml-0.5",
shortcut: "min-w-[22px] rounded-[2px] py-px px-[3px] text-[0.5rem]",
accessory: "pr-0.5",
},
tertiary: {
container: "px-1 h-6 w-full rounded hover:bg-charcoal-750",
input: "px-1 rounded text-xs",
iconSize: "size-3 ml-0.5",
shortcut: "min-w-[22px] rounded-[2px] py-px px-[3px] text-[0.5rem]",
accessory: "pr-0.5",
},
"secondary-small": {
container:
"px-1 h-6 w-full rounded border border-charcoal-600 hover:border-charcoal-550 bg-grid-dimmed hover:bg-charcoal-650",
input: "px-1 rounded text-xs",
iconSize: "size-3 ml-0.5",
accessory: "pr-0.5",
},
};

export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
variant?: keyof typeof variants;
icon?: RenderIcon;
shortcut?: string;
accessory?: React.ReactNode;
fullWidth?: boolean;
containerClassName?: string;
};

const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, shortcut, fullWidth = true, variant = "medium", icon, ...props }, ref) => {
(
{
className,
type,
accessory,
fullWidth = true,
variant = "medium",
icon,
containerClassName,
...props
},
ref
) => {
const innerRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => innerRef.current as HTMLInputElement);

const containerClassName = variants[variant].container;
const variantContainerClassName = variants[variant].container;
const inputClassName = variants[variant].input;
const iconClassName = variants[variant].iconSize;
const shortcutClassName = variants[variant].shortcut;

return (
<div
className={cn(
"flex items-center",
containerBase,
variantContainerClassName,
containerClassName,
fullWidth ? "w-full" : "max-w-max"
)}
Expand All @@ -80,7 +97,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
ref={innerRef}
{...props}
/>
{shortcut && <div className={cn(shortcutBase, shortcutClassName)}>{shortcut}</div>}
{accessory && <div className={cn(variants[variant].accessory)}>{accessory}</div>}
</div>
);
}
Expand Down
185 changes: 185 additions & 0 deletions apps/webapp/app/components/runs/v3/AIFilterInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { useFetcher, useNavigate } from "@remix-run/react";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { Input } from "~/components/primitives/Input";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { Spinner } from "~/components/primitives/Spinner";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { objectToSearchParams } from "~/utils/searchParams";
import { type TaskRunListSearchFilters } from "./RunFilters";

type AIFilterResult =
| {
success: true;
filters: TaskRunListSearchFilters;
explanation?: string;
}
| {
success: false;
error: string;
suggestions?: string[];
};

export function AIFilterInput() {
const [text, setText] = useState("");
const [isFocused, setIsFocused] = useState(false);
const navigate = useNavigate();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const inputRef = useRef<HTMLInputElement>(null);
const fetcher = useFetcher<AIFilterResult>();

useEffect(() => {
if (fetcher.data?.success && fetcher.state === "loading") {
setText("");
setIsFocused(false);

const searchParams = objectToSearchParams(fetcher.data.filters);
if (!searchParams) {
return;
}

navigate(`${location.pathname}?${searchParams.toString()}`, { replace: true });

if (inputRef.current) {
inputRef.current.focus();
}
}
}, [fetcher.data, navigate]);

const isLoading = fetcher.state === "submitting";

return (
<fetcher.Form
className="flex items-center gap-2"
action={`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/ai-filter`}
method="post"
>
<ErrorPopover error={fetcher.data?.success === false ? fetcher.data.error : undefined}>
<motion.div
initial={{ width: "auto" }}
animate={{ width: isFocused && text.length > 0 ? "24rem" : "auto" }}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
className="relative h-6 min-w-44"
>
<AnimatePresence>
{isFocused && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: "linear" }}
className="animated-gradient-glow-small pointer-events-none absolute inset-0 h-6"
/>
)}
</AnimatePresence>
<div className="absolute inset-0 left-0 top-0 h-6">
<Input
type="text"
name="text"
variant="secondary-small"
placeholder="Describe your filters…"
value={text}
onChange={(e) => setText(e.target.value)}
disabled={isLoading}
fullWidth
ref={inputRef}
className={cn(
"disabled:text-text-dimmed/50",
isFocused && "placeholder:text-text-dimmed/70"
)}
containerClassName="has-[:disabled]:opacity-100"
onKeyDown={(e) => {
if (e.key === "Enter" && text.trim() && !isLoading) {
e.preventDefault();
const form = e.currentTarget.closest("form");
if (form) {
form.requestSubmit();
}
}
}}
onFocus={() => setIsFocused(true)}
onBlur={() => {
if (text.length === 0 || !isLoading) {
setIsFocused(false);
}
}}
icon={<AISparkleIcon className="size-4" />}
accessory={
isLoading ? (
<Spinner
color={{
background: "rgba(99, 102, 241, 1)",
foreground: "rgba(217, 70, 239, 1)",
}}
className="size-4 opacity-80"
/>
) : text.length > 0 ? (
<ShortcutKey
shortcut={{ key: "enter" }}
variant="small"
className={cn("transition-opacity", text.length === 0 && "opacity-0")}
/>
) : undefined
}
/>
</div>
</motion.div>
</ErrorPopover>
</fetcher.Form>
);
}

function ErrorPopover({
children,
error,
durationMs = 10_000,
}: {
children: React.ReactNode;
error?: string;
durationMs?: number;
}) {
const [isOpen, setIsOpen] = useState(false);
const timeout = useRef<NodeJS.Timeout | undefined>();

useEffect(() => {
if (error) {
setIsOpen(true);
}
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(() => {
setIsOpen(false);
}, durationMs);

return () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
};
}, [error, durationMs]);

return (
<Popover open={isOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
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"
>
{error}
</PopoverContent>
</Popover>
);
}
Loading