Skip to content

Commit 9f8ce6c

Browse files
committed
Styling progress
1 parent 3cfd89b commit 9f8ce6c

File tree

6 files changed

+122
-26
lines changed

6 files changed

+122
-26
lines changed

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
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";
@@ -37,6 +37,13 @@ const variants = {
3737
iconSize: "size-3 ml-0.5",
3838
accessory: "pr-0.5",
3939
},
40+
"secondary-small": {
41+
container:
42+
"px-1 h-6 w-full rounded border border-charcoal-600 hover:border-charcoal-550 bg-secondary hover:bg-charcoal-650",
43+
input: "px-1 rounded text-xs",
44+
iconSize: "size-3 ml-0.5",
45+
accessory: "pr-0.5",
46+
},
4047
};
4148

4249
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {

apps/webapp/app/components/runs/v3/AIFilterInput.tsx

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { Portal } from "@radix-ui/react-portal";
12
import { useFetcher, useNavigate } from "@remix-run/react";
2-
import { useEffect, useState } from "react";
3+
import { useEffect, useRef, useState } from "react";
34
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
45
import { Input } from "~/components/primitives/Input";
56
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
@@ -32,13 +33,29 @@ export function AIFilterInput() {
3233
const organization = useOrganization();
3334
const project = useProject();
3435
const environment = useEnvironment();
35-
36+
const inputRef = useRef<HTMLInputElement>(null);
3637
const fetcher = useFetcher<AIFilterResult>();
3738

39+
// Calculate position for error message
40+
const [errorPosition, setErrorPosition] = useState({ top: 0, left: 0, width: 0 });
41+
42+
useEffect(() => {
43+
if (fetcher.data?.success === false && inputRef.current) {
44+
const rect = inputRef.current.getBoundingClientRect();
45+
setErrorPosition({
46+
top: rect.bottom + window.scrollY,
47+
left: rect.left + window.scrollX,
48+
width: rect.width,
49+
});
50+
}
51+
}, [fetcher.data?.success]);
52+
3853
useEffect(() => {
3954
if (fetcher.data?.success && fetcher.state === "loading") {
4055
// Clear the input after successful application
4156
setText("");
57+
// Ensure focus is removed after successful submission
58+
setIsFocused(false);
4259

4360
const searchParams = objectToSearchParams(fetcher.data.filters);
4461
if (!searchParams) {
@@ -52,6 +69,11 @@ export function AIFilterInput() {
5269

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

72+
//focus the input again
73+
if (inputRef.current) {
74+
inputRef.current.focus();
75+
}
76+
5577
// TODO: Show success message with explanation
5678
console.log(`AI applied filters: ${fetcher.data.explanation}`);
5779
} else if (fetcher.data?.success === false) {
@@ -70,22 +92,28 @@ export function AIFilterInput() {
7092
>
7193
<motion.div
7294
initial={{ width: "auto" }}
73-
animate={{ width: isFocused ? "24rem" : "auto" }}
95+
animate={{ width: isFocused && text.length > 0 ? "24rem" : "auto" }}
7496
transition={{
7597
type: "spring",
7698
stiffness: 300,
7799
damping: 30,
78100
}}
101+
className="animated-gradient-glow relative"
79102
>
80103
<Input
81104
type="text"
82105
name="text"
83-
variant="small"
106+
variant="secondary-small"
84107
placeholder="Describe your filters…"
85108
value={text}
86109
onChange={(e) => setText(e.target.value)}
87110
disabled={isLoading}
88111
fullWidth
112+
ref={inputRef}
113+
className={cn(
114+
"placeholder:text-text-bright",
115+
isFocused && "placeholder:text-text-dimmed"
116+
)}
89117
onKeyDown={(e) => {
90118
if (e.key === "Enter" && text.trim() && !isLoading) {
91119
e.preventDefault();
@@ -96,7 +124,12 @@ export function AIFilterInput() {
96124
}
97125
}}
98126
onFocus={() => setIsFocused(true)}
99-
onBlur={() => setIsFocused(false)}
127+
onBlur={() => {
128+
// Only blur if the text is empty or we're not loading
129+
if (text.length === 0 || !isLoading) {
130+
setIsFocused(false);
131+
}
132+
}}
100133
icon={<AISparkleIcon className="size-4" />}
101134
accessory={
102135
isLoading ? (
@@ -110,6 +143,20 @@ export function AIFilterInput() {
110143
) : undefined
111144
}
112145
/>
146+
{fetcher.data?.success === false && (
147+
<Portal>
148+
<div
149+
className="fixed z-[9999] rounded-md bg-rose-500 px-3 py-2 text-sm text-white shadow-lg"
150+
style={{
151+
top: `${errorPosition.top + 8}px`,
152+
left: `${errorPosition.left}px`,
153+
width: `${errorPosition.width}px`,
154+
}}
155+
>
156+
{fetcher.data.error}
157+
</div>
158+
</Portal>
159+
)}
113160
</motion.div>
114161
</fetcher.Form>
115162
);

apps/webapp/app/components/runs/v3/RunFilters.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,9 @@ function FilterMenu(props: RunFiltersProps) {
395395
shortcut={shortcut}
396396
tooltipTitle={"Filter runs"}
397397
className="pr-0.5"
398-
/>
398+
>
399+
<></>
400+
</SelectTrigger>
399401
);
400402

401403
return (

apps/webapp/app/v3/services/aiRunFilterService.server.ts

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,21 @@ import { VersionListPresenter } from "~/presenters/v3/VersionListPresenter.serve
1111
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
1212
import { logger } from "~/services/logger.server";
1313

14-
const AIFilterResponseSchema = z.object({
15-
filters: TaskRunListSearchFilters.omit({ environments: true }),
16-
explanation: z
17-
.string()
18-
.describe("A short human-readable explanation of what filters were applied"),
19-
});
14+
const AIFilterResponseSchema = z
15+
.discriminatedUnion("success", [
16+
z.object({
17+
success: z.literal(true),
18+
filters: TaskRunListSearchFilters.omit({ environments: true }),
19+
explanation: z
20+
.string()
21+
.describe("A short human-readable explanation of what filters were applied"),
22+
}),
23+
z.object({
24+
success: z.literal(false),
25+
error: z.string(),
26+
}),
27+
])
28+
.describe("The response from the AI filter service");
2029

2130
export type AIFilterResult =
2231
| {
@@ -27,7 +36,6 @@ export type AIFilterResult =
2736
| {
2837
success: false;
2938
error: string;
30-
suggestions: string;
3139
};
3240

3341
export async function processAIFilter(
@@ -38,7 +46,6 @@ export async function processAIFilter(
3846
return {
3947
success: false,
4048
error: "OpenAI API key is not configured",
41-
suggestions: "Contact your administrator to configure AI features",
4249
};
4350
}
4451

@@ -185,27 +192,41 @@ The filters object should only contain the fields that are actually being filter
185192
186193
CRITICAL: The response must be a valid JSON object with exactly this structure:
187194
{
195+
"success": true,
188196
"filters": {
189197
// only include fields that have actual values
190198
},
191199
"explanation": "string explaining what filters were applied"
192200
}
193201
202+
or if you can't figure out the filters then return:
203+
{
204+
"success": false,
205+
"error": "<short human understandable suggestion>"
206+
}
207+
208+
Make the error no more than 8 words.
209+
194210
Convert the following natural language description into structured filters:
195211
196212
"${text}"
197-
198-
Return only the filters that are explicitly mentioned or can be reasonably inferred. If the description is unclear or doesn't match any known patterns, return an empty filters object and explain why in the explanation field.`,
213+
`,
199214
});
200215

201216
// Add debugging to see what the AI returned
202217
logger.info("AI filter response", {
203218
text,
204219
environmentId: environment.id,
205220
result: result.experimental_output,
206-
filters: result.experimental_output.filters,
207221
});
208222

223+
if (!result.experimental_output.success) {
224+
return {
225+
success: false,
226+
error: result.experimental_output.error,
227+
};
228+
}
229+
209230
// Validate the filters against the schema to catch any issues
210231
const validationResult = TaskRunListSearchFilters.omit({ environments: true }).safeParse(
211232
result.experimental_output.filters
@@ -219,8 +240,6 @@ Return only the filters that are explicitly mentioned or can be reasonably infer
219240
return {
220241
success: false,
221242
error: "AI response validation failed",
222-
suggestions:
223-
"The AI response contained invalid filter values. Try rephrasing your request.",
224243
};
225244
}
226245

@@ -241,17 +260,13 @@ Return only the filters that are explicitly mentioned or can be reasonably infer
241260
if (error instanceof Error && error.message.includes("schema")) {
242261
return {
243262
success: false,
244-
error: "AI response format error",
245-
suggestions:
246-
"The AI response didn't match the expected format. Try rephrasing your request or being more specific about what you want to filter.",
263+
error: error.message,
247264
};
248265
}
249266

250267
return {
251268
success: false,
252-
error: "Failed to process AI filter request",
253-
suggestions:
254-
"Try being more specific about what you want to filter. Use common terms like 'failed runs', 'last 7 days', 'with tag X'. Check that your description is clear and unambiguous",
269+
error: error instanceof Error ? error.message : String(error),
255270
};
256271
}
257272
}

apps/webapp/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
"@radix-ui/react-dialog": "^1.0.3",
8686
"@radix-ui/react-label": "^2.0.1",
8787
"@radix-ui/react-popover": "^1.0.5",
88+
"@radix-ui/react-portal": "^1.1.9",
8889
"@radix-ui/react-radio-group": "^1.1.3",
8990
"@radix-ui/react-select": "^1.2.1",
9091
"@radix-ui/react-slider": "^1.1.2",

pnpm-lock.yaml

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)