Skip to content

Commit b5a1d17

Browse files
feat(ui): improve scheduled task controls (#378)
## Summary - add client-side search to the scheduled tasks agent picker - close and reset the picker on selection, outside click, or Escape - add a visible label to the prompt field in the edit schedule modal ## Test plan - [x] Run `npm run typecheck` - [x] Lint the updated schedule components - [x] Run `npm run test:run -- hooks/use-agents.test.tsx` - [x] Verify the searchable picker and prompt label locally Made with [Cursor](https://cursor.com) <!-- greptile_comment --> <h3>Greptile Summary</h3> This PR improves the scheduled-tasks UI in two ways: it replaces the Radix `Select` with a custom searchable dropdown for the scope picker, and it adds an explicit "Prompt" label to the edit-schedule textarea. - **Custom agent picker** — adds client-side filtering, Escape / outside-click dismissal, and focus-return to the trigger; removes the Radix `Select` dependency from this component. - **Prompt label** — wraps the textarea in a `<label>` element with a visible "Prompt" span, matching the existing `ScheduleNameInput` pattern and improving accessibility. - **Known gap** — the trigger button's `onClick` handler does not call `setQuery('')` when closing, so a stale search term persists if the user toggles the dropdown closed via the button rather than via Escape, outside-click, or item selection. <details><summary><h3>Confidence Score: 4/5</h3></summary> Safe to merge after fixing the stale-query issue on trigger-button close. The custom dropdown correctly resets the search query on every close path (Escape, outside-click, item selection) except one: clicking the trigger button a second time only toggles isOpen without calling setQuery. A user who closes via the button and reopens sees a filtered list based on their previous search term with no explanation. The fix is a one-liner. Everything else is correct. **Files Needing Attention:** agentex-ui/components/scheduled-tasks/scheduled-tasks-page.tsx — the trigger button onClick handler needs to clear the search query when closing. </details> <details><summary><h3>Important Files Changed</h3></summary> | Filename | Overview | |----------|----------| | agentex-ui/components/scheduled-tasks/scheduled-tasks-page.tsx | Replaces Radix Select with a custom searchable dropdown; correctly handles outside-click, Escape, and item-selection close paths, but omits query reset when the trigger button toggles the dropdown closed. | | agentex-ui/components/scheduled-tasks/schedule-modals.tsx | Wraps the prompt textarea in a labeled `<label>` element, matching the existing `ScheduleNameInput` pattern; straightforward and safe change. | </details> <details><summary><h3>Flowchart</h3></summary> ```mermaid %%{init: {'theme': 'neutral'}}%% flowchart TD A([User clicks trigger button]) --> B{isOpen?} B -- false --> C[setIsOpen true\nautoFocus search input] B -- true --> D[setIsOpen false\n query NOT cleared] C --> E[Dropdown renders] E --> F{User action} F -- Types in search --> G[filteredAgents updates] F -- Selects item --> H[selectAgent or selectScope\nsetIsOpen false\nsetQuery empty\ntriggerRef.focus] F -- Presses Escape --> I[closeOnEscape handler\nsetIsOpen false\nsetQuery empty\ntriggerRef.focus] F -- Clicks outside --> J[closeOnOutsideClick handler\nsetIsOpen false\nsetQuery empty] F -- Clicks trigger again --> D D --> K([Dropdown closes, stale query remains]) ``` </details> <sub>Reviews (2): Last reviewed commit: ["fix(ui): handle schedule picker loading ..."](14bf201) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=47575437)</sub> <!-- /greptile_comment --> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2c4f061 commit b5a1d17

2 files changed

Lines changed: 134 additions & 46 deletions

File tree

agentex-ui/components/scheduled-tasks/schedule-modals.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,14 @@ export function EditScheduleModal({
137137
<>
138138
<BasicModal title="Edit scheduled task" onClose={onClose}>
139139
<ScheduleNameInput name={name} setName={setName} />
140-
<textarea
141-
value={prompt}
142-
onChange={event => setPrompt(event.target.value)}
143-
className="border-input bg-background min-h-24 rounded-md border p-3 text-sm"
144-
/>
140+
<label className="flex flex-col gap-1 text-sm">
141+
<span className="font-medium">Prompt</span>
142+
<textarea
143+
value={prompt}
144+
onChange={event => setPrompt(event.target.value)}
145+
className="border-input bg-background min-h-24 rounded-md border p-3 text-sm"
146+
/>
147+
</label>
145148
<CadencePicker
146149
cadence={cadence}
147150
onChange={setCadence}

agentex-ui/components/scheduled-tasks/scheduled-tasks-page.tsx

Lines changed: 126 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
'use client';
22

3-
import { useMemo, useState } from 'react';
3+
import { useEffect, useMemo, useRef, useState } from 'react';
44

5-
import { Bot, CalendarClock, Loader2 } from 'lucide-react';
5+
import { Bot, CalendarClock, ChevronDown, Loader2, Search } from 'lucide-react';
66

77
import { useAgentexClient } from '@/components/providers';
88
import { AllSchedulesList } from '@/components/scheduled-tasks/all-schedules-list';
@@ -17,13 +17,6 @@ import {
1717
sortScheduleItems,
1818
} from '@/components/scheduled-tasks/schedule-helpers';
1919
import { UpcomingScheduleList } from '@/components/scheduled-tasks/upcoming-schedule-list';
20-
import {
21-
Select,
22-
SelectContent,
23-
SelectItem,
24-
SelectTrigger,
25-
SelectValue,
26-
} from '@/components/ui/select';
2720
import { useAgentByName } from '@/hooks/use-agent-by-name';
2821
import {
2922
SCHEDULE_LIST_LIMIT,
@@ -217,43 +210,135 @@ function ScheduleScopeSelector({
217210
onChange: (scope: ScheduleScope) => void;
218211
onSelectAgent: (agentName: string) => void;
219212
}) {
220-
const value =
221-
scope === ScheduleScope.ALL ? ScheduleScope.ALL : selectedAgent?.name;
213+
const [isOpen, setIsOpen] = useState(false);
214+
const [query, setQuery] = useState('');
215+
const containerRef = useRef<HTMLDivElement>(null);
216+
const triggerRef = useRef<HTMLButtonElement>(null);
217+
const filteredAgents = useMemo(() => {
218+
const normalizedQuery = query.trim().toLowerCase();
219+
if (!normalizedQuery) return agents;
220+
return agents.filter(agent =>
221+
agent.name.toLowerCase().includes(normalizedQuery)
222+
);
223+
}, [agents, query]);
224+
225+
useEffect(() => {
226+
if (!isOpen) return;
227+
228+
const closeOnOutsideClick = (event: MouseEvent) => {
229+
if (!containerRef.current?.contains(event.target as Node)) {
230+
setIsOpen(false);
231+
setQuery('');
232+
}
233+
};
234+
const closeOnEscape = (event: KeyboardEvent) => {
235+
if (event.key === 'Escape') {
236+
setIsOpen(false);
237+
setQuery('');
238+
triggerRef.current?.focus();
239+
}
240+
};
241+
242+
document.addEventListener('mousedown', closeOnOutsideClick);
243+
document.addEventListener('keydown', closeOnEscape);
244+
return () => {
245+
document.removeEventListener('mousedown', closeOnOutsideClick);
246+
document.removeEventListener('keydown', closeOnEscape);
247+
};
248+
}, [isOpen]);
249+
250+
const selectScope = (nextScope: ScheduleScope) => {
251+
onChange(nextScope);
252+
setIsOpen(false);
253+
setQuery('');
254+
triggerRef.current?.focus();
255+
};
256+
257+
const selectAgent = (agentName: string) => {
258+
onSelectAgent(agentName);
259+
setIsOpen(false);
260+
setQuery('');
261+
triggerRef.current?.focus();
262+
};
222263

223264
return (
224-
<Select
225-
{...(value ? { value } : {})}
226-
onValueChange={nextValue => {
227-
if (nextValue === ScheduleScope.ALL) {
228-
onChange(ScheduleScope.ALL);
229-
return;
230-
}
231-
onSelectAgent(nextValue);
232-
}}
233-
>
234-
<SelectTrigger
235-
className="max-w-80 min-w-64"
265+
<div ref={containerRef} className="relative max-w-80 min-w-64">
266+
<button
267+
ref={triggerRef}
268+
type="button"
269+
className="border-input focus-visible:border-primary-foreground focus-visible:ring-primary-foreground/50 flex h-9 w-full items-center justify-between gap-2 rounded-full border bg-transparent px-3 py-2 text-sm shadow-xs outline-none focus-visible:ring-[3px]"
236270
aria-label="Schedule agent scope"
271+
aria-haspopup="listbox"
272+
aria-expanded={isOpen}
273+
onClick={() => setIsOpen(current => !current)}
237274
>
238-
<SelectValue placeholder="Select an agent" />
239-
</SelectTrigger>
240-
<SelectContent>
241-
<SelectItem value={ScheduleScope.ALL}>
242-
<span className="flex items-center gap-2">
243-
<CalendarClock className="size-4" />
244-
All agents
275+
<span className="flex min-w-0 items-center gap-2">
276+
{scope === ScheduleScope.ALL ? (
277+
<CalendarClock className="size-4 shrink-0" />
278+
) : (
279+
<Bot className="size-4 shrink-0" />
280+
)}
281+
<span className="truncate">
282+
{scope === ScheduleScope.ALL
283+
? 'All agents'
284+
: (selectedAgent?.name ?? 'Select an agent')}
245285
</span>
246-
</SelectItem>
247-
{agents.map(agent => (
248-
<SelectItem key={agent.id} value={agent.name}>
249-
<span className="flex items-center gap-2">
250-
<Bot className="size-4" />
251-
{agent.name}
252-
</span>
253-
</SelectItem>
254-
))}
255-
</SelectContent>
256-
</Select>
286+
</span>
287+
<ChevronDown className="text-muted-foreground size-4 shrink-0" />
288+
</button>
289+
{isOpen && (
290+
<div className="bg-popover text-popover-foreground absolute right-0 z-50 mt-1 w-full min-w-72 rounded-md border p-1 shadow-md">
291+
<div className="relative p-1">
292+
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2" />
293+
<input
294+
autoFocus
295+
value={query}
296+
onChange={event => setQuery(event.target.value)}
297+
placeholder="Search agents"
298+
aria-label="Search agents"
299+
className="border-input bg-background focus-visible:border-primary-foreground focus-visible:ring-primary-foreground/50 h-9 w-full rounded-md border pr-3 pl-9 text-sm outline-none focus-visible:ring-[3px]"
300+
/>
301+
</div>
302+
<div
303+
className="max-h-64 overflow-y-auto p-1"
304+
role="listbox"
305+
aria-label="Schedule agent scope"
306+
>
307+
<button
308+
type="button"
309+
role="option"
310+
aria-selected={scope === ScheduleScope.ALL}
311+
onClick={() => selectScope(ScheduleScope.ALL)}
312+
className="hover:bg-muted focus:bg-muted flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none"
313+
>
314+
<CalendarClock className="size-4" />
315+
All agents
316+
</button>
317+
{filteredAgents.map(agent => (
318+
<button
319+
key={agent.id}
320+
type="button"
321+
role="option"
322+
aria-selected={
323+
scope === ScheduleScope.CURRENT &&
324+
selectedAgent?.id === agent.id
325+
}
326+
onClick={() => selectAgent(agent.name)}
327+
className="hover:bg-muted focus:bg-muted flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none"
328+
>
329+
<Bot className="size-4" />
330+
<span className="truncate">{agent.name}</span>
331+
</button>
332+
))}
333+
{query.trim() && filteredAgents.length === 0 && (
334+
<p className="text-muted-foreground px-2 py-3 text-center text-sm">
335+
No agents found
336+
</p>
337+
)}
338+
</div>
339+
</div>
340+
)}
341+
</div>
257342
);
258343
}
259344

0 commit comments

Comments
 (0)