diff --git a/.changeset/google-sans-default-font.md b/.changeset/google-sans-default-font.md new file mode 100644 index 000000000..f323f1262 --- /dev/null +++ b/.changeset/google-sans-default-font.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge-ui": patch +--- + +Use Google Sans as the default `trueforge` theme font and load it from Google Fonts when styles are injected. diff --git a/.changeset/schedule-runs-ui.md b/.changeset/schedule-runs-ui.md new file mode 100644 index 000000000..772881f52 --- /dev/null +++ b/.changeset/schedule-runs-ui.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge-ui": minor +--- + +Wire schedule test runs and show last five run status chips on the schedules table. Bump `@truefoundry/assistant-ui-runtime` to `0.1.25`. diff --git a/.changeset/schedules-side-drawer.md b/.changeset/schedules-side-drawer.md new file mode 100644 index 000000000..bca28f0e8 --- /dev/null +++ b/.changeset/schedules-side-drawer.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge-ui": minor +--- + +Add global Schedules page at `/schedules` with listing, popover-based filters, and create/edit drawer wired to the schedule API. New schedules save as paused, open a Test Schedule review with MCP connect status, and support Activate Anyway. List schedules uses server token pagination and multi-agent filters. Agents Library shows a Schedules count column (warning when any are paused) loaded via a batched list for on-screen agents. Add Table primitives with client-side and token pagination plus portal DropdownMenu so row actions are not clipped by overflow. Export a reusable popover select with single- and multi-select modes. diff --git a/.gitignore b/.gitignore index 6ba4f9250..a90e1c82d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ *.tgz +!vendor/*.tgz .DS_Store *.log coverage/ diff --git a/packages/frontend/package.json b/packages/frontend/package.json index d250f424d..b4b9a7ff7 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -26,7 +26,7 @@ "@assistant-ui/core": "0.2.22", "@assistant-ui/react": "0.14.27", "@assistant-ui/store": "0.2.21", - "@truefoundry/assistant-ui-runtime": "0.1.23", + "@truefoundry/assistant-ui-runtime": "0.1.25", "@truefoundry/trueforge-sdk": "workspace:*", "@truefoundry/trueforge-ui": "workspace:*", "monaco-editor": "^0.52.0", diff --git a/packages/trueforge-ui/docs/compatibility.md b/packages/trueforge-ui/docs/compatibility.md index 7703150d2..935e92210 100644 --- a/packages/trueforge-ui/docs/compatibility.md +++ b/packages/trueforge-ui/docs/compatibility.md @@ -21,7 +21,7 @@ Install once at the app root. | ----------------------------------- | ---------- | | `@assistant-ui/core` | `^0.2.22` | | `@assistant-ui/react` | `^0.14.24` | -| `@truefoundry/assistant-ui-runtime` | `0.1.22` | +| `@truefoundry/assistant-ui-runtime` | `0.1.25` | | `truefoundry-gateway-sdk` | `^0.4.0` | | `lucide-react` | `^0.562.0` | diff --git a/packages/trueforge-ui/package.json b/packages/trueforge-ui/package.json index 0b6fe2d1f..a5f8c9592 100644 --- a/packages/trueforge-ui/package.json +++ b/packages/trueforge-ui/package.json @@ -95,7 +95,7 @@ "@openuidev/react-headless": "^0.9.4", "@openuidev/react-lang": "^0.2.9", "@openuidev/react-ui": "^0.13.2", - "@truefoundry/assistant-ui-runtime": "0.1.23", + "@truefoundry/assistant-ui-runtime": "0.1.25", "@truefoundry/trueforge-sdk": "workspace:*", "chart.js": "^4.5.1", "clsx": "^2.1.1", diff --git a/packages/trueforge-ui/src/atoms/AgentHistoryFilterButton.tsx b/packages/trueforge-ui/src/atoms/AgentHistoryFilterButton.tsx index bf86cf759..36d91b551 100644 --- a/packages/trueforge-ui/src/atoms/AgentHistoryFilterButton.tsx +++ b/packages/trueforge-ui/src/atoms/AgentHistoryFilterButton.tsx @@ -9,17 +9,13 @@ import { libraryAgentId, useOptionalShellMode } from '../server/ShellModeContext import { auiButtonClass } from './lib/buttonClasses.js'; import { cn } from './lib/cn.js'; import { useCompactLayout } from './lib/CompactLayoutContext.js'; +import { themePortalRoot } from './lib/themePortalRoot.js'; import { useIsMobile } from './lib/useIsMobile.js'; import { useSearchAgentsList } from './lib/useSearchAgentsList.js'; import { BottomSheet } from './primitives/BottomSheet.js'; import { DropdownMenuItem } from './primitives/DropdownMenu.js'; import SearchInput from './primitives/SearchInput.js'; -/** Keep portaled chrome under ThemeProvider so preset/custom CSS vars still apply. */ -function themePortalRoot(from: HTMLElement | null): HTMLElement { - return from?.closest('.aui-theme-root') ?? document.body; -} - /** * Funnel popover for filtering chat history by agent id. * Desktop: fixed menu to the right of the funnel. Mobile/compact: bottom sheet. diff --git a/packages/trueforge-ui/src/atoms/AgentsLibrary.tsx b/packages/trueforge-ui/src/atoms/AgentsLibrary.tsx index 299bde754..8364d5e82 100644 --- a/packages/trueforge-ui/src/atoms/AgentsLibrary.tsx +++ b/packages/trueforge-ui/src/atoms/AgentsLibrary.tsx @@ -4,24 +4,36 @@ import { useCallback, useEffect, useState } from 'react'; import { useSessionShareSearch } from '../hooks/useSessionShareSearch.js'; import { Icon } from '../icons/Icon.js'; -import { useOptionalAgentSessionsServer } from '../server/ServerContext.js'; +import { useOptionalAgentSessionsServer, useOptionalScheduleServer } from '../server/ServerContext.js'; import { libraryAgentId, useShellMode } from '../server/ShellModeContext.js'; -import type { AgentLibraryEntry, AgentSpec } from '../server/types.js'; +import type { AgentLibraryEntry, AgentSpec, Schedule } from '../server/types.js'; import { useSlot } from '../theme/SlotsProvider.js'; +import { writeScheduleShareSearch } from '../utils/scheduleShareUrl.js'; +import { writeSessionShareSearch } from '../utils/sessionShareUrl.js'; import { auiButtonClass } from './lib/buttonClasses.js'; import { cn } from './lib/cn.js'; +import { mountName } from './lib/mountName.js'; import { useSearchAgentsList } from './lib/useSearchAgentsList.js'; +import { DropdownMenu, DropdownMenuItem } from './primitives/DropdownMenu.js'; import SearchInput from './primitives/SearchInput.js'; import { Skeleton } from './primitives/Skeleton.js'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './primitives/Table.js'; import { Tooltip } from './primitives/Tooltip.js'; export type AgentsLibraryProps = { onSelectAgent?: (agentName: string) => void; }; +export type AgentScheduleSummary = { + count: number; + hasPaused: boolean; +}; + export type AgentLibraryRowProps = { agent: AgentLibraryEntry; showEdit: boolean; + scheduleSummary?: AgentScheduleSummary | null; + onOpenSchedules?: () => void; onOpen?: () => void; onTry: () => void; onEdit: () => void; @@ -33,113 +45,229 @@ function displayModelLabel(modelName: string): string { return slash >= 0 ? modelName.slice(slash + 1) : modelName; } -export function AgentLibraryRow({ agent, showEdit, onOpen, onTry, onEdit }: AgentLibraryRowProps) { +function AgentSchedulesEmptyState({ agentName, onOpen }: { agentName: string; onOpen?: () => void }) { + return ( + + ); +} + +function AgentSchedulesBadge({ + summary, + agentName, + onOpen, +}: { + summary: AgentScheduleSummary; + agentName: string; + onOpen?: () => void; +}) { + const warning = summary.hasPaused; + return ( + + ); +} + +export function AgentLibraryRow({ + agent, + showEdit, + scheduleSummary, + onOpenSchedules, + onOpen, + onTry, + onEdit, +}: AgentLibraryRowProps) { const spec = agent.agentSpec; const modelName = spec?.model.name; const skillsCount = spec?.skills?.length ?? 0; const mcpCount = spec?.mcpServers?.length ?? 0; - const skillNames = (spec?.skills ?? []).map(s => (s as { name?: string }).name).filter(Boolean); - const mcpNames = (spec?.mcpServers ?? []).map(m => (m as { name?: string }).name).filter(Boolean); + const skillNames = (spec?.skills ?? []).map(mountName).filter((name: string | null): name is string => name != null); + const mcpNames = (spec?.mcpServers ?? []) + .map(mountName) + .filter((name: string | null): name is string => name != null); const connectorsTitle = mcpNames.length ? `Connectors: ${mcpNames.join(', ')}` : `${mcpCount} connectors`; const skillsTitle = skillNames.length ? `Skills: ${skillNames.join(', ')}` : `${skillsCount} skills`; + const hasNoSchedules = scheduleSummary != null && scheduleSummary.count === 0; + return ( -
{ - if (onOpen == null || event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) { - return; - } - event.preventDefault(); - onOpen(); - }} - > - - - - - {agent.name} - - {spec != null ? ( - - {modelName ? ( - - - {displayModelLabel(modelName)} - - ) : null} - - - - {mcpCount} - - - - - - {skillsCount} + + +
+ + + +
+ {onOpen == null ? ( + {agent.name} + ) : ( + + )} + {spec != null ? ( +
+ {modelName ? ( + + + {displayModelLabel(modelName)} + + ) : null} + {mcpCount > 0 ? ( + + + + {mcpCount} + + + ) : null} + {skillsCount > 0 ? ( + + + + {skillsCount} + + + ) : null} +
+ ) : null} +
+
+
+ {scheduleSummary !== undefined ? ( + + {scheduleSummary != null && scheduleSummary.count > 0 ? ( + + ) : scheduleSummary != null ? ( + + ) : ( + + — -
-
+ )} + ) : null} - - {showEdit ? ( + +
- ) : null} - - -
+ {showEdit ? ( + + + + } + > + + + Edit + + + ) : null} +
+ + ); } +function summarizeSchedulesByAgent(schedules: readonly Schedule[]): Map { + const map = new Map(); + for (const schedule of schedules) { + const id = schedule.agentId; + const prev = map.get(id) ?? { count: 0, hasPaused: false }; + const next = { + count: prev.count + 1, + hasPaused: prev.hasPaused || schedule.status === 'paused', + }; + map.set(id, next); + if (schedule.agentName != null && schedule.agentName !== '' && schedule.agentName !== id) { + map.set(schedule.agentName, next); + } + } + return map; +} + +async function listAllSchedulesForAgents({ + listSchedules, + agentIds, +}: { + listSchedules: NonNullable>['listSchedules']; + agentIds: string[]; +}): Promise { + if (agentIds.length === 0) return []; + const rows: Schedule[] = []; + let pageToken: string | undefined; + do { + const page = await listSchedules({ + agentIds, + limit: 25, + ...(pageToken === undefined ? {} : { pageToken }), + }); + rows.push(...page.data); + pageToken = page.nextPageToken; + } while (pageToken != null && pageToken !== ''); + return rows; +} + export function AgentsLibrary({ onSelectAgent }: AgentsLibraryProps) { const shell = useShellMode(); const { updateShareSearch } = useSessionShareSearch(); const sessionsServer = useOptionalAgentSessionsServer(); + const scheduleServer = useOptionalScheduleServer(); const SlottedAgentLibraryRow = useSlot('AgentLibraryRow'); const [query, setQuery] = useState(''); + const [scheduleByAgent, setScheduleByAgent] = useState | null>(null); const open = shell.libraryOpen; const canEdit = shell.isComposerEnabled === true; const agentsListEpoch = shell.agentsListEpoch; + const showSchedulesColumn = scheduleServer != null; useEffect(() => { if (!open) setQuery(''); @@ -168,6 +296,41 @@ export function AgentsLibrary({ onSelectAgent }: AgentsLibraryProps) { refreshKey: agentsListEpoch, }); + useEffect(() => { + if (!open || scheduleServer == null || agents.length === 0) { + setScheduleByAgent(null); + return; + } + let cancelled = false; + setScheduleByAgent(null); + const agentIds = agents.map(libraryAgentId); + void listAllSchedulesForAgents({ listSchedules: scheduleServer.listSchedules, agentIds }) + .then(rows => { + if (!cancelled) setScheduleByAgent(summarizeSchedulesByAgent(rows)); + }) + .catch(() => { + if (!cancelled) setScheduleByAgent(null); + }); + return () => { + cancelled = true; + }; + }, [agents, open, scheduleServer, agentsListEpoch]); + + const openSchedulesForAgent = (agentId: string) => { + const url = new URL(window.location.href); + writeSessionShareSearch(url.searchParams, { + sessionId: null, + agentId: null, + tab: null, + view: null, + timeRange: null, + }); + writeScheduleShareSearch(url.searchParams, { agent: agentId, status: null, q: null }); + window.history.replaceState(window.history.state, '', url); + shell.setLibraryOpen(false); + shell.setSchedulesOpen(true); + }; + const handleTry = (agent: AgentLibraryEntry) => { closeLibrary(); onSelectAgent?.(agent.name); @@ -193,34 +356,23 @@ export function AgentsLibrary({ onSelectAgent }: AgentsLibraryProps) { return (
-
- -

Agents Library

-
- -
-
+
+
+ +

Agents Library

+
+
{isSearching ? ( -

+

Searching…

) : null}
-
+
+ +
+
{isInitialLoading ? (
{Array.from({ length: 6 }, (_, i) => ( @@ -237,36 +389,63 @@ export function AgentsLibrary({ onSelectAgent }: AgentsLibraryProps) {

) : ( <> - {agents.map(agent => { - const agentSpec = agent.agentSpec; - const agentId = agent.agentId; - const showEdit = canEdit && agentSpec != null; - return ( - { - updateShareSearch({ - agentId, - tab: 'overview', - sessionId: null, - view: null, - timeRange: null, - }); - shell.openLibraryAgent(agentId); - }, - } - : {})} - onTry={() => handleTry(agent)} - onEdit={() => { - if (agentSpec != null) handleEdit(agent, agentSpec); - }} - /> - ); - })} +
+ + + + Agent name + {showSchedulesColumn ? Schedules : null} + + Actions + + + + + {agents.map(agent => { + const agentSpec = agent.agentSpec; + const agentId = agent.agentId; + const id = libraryAgentId(agent); + const showEdit = canEdit && agentSpec != null; + const summary = showSchedulesColumn + ? (scheduleByAgent?.get(id) ?? + scheduleByAgent?.get(agent.name) ?? + (scheduleByAgent == null ? null : { count: 0, hasPaused: false })) + : undefined; + return ( + openSchedulesForAgent(id), + } + : {})} + {...(sessionsServer != null && agentId != null + ? { + onOpen: () => { + updateShareSearch({ + agentId, + tab: 'overview', + sessionId: null, + view: null, + timeRange: null, + }); + shell.openLibraryAgent(agentId); + }, + } + : {})} + onTry={() => handleTry(agent)} + onEdit={() => { + if (agentSpec != null) handleEdit(agent, agentSpec); + }} + /> + ); + })} + +
+
{hasMore ? (
{loadingMore ? ( diff --git a/packages/trueforge-ui/src/atoms/AgentsLibraryButton.tsx b/packages/trueforge-ui/src/atoms/AgentsLibraryButton.tsx index 77ee2f9ec..72a105cae 100644 --- a/packages/trueforge-ui/src/atoms/AgentsLibraryButton.tsx +++ b/packages/trueforge-ui/src/atoms/AgentsLibraryButton.tsx @@ -60,7 +60,7 @@ export function AgentsLibraryButton({ className, compact = false }: AgentsLibrar })} onClick={() => shell?.setLibraryOpen(true)} > - + {!compact ? ( <> diff --git a/packages/trueforge-ui/src/atoms/SchedulesButton.tsx b/packages/trueforge-ui/src/atoms/SchedulesButton.tsx new file mode 100644 index 000000000..d89cf651d --- /dev/null +++ b/packages/trueforge-ui/src/atoms/SchedulesButton.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { Icon } from '../icons/Icon.js'; +import { useOptionalScheduleServer } from '../server/ServerContext.js'; +import { useOptionalShellMode } from '../server/ShellModeContext.js'; +import { auiButtonClass } from './lib/buttonClasses.js'; +import { cn } from './lib/cn.js'; + +export type SchedulesButtonProps = { + className?: string; + compact?: boolean; +}; + +export function SchedulesButton({ className, compact = false }: SchedulesButtonProps) { + const shell = useOptionalShellMode(); + const scheduleServer = useOptionalScheduleServer(); + + const enabled = scheduleServer != null && shell != null; + const open = shell?.schedulesOpen === true; + + if (!enabled) return null; + + return ( +
+ +
+ ); +} + +declare module '../theme/SlotsProvider.js' { + interface AtomSlots { + SchedulesButton: typeof SchedulesButton; + } +} diff --git a/packages/trueforge-ui/src/atoms/SessionsBrowserButton.tsx b/packages/trueforge-ui/src/atoms/SessionsBrowserButton.tsx index 64be5ea56..c6c527b4b 100644 --- a/packages/trueforge-ui/src/atoms/SessionsBrowserButton.tsx +++ b/packages/trueforge-ui/src/atoms/SessionsBrowserButton.tsx @@ -49,7 +49,7 @@ export function SessionsBrowserButton({ className, compact = false }: SessionsBr shell.setSessionsOpen(true); }} > - + {!compact ? ( <> Sessions diff --git a/packages/trueforge-ui/src/atoms/agent-details/AgentSessionsFilters.tsx b/packages/trueforge-ui/src/atoms/agent-details/AgentSessionsFilters.tsx index 440f67dad..940b33f97 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/AgentSessionsFilters.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/AgentSessionsFilters.tsx @@ -2,9 +2,10 @@ import { useEffect, useRef, useState } from 'react'; +import { Icon } from '../../icons/Icon.js'; import { useOptionalServer } from '../../server/ServerContext.js'; import { useOptionalShellMode } from '../../server/ShellModeContext.js'; -import type { AgentBuilderServer, AgentLibraryEntry } from '../../server/types.js'; +import type { AgentLibraryEntry } from '../../server/types.js'; import { SESSION_CUSTOM_RANGE_MAX_DAYS, type SessionTimeRange } from '../../utils/sessionShareUrl.js'; import { formatSessionTimePresetLabel, @@ -13,17 +14,12 @@ import { SESSION_TIME_PRESETS, toDateTimeLocalValue, } from '../../utils/sessionTimePresets.js'; +import { auiButtonClass } from '../lib/buttonClasses.js'; import { cn } from '../lib/cn.js'; -import { SEARCH_AGENTS_PAGE_SIZE } from '../lib/useSearchAgentsList.js'; - -async function searchAllAgents( - server: Pick, - offset = 0, -): Promise { - const rows = await server.searchAgents({ limit: SEARCH_AGENTS_PAGE_SIZE, offset }); - if (rows.length < SEARCH_AGENTS_PAGE_SIZE) return rows; - return [...rows, ...(await searchAllAgents(server, offset + rows.length))]; -} +import { auiInputClass } from '../lib/inputClasses.js'; +import { auiSelectMenuClass, auiSelectOptionClass, auiSelectTriggerClass } from '../lib/selectClasses.js'; +import { searchAllAgents } from '../lib/useSearchAgentsList.js'; +import { PopoverSelect } from '../primitives/PopoverSelect.js'; export type AgentSessionsFiltersProps = { agentId: string | null; @@ -111,32 +107,24 @@ export function AgentSessionsFilters({ return (
{showAgentFilter ? ( - + ({ value: agent.agentId ?? agent.name, label: agent.name })), + ]} + onValueChange={value => onAgentChange(value.length === 0 ? null : value)} + /> ) : null}
{menuOpen ? ( -
+
{customPickerOpen ? (
Select Time Range
@@ -157,7 +146,7 @@ export function AgentSessionsFilters({ setFromValue(event.target.value)} /> @@ -167,7 +156,7 @@ export function AgentSessionsFilters({ setToValue(event.target.value)} /> @@ -175,54 +164,58 @@ export function AgentSessionsFilters({

Timezone: {formatTimezoneOffsetLabel()}

-
) : null} -
+
{showCustomTimeRange ? ( ) : null} - {SESSION_TIME_PRESETS.map(preset => ( - - ))} + {SESSION_TIME_PRESETS.map(preset => { + const selected = timeRange.timeWindowMs === preset.windowMs; + return ( + + ); + })}
) : null} diff --git a/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx b/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx index 5837eaa9c..32fc479a2 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/SessionsPage.tsx @@ -3,6 +3,7 @@ import { Suspense, useEffect, useMemo, useState } from 'react'; import { useSessionShareSearch } from '../../hooks/useSessionShareSearch.js'; +import { Icon } from '../../icons/Icon.js'; import { useOptionalAgentSessionsServer } from '../../server/ServerContext.js'; import { useSlot } from '../../theme/SlotsProvider.js'; import { @@ -52,20 +53,25 @@ export function SessionsPage() { return (
-
-

Agent Sessions

- { - setAgentFilter(nextAgentId); - updateShareSearch({ agentId: nextAgentId, sessionId: null, view: 'sessions' }); - }} - onTimeRangeChange={nextRange => { - setTimeRange(nextRange); - updateShareSearch({ timeRange: nextRange, sessionId: null, view: 'sessions' }); - }} - /> +
+
+ +

Agent Sessions

+
+
+ { + setAgentFilter(nextAgentId); + updateShareSearch({ agentId: nextAgentId, sessionId: null, view: 'sessions' }); + }} + onTimeRangeChange={nextRange => { + setTimeRange(nextRange); + updateShareSearch({ timeRange: nextRange, sessionId: null, view: 'sessions' }); + }} + /> +
{sessionsServer == null ? ( diff --git a/packages/trueforge-ui/src/atoms/draft/DraftSpecPreferenceBridge.tsx b/packages/trueforge-ui/src/atoms/draft/DraftSpecPreferenceBridge.tsx index 74dd0eac8..20fdf47a2 100644 --- a/packages/trueforge-ui/src/atoms/draft/DraftSpecPreferenceBridge.tsx +++ b/packages/trueforge-ui/src/atoms/draft/DraftSpecPreferenceBridge.tsx @@ -7,14 +7,10 @@ import { withCapabilitiesSandbox } from '../../server/draftSpecPreferences.js'; import { useServerCapabilities } from '../../server/ServerContext.js'; import { useShellMode } from '../../server/ShellModeContext.js'; import type { AgentSkill, AgentSpec, ConnectorState, ModelSelection } from '../../server/types.js'; +import { mountName } from '../lib/mountName.js'; import { useDraftCatalog } from './DraftCatalogProvider.js'; import { modelPatchWithReasoningEffort } from './reasoningEffort.js'; -function mountName(mount: object): string | null { - const name = Reflect.get(mount, 'name'); - return typeof name === 'string' ? name : null; -} - function filterMounts(mounts: T[] | undefined, availableNames: Set): T[] | undefined { if (mounts === undefined) return undefined; const filtered = mounts.filter(mount => { diff --git a/packages/trueforge-ui/src/atoms/lib/mountName.ts b/packages/trueforge-ui/src/atoms/lib/mountName.ts new file mode 100644 index 000000000..d65457db6 --- /dev/null +++ b/packages/trueforge-ui/src/atoms/lib/mountName.ts @@ -0,0 +1,3 @@ +export function mountName(mount: object): string | null { + return 'name' in mount && typeof mount.name === 'string' ? mount.name : null; +} diff --git a/packages/trueforge-ui/src/atoms/lib/selectClasses.ts b/packages/trueforge-ui/src/atoms/lib/selectClasses.ts new file mode 100644 index 000000000..ae392c4af --- /dev/null +++ b/packages/trueforge-ui/src/atoms/lib/selectClasses.ts @@ -0,0 +1,36 @@ +import { cn } from './cn.js'; +import { auiInputClass } from './inputClasses.js'; + +/** + * Shared dropdown chrome. Every select-like surface (PopoverSelect, the session + * time-range popover) composes these so triggers, menus, and option rows stay + * visually identical; callers pass `className` for width and placement only. + */ +export function auiSelectTriggerClass(className?: string): string { + return auiInputClass(cn('flex h-9 cursor-pointer items-center justify-between gap-2 pr-2 text-left', className)); +} + +/** Prefixed chip trigger: static label | selected chip + chevron. */ +export function auiSelectPrefixTriggerClass(className?: string): string { + return cn( + 'inline-flex h-9 w-full cursor-pointer items-center rounded-md border border-border bg-input-box-bg text-left text-sm text-text-primary outline-none', + 'focus-visible:ring-2 focus-visible:ring-focus-ring/40 disabled:cursor-not-allowed disabled:opacity-50', + className, + ); +} + +export function auiSelectMenuClass(className?: string): string { + return cn( + 'bg-card-bg text-text-primary absolute top-full z-50 mt-1 max-h-64 overflow-y-auto rounded-md border border-border p-1 shadow-md', + className, + ); +} + +export function auiSelectOptionClass(className?: string): string { + return cn( + 'text-text-primary flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors', + 'hover:bg-ghost-button-hover focus:bg-dropdown-selected-item-bg focus:text-dropdown-selected-item-text', + 'disabled:pointer-events-none disabled:opacity-50', + className, + ); +} diff --git a/packages/trueforge-ui/src/atoms/lib/themePortalRoot.ts b/packages/trueforge-ui/src/atoms/lib/themePortalRoot.ts new file mode 100644 index 000000000..dd386dc48 --- /dev/null +++ b/packages/trueforge-ui/src/atoms/lib/themePortalRoot.ts @@ -0,0 +1,6 @@ +/** Keep portaled chrome inside the active dialog/theme boundary. */ +export function themePortalRoot(from: HTMLElement | null): HTMLElement { + const dialog = from?.closest('dialog'); + if (dialog instanceof HTMLElement) return dialog; + return from?.closest('.aui-theme-root') ?? document.body; +} diff --git a/packages/trueforge-ui/src/atoms/lib/useSearchAgentsList.ts b/packages/trueforge-ui/src/atoms/lib/useSearchAgentsList.ts index acda757f1..acc0e70ca 100644 --- a/packages/trueforge-ui/src/atoms/lib/useSearchAgentsList.ts +++ b/packages/trueforge-ui/src/atoms/lib/useSearchAgentsList.ts @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useOptionalServer } from '../../server/ServerContext.js'; -import type { AgentLibraryEntry } from '../../server/types.js'; +import type { AgentBuilderServer, AgentLibraryEntry } from '../../server/types.js'; import { getErrorMessage } from '../../utils/getErrorMessage.js'; import { useDebouncedValue } from './useDebouncedValue.js'; @@ -11,6 +11,16 @@ export const SEARCH_AGENTS_PAGE_SIZE = 50; const DEFAULT_DEBOUNCE_MS = 300; const LOAD_MORE_ROOT_MARGIN = '48px'; +/** Drain every `searchAgents` page (offset pagination). */ +export async function searchAllAgents( + server: Pick, + offset = 0, +): Promise { + const rows = await server.searchAgents({ limit: SEARCH_AGENTS_PAGE_SIZE, offset }); + if (rows.length < SEARCH_AGENTS_PAGE_SIZE) return rows; + return [...rows, ...(await searchAllAgents(server, offset + rows.length))]; +} + export type UseSearchAgentsListOptions = { /** When false, no fetches run. */ enabled: boolean; diff --git a/packages/trueforge-ui/src/atoms/primitives/DropdownMenu.tsx b/packages/trueforge-ui/src/atoms/primitives/DropdownMenu.tsx index 5a77fb39f..c26569c85 100644 --- a/packages/trueforge-ui/src/atoms/primitives/DropdownMenu.tsx +++ b/packages/trueforge-ui/src/atoms/primitives/DropdownMenu.tsx @@ -1,6 +1,10 @@ -import React, { useEffect, useId, useRef, useState } from 'react'; +'use client'; + +import React, { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { cn } from '../lib/cn.js'; +import { themePortalRoot } from '../lib/themePortalRoot.js'; export type DropdownMenuProps = { trigger: React.ReactNode; @@ -11,26 +15,61 @@ export type DropdownMenuProps = { export function DropdownMenu({ trigger, children, align = 'end', className }: DropdownMenuProps) { const [open, setOpen] = useState(false); + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); const containerRef = useRef(null); const menuRef = useRef(null); + const focusedOpenRef = useRef(false); const menuId = useId(); + useLayoutEffect(() => { + if (!open) { + setPos(null); + return; + } + + const update = () => { + const el = containerRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + setPos({ + top: rect.bottom + 4, + left: align === 'end' ? rect.right : rect.left, + }); + }; + + update(); + window.addEventListener('scroll', update, true); + window.addEventListener('resize', update); + return () => { + window.removeEventListener('scroll', update, true); + window.removeEventListener('resize', update); + }; + }, [open, align]); + useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setOpen(false); - } + const target = e.target; + if (!(target instanceof Node)) return; + if (containerRef.current?.contains(target)) return; + if (menuRef.current?.contains(target)) return; + setOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [open]); + // Menu mounts only after `pos` is set; focus once per open, not on every scroll/resize pos rewrite. useEffect(() => { - if (!open) return; + if (!open) { + focusedOpenRef.current = false; + return; + } + if (pos == null || focusedOpenRef.current) return; const first = menuRef.current?.querySelector('[role="menuitem"]:not([disabled])'); first?.focus(); - }, [open]); + focusedOpenRef.current = true; + }, [open, pos]); useEffect(() => { if (!open) return; @@ -82,25 +121,35 @@ export function DropdownMenu({ trigger, children, align = 'end', className }: Dr }) : trigger; + const menu = + open && pos != null + ? createPortal( + , + themePortalRoot(containerRef.current), + ) + : null; + return (
setOpen(v => !v)}>{triggerEl}
- {open && ( - - )} + {menu}
); } diff --git a/packages/trueforge-ui/src/atoms/primitives/PopoverSelect.tsx b/packages/trueforge-ui/src/atoms/primitives/PopoverSelect.tsx new file mode 100644 index 000000000..99c875b40 --- /dev/null +++ b/packages/trueforge-ui/src/atoms/primitives/PopoverSelect.tsx @@ -0,0 +1,201 @@ +'use client'; + +import { useEffect, useId, useRef, useState } from 'react'; + +import { Icon } from '../../icons/Icon.js'; +import { cn } from '../lib/cn.js'; +import { + auiSelectMenuClass, + auiSelectOptionClass, + auiSelectPrefixTriggerClass, + auiSelectTriggerClass, +} from '../lib/selectClasses.js'; + +export type PopoverSelectOption = { + value: T; + label: string; + disabled?: boolean; +}; + +type CommonPopoverSelectProps = { + options: readonly PopoverSelectOption[]; + placeholder?: string; + disabled?: boolean; + className?: string; + /** Which edge of the trigger the menu opens toward. Default `bottom`. */ + menuPlacement?: 'top' | 'bottom'; + /** When set, renders a labeled chip trigger (label | value chip + chevron). */ + prefix?: string; + 'aria-label': string; +}; + +export type PopoverSelectProps = CommonPopoverSelectProps & + ( + | { + multiple?: false; + value: T; + onValueChange: (value: T) => void; + } + | { + multiple: true; + value: readonly T[]; + onValueChange: (value: T[]) => void; + } + ); + +export function PopoverSelect(props: PopoverSelectProps) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const listboxRef = useRef(null); + const listboxId = useId(); + + useEffect(() => { + if (!open) return; + + const handlePointerDown = (event: MouseEvent) => { + if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setOpen(false); + }; + document.addEventListener('mousedown', handlePointerDown); + return () => document.removeEventListener('mousedown', handlePointerDown); + }, [open]); + + useEffect(() => { + if (!open) return; + const selected = listboxRef.current?.querySelector('[role="option"][aria-selected="true"]'); + const first = listboxRef.current?.querySelector('[role="option"]:not([aria-disabled="true"])'); + (selected ?? first)?.focus(); + }, [open]); + + useEffect(() => { + if (!open) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + setOpen(false); + triggerRef.current?.focus(); + return; + } + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + + const items = Array.from( + listboxRef.current?.querySelectorAll('[role="option"]:not([aria-disabled="true"])') ?? [], + ); + if (items.length === 0) return; + + event.preventDefault(); + const currentIndex = items.findIndex(item => item === document.activeElement); + const nextIndex = + event.key === 'Home' + ? 0 + : event.key === 'End' + ? items.length - 1 + : event.key === 'ArrowDown' + ? (currentIndex + 1) % items.length + : (currentIndex - 1 + items.length) % items.length; + items[nextIndex]?.focus(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [open]); + + const isSelected = (value: T) => (props.multiple ? props.value.includes(value) : props.value === value); + + const selectedLabels = props.options.filter(option => isSelected(option.value)).map(option => option.label); + const triggerLabel = + selectedLabels.length === 0 + ? (props.placeholder ?? 'Select') + : props.multiple && selectedLabels.length > 1 + ? `${selectedLabels.length} selected` + : selectedLabels[0]; + + const select = (option: PopoverSelectOption) => { + if (option.disabled) return; + if (props.multiple) { + props.onValueChange( + props.value.includes(option.value) + ? props.value.filter(value => value !== option.value) + : [...props.value, option.value], + ); + return; + } + props.onValueChange(option.value); + setOpen(false); + triggerRef.current?.focus(); + }; + + const menuPlacement = props.menuPlacement ?? 'bottom'; + + return ( +
+ + + {open ? ( +
+ {props.options.map(option => { + const selected = isSelected(option.value); + return ( + + ); + })} +
+ ) : null} +
+ ); +} diff --git a/packages/trueforge-ui/src/atoms/primitives/SideDrawer.tsx b/packages/trueforge-ui/src/atoms/primitives/SideDrawer.tsx new file mode 100644 index 000000000..21d885629 --- /dev/null +++ b/packages/trueforge-ui/src/atoms/primitives/SideDrawer.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useEffect, useId, useRef, type ReactNode } from 'react'; + +import { Icon } from '../../icons/Icon.js'; +import { auiButtonClass } from '../lib/buttonClasses.js'; +import { cn } from '../lib/cn.js'; +import { useCompactLayout } from '../lib/CompactLayoutContext.js'; +import { useCompactOverlayStyle } from '../lib/useCompactOverlayStyle.js'; + +export type SideDrawerAnchor = 'left' | 'right'; +export type SideDrawerSize = 'sm' | 'md' | 'lg' | 'xl'; + +export type SideDrawerProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description?: string; + headerIcon?: ReactNode; + children: ReactNode; + footer?: ReactNode; + /** Desktop side; ignored when compact / below `md` (falls back to bottom sheet). */ + anchor?: SideDrawerAnchor; + /** Desktop width. Defaults to `md`. */ + size?: SideDrawerSize; + className?: string; + 'aria-label'?: string; +}; + +const SIZE_WIDTH: Record = { + sm: 'md:w-80', + md: 'md:w-[28rem]', + lg: 'md:w-[36rem]', + xl: 'md:w-[42rem]', +}; + +/** + * Responsive overlay chrome: side drawer on `md+`, bottom sheet below `md` + * (and always when compact dock/widget layout is active). + */ +export function SideDrawer({ + open, + onOpenChange, + title, + description, + headerIcon, + children, + footer, + anchor = 'right', + size = 'md', + className, + 'aria-label': ariaLabel, +}: SideDrawerProps) { + const ref = useRef(null); + const titleId = useId(); + const descriptionId = useId(); + const compact = useCompactLayout(); + const compactStyle = useCompactOverlayStyle(ref, compact); + + useEffect(() => { + const el = ref.current; + if (!el) return; + if (open && !el.open) el.showModal(); + else if (!open && el.open) el.close(); + }, [open]); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const handler = () => onOpenChange(false); + el.addEventListener('close', handler); + return () => el.removeEventListener('close', handler); + }, [onOpenChange]); + + return ( + { + event.preventDefault(); + event.stopPropagation(); + onOpenChange(false); + }} + style={compactStyle} + className={cn( + 'bg-card-bg text-text-primary border-border open:flex open:flex-col overflow-hidden rounded-none p-0 shadow-xl', + 'backdrop:bg-black/50 backdrop:backdrop-blur-[2px] dark:backdrop:bg-black/70', + compact + ? 'm-0 mt-auto h-[min(85dvh,40rem)] w-full max-w-none border-t pb-[env(safe-area-inset-bottom)]' + : cn( + // Mobile / narrow: bottom sheet + 'm-0 mt-auto h-[min(85dvh,40rem)] w-full max-w-none border-t pb-[env(safe-area-inset-bottom)]', + // Desktop: side drawer + 'md:mt-0 md:h-dvh md:max-h-none md:border md:pb-0', + SIZE_WIDTH[size], + anchor === 'right' + ? 'md:ml-auto md:mr-0 md:border-y-0 md:border-r-0' + : 'md:mr-auto md:ml-0 md:border-y-0 md:border-l-0', + ), + className, + )} + > +
+ {headerIcon} +
+

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null} +
+ +
+
{children}
+ {footer ?
{footer}
: null} +
+ ); +} diff --git a/packages/trueforge-ui/src/atoms/primitives/Table.tsx b/packages/trueforge-ui/src/atoms/primitives/Table.tsx new file mode 100644 index 000000000..6b5a76877 --- /dev/null +++ b/packages/trueforge-ui/src/atoms/primitives/Table.tsx @@ -0,0 +1,223 @@ +'use client'; + +import React from 'react'; + +import { Icon } from '../../icons/Icon.js'; +import { auiButtonClass } from '../lib/buttonClasses.js'; +import { cn } from '../lib/cn.js'; +import { PopoverSelect } from './PopoverSelect.js'; + +export const DEFAULT_TABLE_PAGE_SIZE = 10; +export const TABLE_PAGE_SIZE_OPTIONS = [10, 25, 50] as const; + +export type TableProps = React.HTMLAttributes & { + /** Extra classes on the horizontal scroll wrapper. */ + containerClassName?: string; +}; + +export function Table({ className, containerClassName, ...props }: TableProps) { + return ( +
+ + + ); +} + +export type TableHeaderProps = React.HTMLAttributes; + +export function TableHeader({ className, ...props }: TableHeaderProps) { + return ; +} + +export type TableBodyProps = React.HTMLAttributes; + +export function TableBody({ className, ...props }: TableBodyProps) { + return ; +} + +export type TableRowProps = React.HTMLAttributes; + +export function TableRow({ className, ...props }: TableRowProps) { + return ( + + ); +} + +export type TableHeadProps = React.ThHTMLAttributes; + +export function TableHead({ className, ...props }: TableHeadProps) { + return ( +
+ ); +} + +export type TableCellProps = React.TdHTMLAttributes; + +export function TableCell({ className, ...props }: TableCellProps) { + return ; +} + +export type TablePaginationProps = { + page: number; + pageSize: number; + total: number; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; + pageSizeOptions?: readonly number[]; + className?: string; +}; + +export function TablePagination({ + page, + pageSize, + total, + onPageChange, + onPageSizeChange, + pageSizeOptions = TABLE_PAGE_SIZE_OPTIONS, + className, +}: TablePaginationProps) { + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + const safePage = Math.min(page, pageCount - 1); + const from = total === 0 ? 0 : safePage * pageSize + 1; + const to = Math.min(total, (safePage + 1) * pageSize); + const canPrev = safePage > 0; + const canNext = safePage < pageCount - 1; + + const sizeOptions = pageSizeOptions.map(size => ({ + value: String(size), + label: String(size), + })); + + return ( +
+

+ {total === 0 ? 'Showing 0 of 0' : `Showing ${String(from)}–${String(to)} of ${String(total)}`} +

+
+ +
+ + + {String(safePage + 1)} / {String(pageCount)} + + +
+
+
+ ); +} + +/** Cursor / token pagination when the API has no total count. */ +export type TableTokenPaginationProps = { + pageSize: number; + rowCount: number; + canPrev: boolean; + canNext: boolean; + onPrev: () => void; + onNext: () => void; + onPageSizeChange: (pageSize: number) => void; + pageSizeOptions?: readonly number[]; + className?: string; +}; + +export function TableTokenPagination({ + pageSize, + rowCount, + canPrev, + canNext, + onPrev, + onNext, + onPageSizeChange, + pageSizeOptions = TABLE_PAGE_SIZE_OPTIONS, + className, +}: TableTokenPaginationProps) { + const sizeOptions = pageSizeOptions.map(size => ({ + value: String(size), + label: String(size), + })); + + return ( +
+

+ {rowCount === 0 ? 'Showing 0' : `Showing ${String(rowCount)}`} +

+
+ +
+ + +
+
+
+ ); +} diff --git a/packages/trueforge-ui/src/atoms/primitives/Tooltip.tsx b/packages/trueforge-ui/src/atoms/primitives/Tooltip.tsx index a8c8cc864..f51b48de0 100644 --- a/packages/trueforge-ui/src/atoms/primitives/Tooltip.tsx +++ b/packages/trueforge-ui/src/atoms/primitives/Tooltip.tsx @@ -4,6 +4,7 @@ import React, { cloneElement, isValidElement, useLayoutEffect, useRef, useState import { createPortal } from 'react-dom'; import { cn } from '../lib/cn.js'; +import { themePortalRoot } from '../lib/themePortalRoot.js'; const TOOLTIP_VIEWPORT_PAD = 8; @@ -47,16 +48,6 @@ export function clampCenteredTooltip({ return { top: nextTop, left: nextLeft }; } -// Keep portaled chrome under ThemeProvider so preset/custom CSS vars still apply. -function themePortalRoot(from: HTMLElement | null): HTMLElement { - // A native opened with showModal() renders in the top layer, above any - // z-index. When the trigger lives inside one, portal into the dialog so the - // tooltip joins the top layer instead of rendering behind the modal. - const dialog = from?.closest('dialog'); - if (dialog instanceof HTMLElement) return dialog; - return from?.closest('.aui-theme-root') ?? document.body; -} - function hasTooltipContent(content: React.ReactNode): boolean { if (content == null || content === false) return false; if (typeof content === 'string') return content.trim().length > 0; diff --git a/packages/trueforge-ui/src/atoms/schedules/ScheduleFormDrawer.tsx b/packages/trueforge-ui/src/atoms/schedules/ScheduleFormDrawer.tsx new file mode 100644 index 000000000..bd9d0b9d5 --- /dev/null +++ b/packages/trueforge-ui/src/atoms/schedules/ScheduleFormDrawer.tsx @@ -0,0 +1,320 @@ +'use client'; + +import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'; + +import { useToasterOptional } from '../../containers/ToasterContainer.js'; +import { Icon } from '../../icons/Icon.js'; +import { useScheduleServer, useServer } from '../../server/ServerContext.js'; +import { libraryAgentId } from '../../server/ShellModeContext.js'; +import type { AgentLibraryEntry, Schedule } from '../../server/types.js'; +import { DraftCatalogProvider } from '../draft/DraftCatalogProvider.js'; +import { mountName } from '../lib/mountName.js'; +import { searchAllAgents } from '../lib/useSearchAgentsList.js'; +import { Button } from '../primitives/Button.js'; +import { SideDrawer } from '../primitives/SideDrawer.js'; +import { + cronToFormValues, + defaultScheduleFormValues, + formatCadenceSummary, + valuesToCron, + type ScheduleFormValues, +} from './cadence.js'; +import { ScheduleFormFields } from './ScheduleFormFields.js'; +import { TestScheduleScreen } from './TestScheduleScreen.js'; + +export type ScheduleFormDrawerProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: 'create' | 'edit'; + schedule?: Schedule; + initialAgentId?: string; + onSaved?: () => void; +}; + +type DrawerView = { kind: 'form'; saved?: Schedule } | { kind: 'test'; schedule: Schedule }; + +function ScheduleFormDrawerBody({ + open, + onOpenChange, + mode, + schedule, + initialAgentId = '', + onSaved, +}: ScheduleFormDrawerProps) { + const scheduleServer = useScheduleServer(); + const server = useServer(); + const toaster = useToasterOptional(); + const [form, setForm] = useState(defaultScheduleFormValues); + const [agentId, setAgentId] = useState(initialAgentId); + const [agents, setAgents] = useState([]); + const [view, setView] = useState({ kind: 'form' }); + const [saving, setSaving] = useState(false); + const [activating, setActivating] = useState(false); + const [error, setError] = useState(null); + + const savedFromCreate = view.kind === 'form' ? view.saved : view.schedule; + const isExternalEdit = mode === 'edit' && view.kind === 'form' && view.saved == null && schedule != null; + const isCreatedEdit = view.kind === 'form' && view.saved != null; + + useEffect(() => { + if (!open) return; + let cancelled = false; + void searchAllAgents(server) + .then(rows => { + if (cancelled) return; + setAgents(rows); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [open, server]); + + useEffect(() => { + if (!open) { + setForm(defaultScheduleFormValues()); + setAgentId(initialAgentId); + setError(null); + setView({ kind: 'form' }); + setActivating(false); + return; + } + if (mode === 'edit' && schedule != null) { + setForm( + cronToFormValues({ + name: schedule.name, + task: schedule.task, + cron: schedule.cron, + timezone: schedule.timezone, + }), + ); + setAgentId(schedule.agentId); + setView({ kind: 'form' }); + return; + } + setForm(defaultScheduleFormValues()); + setAgentId(initialAgentId); + setView({ kind: 'form' }); + }, [open, mode, schedule, initialAgentId]); + + const agentOptions = useMemo( + () => agents.map(agent => ({ agentId: libraryAgentId(agent), name: agent.name })), + [agents], + ); + + const selectedAgent = useMemo( + () => agents.find(agent => libraryAgentId(agent) === agentId) ?? null, + [agents, agentId], + ); + + const agentLabel = + selectedAgent?.name ?? + savedFromCreate?.agentName ?? + schedule?.agentName ?? + agentOptions.find(option => option.agentId === agentId)?.name ?? + agentId; + + const mcpMounts = useMemo(() => { + const mounts = selectedAgent?.agentSpec?.mcpServers ?? []; + return mounts + .map(mountName) + .filter((name: string | null): name is string => name != null) + .map(name => ({ name })); + }, [selectedAgent]); + + const canSubmit = useMemo(() => { + const cron = valuesToCron(form); + return form.name.trim().length > 0 && form.task.trim().length > 0 && cron.length > 0 && agentId.length > 0; + }, [form, agentId]); + + const enterTestView = (saved: Schedule) => { + setView({ kind: 'test', schedule: saved }); + setForm( + cronToFormValues({ + name: saved.name, + task: saved.task, + cron: saved.cron, + timezone: saved.timezone, + }), + ); + setAgentId(saved.agentId); + }; + + const handleSave = async (event: FormEvent) => { + event.preventDefault(); + if (!canSubmit) return; + const cron = valuesToCron(form); + setSaving(true); + setError(null); + try { + if (isExternalEdit && schedule != null) { + await scheduleServer.updateSchedule({ + id: schedule.id, + name: form.name.trim(), + task: form.task.trim(), + cron, + timezone: form.timezone, + status: schedule.status, + }); + onSaved?.(); + onOpenChange(false); + return; + } + + if (isCreatedEdit && view.kind === 'form' && view.saved != null) { + const saved = await scheduleServer.updateSchedule({ + id: view.saved.id, + name: form.name.trim(), + task: form.task.trim(), + cron, + timezone: form.timezone, + status: 'paused', + }); + onSaved?.(); + enterTestView(saved); + toaster?.showSuccess({ + title: 'Schedule saved as paused', + description: formatCadenceSummary({ cron: saved.cron, timezone: saved.timezone }), + }); + return; + } + + const saved = await scheduleServer.createSchedule({ + agentId, + name: form.name.trim(), + task: form.task.trim(), + cron, + timezone: form.timezone, + status: 'paused', + }); + onSaved?.(); + enterTestView(saved); + toaster?.showSuccess({ + title: 'Schedule saved as paused', + description: formatCadenceSummary({ cron: saved.cron, timezone: saved.timezone }), + }); + } catch (caught) { + const message = caught instanceof Error ? caught.message : 'Failed to save schedule'; + setError(message); + } finally { + setSaving(false); + } + }; + + const handleActivate = async () => { + if (view.kind !== 'test') return; + setActivating(true); + setError(null); + try { + await scheduleServer.updateSchedule({ + id: view.schedule.id, + name: view.schedule.name, + task: view.schedule.task, + cron: view.schedule.cron, + timezone: view.schedule.timezone, + status: 'active', + }); + onSaved?.(); + toaster?.showSuccess({ title: 'Schedule activated' }); + onOpenChange(false); + } catch (caught) { + const message = caught instanceof Error ? caught.message : 'Failed to activate schedule'; + setError(message); + toaster?.showError(caught); + } finally { + setActivating(false); + } + }; + + const title = + view.kind === 'test' ? 'Test Schedule' : isExternalEdit || isCreatedEdit ? 'Edit Schedule' : 'New Schedule'; + const description = + view.kind === 'test' + ? `Recurring unattended runs of ${agentLabel}.` + : isExternalEdit || isCreatedEdit + ? 'Update cadence and task for this schedule.' + : 'Create a recurring unattended run for an agent.'; + + let footer: ReactNode; + if (view.kind === 'test') { + footer = ( +
+ {error != null ?

{error}

: null} + +
+ ); + } else { + footer = ( +
+ {error != null ?

{error}

: null} +
+ + +
+
+ ); + } + + return ( + + + + } + footer={footer} + > + {view.kind === 'test' ? ( + { + setError(null); + setView({ kind: 'form', saved: view.schedule }); + }} + /> + ) : ( +
void handleSave(event)}> + + + )} +
+ ); +} + +export function ScheduleFormDrawer(props: ScheduleFormDrawerProps) { + return ( + + + + ); +} diff --git a/packages/trueforge-ui/src/atoms/schedules/ScheduleFormFields.tsx b/packages/trueforge-ui/src/atoms/schedules/ScheduleFormFields.tsx new file mode 100644 index 000000000..97a70fec8 --- /dev/null +++ b/packages/trueforge-ui/src/atoms/schedules/ScheduleFormFields.tsx @@ -0,0 +1,221 @@ +'use client'; + +import { cn } from '../lib/cn.js'; +import { auiInputClass } from '../lib/inputClasses.js'; +import { PopoverSelect } from '../primitives/PopoverSelect.js'; +import { + TIMEZONE_OPTIONS, + WEEKDAY_OPTIONS, + formatCadenceSummary, + valuesToCron, + type RecurrenceKind, + type ScheduleFormValues, +} from './cadence.js'; + +export const RECURRENCE_OPTIONS: Array<{ value: RecurrenceKind; label: string }> = [ + { value: 'hourly', label: 'Hourly' }, + { value: 'daily', label: 'Daily' }, + { value: 'weekly', label: 'Weekly' }, + { value: 'custom', label: 'Custom' }, +]; + +const HOUR_OPTIONS = Array.from({ length: 24 }, (_, hour) => ({ + value: String(hour), + label: hour.toString().padStart(2, '0'), +})); +const MINUTE_OPTIONS = [0, 15, 30, 45].map(minute => ({ + value: String(minute), + label: `:${minute.toString().padStart(2, '0')}`, +})); + +export type ScheduleFormFieldsProps = { + values: ScheduleFormValues; + onChange: (next: ScheduleFormValues) => void; + agentId: string; + onAgentIdChange?: (agentId: string) => void; + agentOptions: Array<{ agentId: string; name: string }>; + agentPickerDisabled?: boolean; +}; + +export function ScheduleFormFields({ + values, + onChange, + agentId, + onAgentIdChange, + agentOptions, + agentPickerDisabled = false, +}: ScheduleFormFieldsProps) { + const cron = valuesToCron(values); + const cadence = formatCadenceSummary({ cron, timezone: values.timezone }); + + const set = (key: K, value: ScheduleFormValues[K]) => { + onChange({ ...values, [key]: value }); + }; + + const toggleWeekday = (day: number) => { + const has = values.weekdays.includes(day); + const weekdays = has ? values.weekdays.filter(d => d !== day) : [...values.weekdays, day]; + onChange({ ...values, weekdays: weekdays.length > 0 ? weekdays : [day] }); + }; + + return ( +
+
+ Agent + ({ value: agent.agentId, label: agent.name }))} + onValueChange={value => onAgentIdChange?.(value)} + disabled={agentPickerDisabled || onAgentIdChange == null} + /> +
+ + + +