diff --git a/.changeset/avatar-gradient-initial.md b/.changeset/avatar-gradient-initial.md new file mode 100644 index 000000000..4bef1114c --- /dev/null +++ b/.changeset/avatar-gradient-initial.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge-ui': patch +--- + +Avatar fallbacks use a light primary-button gradient in light mode, a solid primary gradient in dark mode, and show a single initial character. diff --git a/.changeset/message-indicator-thinking-orb.md b/.changeset/message-indicator-thinking-orb.md new file mode 100644 index 000000000..43e39be76 --- /dev/null +++ b/.changeset/message-indicator-thinking-orb.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge-ui': patch +--- + +Assistant message loading indicator uses ThinkingOrb with left-to-right shimmering Working... text. diff --git a/.changeset/popover-select-flip.md b/.changeset/popover-select-flip.md new file mode 100644 index 000000000..1b470f196 --- /dev/null +++ b/.changeset/popover-select-flip.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge-ui': patch +--- + +Flip PopoverSelect menus when the preferred side lacks viewport room (e.g. table rows-per-page). diff --git a/.changeset/schedules-lazy-agents.md b/.changeset/schedules-lazy-agents.md new file mode 100644 index 000000000..c399515ca --- /dev/null +++ b/.changeset/schedules-lazy-agents.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge-ui': patch +--- + +Stop draining the full agents catalog on Schedules mount; load agents only when the filter opens (infinite scroll). diff --git a/.changeset/settings-nav-selected.md b/.changeset/settings-nav-selected.md new file mode 100644 index 000000000..ebaf9884c --- /dev/null +++ b/.changeset/settings-nav-selected.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge-ui': patch +--- + +Settings sidebar uses a subtle primary tint for the selected section instead of a solid fill. diff --git a/packages/trueforge-ui/src/atoms/AttachmentCard.tsx b/packages/trueforge-ui/src/atoms/AttachmentCard.tsx index d6a2617f3..5ea2feec9 100644 --- a/packages/trueforge-ui/src/atoms/AttachmentCard.tsx +++ b/packages/trueforge-ui/src/atoms/AttachmentCard.tsx @@ -60,7 +60,8 @@ export function AttachmentCard({
- + {/* bg-none drops the default gradient (bg-image group), which bg-secondary-bg alone would not override. */} + diff --git a/packages/trueforge-ui/src/atoms/MessageIndicator.tsx b/packages/trueforge-ui/src/atoms/MessageIndicator.tsx index d216b4cd2..601cbbb05 100644 --- a/packages/trueforge-ui/src/atoms/MessageIndicator.tsx +++ b/packages/trueforge-ui/src/atoms/MessageIndicator.tsx @@ -1,3 +1,5 @@ +import { ThinkingOrb } from 'thinking-orbs'; + import { cn } from './lib/cn.js'; export type MessageIndicatorProps = { @@ -8,10 +10,11 @@ export function MessageIndicator({ className }: MessageIndicatorProps) { return ( - {'●'} + + Working... ); } diff --git a/packages/trueforge-ui/src/atoms/ThreadListRow.tsx b/packages/trueforge-ui/src/atoms/ThreadListRow.tsx index a6ad4cc25..1af8c1c61 100644 --- a/packages/trueforge-ui/src/atoms/ThreadListRow.tsx +++ b/packages/trueforge-ui/src/atoms/ThreadListRow.tsx @@ -37,7 +37,7 @@ export function ThreadListRow({ data-slot="aui_thread-list-item" data-active={active || undefined} className={cn( - 'group flex min-w-0 items-center gap-0.5 rounded-[0.75rem] transition-colors', + 'group flex min-w-0 items-center gap-0.5 rounded-[0.5rem] transition-colors', active ? 'bg-dropdown-selected-item-bg text-dropdown-selected-item-text' : 'text-text-secondary hover:bg-ghost-button-hover hover:text-text-primary', @@ -58,10 +58,10 @@ export function ThreadListRow({ })} > - {title} + {title} {agentName != null ? ( - - + + {agentName} ) : null} diff --git a/packages/trueforge-ui/src/atoms/UserAvatar.tsx b/packages/trueforge-ui/src/atoms/UserAvatar.tsx index 1dfc85f4f..bbf2aa466 100644 --- a/packages/trueforge-ui/src/atoms/UserAvatar.tsx +++ b/packages/trueforge-ui/src/atoms/UserAvatar.tsx @@ -10,20 +10,8 @@ export type UserAvatarProps = { }; export function getUserInitials(displayName: string): string { - // split by spaces and filter out empty strings - const parts = displayName.trim().split(/\s+/).filter(Boolean); - const first = parts[0]; - if (first === undefined) { - return ''; - } - const last = parts.at(-1); - const characters = - parts.length === 1 ? Array.from(first).slice(0, 2) : [Array.from(first)[0], Array.from(last ?? '')[0]]; - - return characters - .filter(character => character !== undefined) - .join('') - .toLocaleUpperCase(); + const first = Array.from(displayName.trim())[0]; + return first === undefined ? '' : first.toLocaleUpperCase(); } // Default current-user chrome; hosts can replace it through `overrides.UserAvatar`. @@ -46,9 +34,7 @@ export function UserAvatar({ labeled = false, className }: UserAvatarProps) { )} > - - {getUserInitials(displayName)} - + {getUserInitials(displayName)} {displayName}
diff --git a/packages/trueforge-ui/src/atoms/primitives/Avatar.tsx b/packages/trueforge-ui/src/atoms/primitives/Avatar.tsx index f4699c153..5611968c0 100644 --- a/packages/trueforge-ui/src/atoms/primitives/Avatar.tsx +++ b/packages/trueforge-ui/src/atoms/primitives/Avatar.tsx @@ -3,9 +3,9 @@ import React, { useState } from 'react'; import { cn } from '../lib/cn.js'; const sizeClasses = { - sm: 'h-6 w-6 text-xs', - default: 'h-8 w-8 text-sm', - lg: 'h-10 w-10 text-base', + sm: 'h-5 w-5 text-[0.625rem] leading-none', + default: 'h-8 w-8 text-sm leading-none', + lg: 'h-10 w-10 text-base leading-none', }; export type AvatarProps = React.HTMLAttributes & { @@ -52,7 +52,7 @@ export function AvatarFallback({ className, ...props }: AvatarFallbackProps) {
= menuHeight) return 'bottom'; + if (spaceAbove >= menuHeight) return 'top'; + return spaceAbove > spaceBelow ? 'top' : 'bottom'; + } + if (spaceAbove >= menuHeight) return 'top'; + if (spaceBelow >= menuHeight) return 'bottom'; + return spaceBelow > spaceAbove ? 'bottom' : 'top'; +} + export type PopoverSelectOption = { value: T; label: string; @@ -25,8 +59,8 @@ type CommonPopoverSelectProps = { disabled?: boolean; className?: string; menuClassName?: string; - /** Which edge of the trigger the menu opens toward. Default `bottom`. */ - menuPlacement?: 'top' | 'bottom'; + /** Preferred open edge; flips when that side lacks room. Default `bottom`. */ + menuPlacement?: MenuPlacement; /** When set, renders a labeled chip trigger (label | value chip + chevron). */ prefix?: string; emptyContent?: ReactNode; @@ -50,14 +84,16 @@ export type PopoverSelectProps = CommonPopoverSelectProps & export function PopoverSelect(props: PopoverSelectProps) { const [open, setOpen] = useState(false); - const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null); + const [pos, setPos] = useState<{ top: number; left: number; width: number; placement: MenuPlacement } | null>(null); const rootRef = useRef(null); const triggerRef = useRef(null); const menuRef = useRef(null); const listboxRef = useRef(null); const focusedOpenRef = useRef(false); const listboxId = useId(); - const menuPlacement = props.menuPlacement ?? 'bottom'; + const preferredPlacement = props.menuPlacement ?? 'bottom'; + const hasFooter = props.footer != null; + const optionCount = props.options.length; useLayoutEffect(() => { if (!open) { @@ -69,21 +105,36 @@ export function PopoverSelect(props: PopoverSelectProps) { const el = triggerRef.current; if (!el) return; const rect = el.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom - MENU_GAP_PX; + const spaceAbove = rect.top - MENU_GAP_PX; + const measuredHeight = menuRef.current?.offsetHeight; + const menuHeight = + measuredHeight != null && measuredHeight > 0 ? measuredHeight : estimateMenuHeight({ optionCount, hasFooter }); + const placement = resolveMenuPlacement({ + preferred: preferredPlacement, + spaceAbove, + spaceBelow, + menuHeight, + }); setPos({ - top: menuPlacement === 'top' ? rect.top - 4 : rect.bottom + 4, + top: placement === 'top' ? rect.top - MENU_GAP_PX : rect.bottom + MENU_GAP_PX, left: rect.left, width: rect.width, + placement, }); }; update(); + // Remeasure after the menu mounts so flip uses the real height. + const rafId = requestAnimationFrame(update); window.addEventListener('scroll', update, true); window.addEventListener('resize', update); return () => { + cancelAnimationFrame(rafId); window.removeEventListener('scroll', update, true); window.removeEventListener('resize', update); }; - }, [open, menuPlacement]); + }, [open, preferredPlacement, optionCount, hasFooter]); useEffect(() => { if (!open) return; @@ -178,7 +229,7 @@ export function PopoverSelect(props: PopoverSelectProps) { top: pos.top, left: pos.left, width: pos.width, - transform: menuPlacement === 'top' ? 'translateY(-100%)' : undefined, + transform: pos.placement === 'top' ? 'translateY(-100%)' : undefined, }} onMouseDown={event => event.stopPropagation()} > diff --git a/packages/trueforge-ui/src/atoms/schedules/SchedulesPage.tsx b/packages/trueforge-ui/src/atoms/schedules/SchedulesPage.tsx index 96546fc88..609820a08 100644 --- a/packages/trueforge-ui/src/atoms/schedules/SchedulesPage.tsx +++ b/packages/trueforge-ui/src/atoms/schedules/SchedulesPage.tsx @@ -16,7 +16,6 @@ import { CreatedByCell } from '../CreatedByCell.js'; import { EmptyScreen } from '../EmptyScreen.js'; import { auiButtonClass } from '../lib/buttonClasses.js'; import { cn } from '../lib/cn.js'; -import { searchAllAgents } from '../lib/useSearchAgentsList.js'; import { PageHeader } from '../PageHeader.js'; import { Button } from '../primitives/Button.js'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../primitives/Dialog.js'; @@ -39,8 +38,6 @@ import { ScheduleFormDrawer } from './ScheduleFormDrawer.js'; import { ScheduleLastRunsCell } from './ScheduleLastRunsCell.js'; import { ScheduleStatusBadge } from './ScheduleStatusBadge.js'; -type AgentOption = { agentId: string; name: string }; - type DrawerState = { kind: 'closed' } | { kind: 'create'; agentId?: string } | { kind: 'edit'; schedule: Schedule }; export type SchedulesPageProps = { @@ -166,7 +163,8 @@ export function SchedulesPage({ agentId }: SchedulesPageProps) { const [runsByScheduleId, setRunsByScheduleId] = useState>({}); const [runsLoading, setRunsLoading] = useState(false); const [runningScheduleIds, setRunningScheduleIds] = useState>(() => new Set()); - const [agentOptions, setAgentOptions] = useState([]); + /** Names learned from picker picks; no mount-time catalog drain. */ + const [agentLabelById, setAgentLabelById] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [nameQuery, setNameQuery] = useState(() => filtersFromSearch(window.location.search).nameQuery); @@ -176,20 +174,14 @@ export function SchedulesPage({ agentId }: SchedulesPageProps) { const [agentFilter, setAgentFilter] = useState( () => agentId ?? filtersFromSearch(window.location.search).agentFilter, ); + // Specific agent (filter or embedded): gate Create on USE. Filter "all": enable and let the drawer enforce USE. const permissionAgentId = agentId ?? (agentFilter === 'all' ? null : agentFilter); - const permissionAgentIds = useMemo( - () => (permissionAgentId == null ? agentOptions.map(option => option.agentId) : [permissionAgentId]), - [agentOptions, permissionAgentId], - ); const { allows: allowsAgent } = useResourcePermissions({ resourceType: 'agent', - resourceIds: permissionAgentIds, + resourceIds: permissionAgentId == null ? [] : [permissionAgentId], }); const canCreateSchedule = - server.permissions == null || - (permissionAgentId == null - ? agentOptions.some(option => allowsAgent(option.agentId, 'USE')) - : allowsAgent(permissionAgentId, 'USE')); + server.permissions == null || permissionAgentId == null || allowsAgent(permissionAgentId, 'USE'); const [drawer, setDrawer] = useState(() => initialDrawerState(agentId)); const [pendingDelete, setPendingDelete] = useState(null); const [pageSize, setPageSize] = useState(() => clampPageSize(DEFAULT_TABLE_PAGE_SIZE)); @@ -318,27 +310,6 @@ export function SchedulesPage({ agentId }: SchedulesPageProps) { void loadSchedules({ token: pageToken, size: pageSize, agentId: agentFilter }); }, [agentFilter, pageSize, pageToken, loadSchedules]); - useEffect(() => { - let cancelled = false; - void searchAllAgents(server) - .then(rows => { - if (cancelled) return; - setAgentOptions(rows.map(agent => ({ agentId: libraryAgentId(agent), name: agent.name }))); - }) - .catch(() => undefined); - return () => { - cancelled = true; - }; - }, [server]); - - const agentNameById = useMemo(() => { - const map = new Map(); - for (const agent of agentOptions) { - map.set(agent.agentId, agent.name); - } - return map; - }, [agentOptions]); - // Name + status are client-side on the current server page only. const filtered = useMemo(() => { const q = nameQuery.trim().toLowerCase(); @@ -427,12 +398,18 @@ export function SchedulesPage({ agentId }: SchedulesPageProps) { {agentId === undefined ? ( { setAgentFilter(value); setPageToken(undefined); setPrevTokenStack([]); }} + onAgentPicked={agent => { + setAgentLabelById(current => ({ + ...current, + [libraryAgentId(agent)]: agent.name, + })); + }} allOption={{ value: 'all', label: 'All agents' }} className="sm:w-48" aria-label="Filter by agent" @@ -508,7 +485,7 @@ export function SchedulesPage({ agentId }: SchedulesPageProps) { {filtered.map(schedule => { const cadence = formatCadenceSummary({ cron: schedule.cron, timezone: schedule.timezone }); - const agentLabel = schedule.agentName ?? agentNameById.get(schedule.agentId) ?? schedule.agentId; + const agentLabel = schedule.agentName ?? agentLabelById[schedule.agentId] ?? schedule.agentId; return ( diff --git a/packages/trueforge-ui/src/containers/SettingsBuilder/index.tsx b/packages/trueforge-ui/src/containers/SettingsBuilder/index.tsx index cf7bb0665..4f1736a1f 100644 --- a/packages/trueforge-ui/src/containers/SettingsBuilder/index.tsx +++ b/packages/trueforge-ui/src/containers/SettingsBuilder/index.tsx @@ -134,7 +134,7 @@ const TruefoundrySettingsBuilder = () => { // Narrow panels cannot fit fixed-width tabs, so tabs split the row instead. compact ? 'min-w-0 flex-1 justify-center gap-1.5 px-1.5' : 'shrink-0', section === item.id - ? 'bg-primary-button-bg text-primary-button-text' + ? 'bg-primary-button-bg/10 text-primary-button-bg' : 'text-text-secondary hover:bg-ghost-button-hover/60 hover:text-text-primary', )} onClick={() => { diff --git a/packages/trueforge-ui/src/styles.css b/packages/trueforge-ui/src/styles.css index 12d38fc52..ea369e41b 100644 --- a/packages/trueforge-ui/src/styles.css +++ b/packages/trueforge-ui/src/styles.css @@ -465,3 +465,31 @@ border-radius: 0.375rem; } } + +/* Left-to-right shimmer for assistant "Working..." indicator text. */ +@keyframes aui-text-shimmer { + from { + background-position: 100% center; + } + to { + background-position: -100% center; + } +} + +@media (prefers-reduced-motion: no-preference) { + .aui-message-indicator-shimmer { + background-image: linear-gradient( + 90deg, + var(--text-secondary) 0%, + var(--text-secondary) 40%, + var(--text-primary) 50%, + var(--text-secondary) 60%, + var(--text-secondary) 100% + ); + background-size: 200% auto; + background-clip: text; + -webkit-background-clip: text; + color: transparent; + animation: aui-text-shimmer 1.5s linear infinite; + } +} diff --git a/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx b/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx index 7e9439da6..b9e338061 100644 --- a/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx +++ b/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx @@ -675,7 +675,7 @@ describe('AgentsLibraryButton', () => { expect(await screen.findByRole('columnheader', { name: 'Created by' })).toBeInTheDocument(); expect(screen.getByText('alice@example.com')).toBeInTheDocument(); - expect(document.querySelector('[data-slot="avatar-fallback"]')).toHaveTextContent('AL'); + expect(document.querySelector('[data-slot="avatar-fallback"]')).toHaveTextContent(/^A$/); }); it('hides Created by when no agent has createdBySubject', async () => { diff --git a/packages/trueforge-ui/test/atoms/CreatedByCell.test.tsx b/packages/trueforge-ui/test/atoms/CreatedByCell.test.tsx index 3ddb2cc99..d99f89f93 100644 --- a/packages/trueforge-ui/test/atoms/CreatedByCell.test.tsx +++ b/packages/trueforge-ui/test/atoms/CreatedByCell.test.tsx @@ -21,6 +21,6 @@ describe('CreatedByCell', () => { ); expect(screen.getByText('alice@example.com')).toBeInTheDocument(); expect(container.querySelector('[data-slot="avatar"]')).toBeInTheDocument(); - expect(container.querySelector('[data-slot="avatar-fallback"]')).toHaveTextContent('AL'); + expect(container.querySelector('[data-slot="avatar-fallback"]')).toHaveTextContent('A'); }); }); diff --git a/packages/trueforge-ui/test/atoms/MessageIndicator.test.tsx b/packages/trueforge-ui/test/atoms/MessageIndicator.test.tsx index 2ae551cfd..40136e430 100644 --- a/packages/trueforge-ui/test/atoms/MessageIndicator.test.tsx +++ b/packages/trueforge-ui/test/atoms/MessageIndicator.test.tsx @@ -1,15 +1,21 @@ import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { MessageIndicator } from '@/atoms/MessageIndicator.js'; +vi.mock('thinking-orbs', () => ({ + ThinkingOrb: () =>
, +})); + describe('MessageIndicator', () => { - it('provides an accessible working state and preserves host styling', () => { + it('shows ThinkingOrb with Working... and preserves host styling', () => { render(); - const indicator = screen.getByLabelText('Assistant is working'); - expect(indicator).toHaveTextContent('●'); + const indicator = screen.getByRole('status'); + expect(indicator).toHaveTextContent('Working...'); expect(indicator).toHaveAttribute('data-slot', 'aui_assistant-message-indicator'); - expect(indicator).toHaveClass('animate-pulse', 'host-indicator'); + expect(indicator).toHaveClass('host-indicator'); + expect(screen.getByText('Working...')).toHaveClass('aui-message-indicator-shimmer'); + expect(screen.getByTestId('thinking-orb')).toBeInTheDocument(); }); }); diff --git a/packages/trueforge-ui/test/atoms/UserAvatar.test.tsx b/packages/trueforge-ui/test/atoms/UserAvatar.test.tsx index 00e779afc..fd5b6f491 100644 --- a/packages/trueforge-ui/test/atoms/UserAvatar.test.tsx +++ b/packages/trueforge-ui/test/atoms/UserAvatar.test.tsx @@ -6,20 +6,20 @@ import { CurrentUserProvider } from '@/contexts/CurrentUserContext.js'; describe('getUserInitials', () => { it.each([ - ['Ada Lovelace', 'AL'], - ['Ada King Lovelace', 'AL'], - ['Ada', 'AD'], - ['Ada Ada', 'AA'], - [' ada lovelace ', 'AL'], + ['Ada Lovelace', 'A'], + ['Ada King Lovelace', 'A'], + ['Ada', 'A'], + ['bob', 'B'], + [' ada lovelace ', 'A'], ['', ''], [' ', ''], - ])('returns initials for %j', (displayName, expected) => { + ])('returns the first character for %j', (displayName, expected) => { expect(getUserInitials(displayName)).toBe(expected); }); }); describe('UserAvatar', () => { - it('shows initials with the full display name underneath', () => { + it('shows the first character with the full display name underneath', () => { render( @@ -27,7 +27,7 @@ describe('UserAvatar', () => { ); const avatar = screen.getByLabelText('Ada Lovelace'); - expect(avatar).toHaveTextContent('AL'); + expect(avatar).toHaveTextContent('A'); expect(avatar).toHaveTextContent('Ada Lovelace'); expect(avatar).toHaveAttribute('title', 'Ada Lovelace'); expect(avatar).toHaveClass('w-14.5'); diff --git a/packages/trueforge-ui/test/atoms/primitives/Avatar.test.tsx b/packages/trueforge-ui/test/atoms/primitives/Avatar.test.tsx index ef367f1d0..7bfc9166a 100644 --- a/packages/trueforge-ui/test/atoms/primitives/Avatar.test.tsx +++ b/packages/trueforge-ui/test/atoms/primitives/Avatar.test.tsx @@ -45,13 +45,22 @@ describe('AvatarFallback', () => { it('renders fallback content and forwards host attributes', () => { render( - AL + A , ); const fallback = screen.getByLabelText('Ada initials'); - expect(fallback).toHaveTextContent('AL'); + expect(fallback).toHaveTextContent('A'); expect(fallback).toHaveAttribute('data-slot', 'avatar-fallback'); - expect(fallback).toHaveClass('host-fallback'); + expect(fallback).toHaveClass( + 'host-fallback', + 'bg-gradient-to-br', + 'from-primary-button-bg/20', + 'to-primary-button-bg/10', + 'text-primary-button-bg', + 'dark:from-primary-button-bg', + 'dark:to-primary-button-hover', + 'dark:text-primary-button-text', + ); }); }); diff --git a/packages/trueforge-ui/test/atoms/primitives/PopoverSelect.test.tsx b/packages/trueforge-ui/test/atoms/primitives/PopoverSelect.test.tsx index 8e83a9107..4105cf453 100644 --- a/packages/trueforge-ui/test/atoms/primitives/PopoverSelect.test.tsx +++ b/packages/trueforge-ui/test/atoms/primitives/PopoverSelect.test.tsx @@ -70,7 +70,8 @@ describe('PopoverSelect', () => { expect(trigger.querySelector('.border-r')).not.toBeNull(); }); - it('opens the menu above the trigger when menuPlacement is top', () => { + it('opens the menu above the trigger when menuPlacement is top and there is room', () => { + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 }); render( { />, ); - fireEvent.click(screen.getByRole('button', { name: 'Timezone' })); + const trigger = screen.getByRole('button', { name: 'Timezone' }); + trigger.getBoundingClientRect = () => new DOMRect(12, 400, 120, 32); + + fireEvent.click(trigger); const menu = screen.getByRole('listbox').parentElement; expect(menu).toHaveClass('fixed'); expect(menu).toHaveStyle({ transform: 'translateY(-100%)' }); }); + it('flips the menu above the trigger when there is no room below', () => { + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 200 }); + render( + undefined} + />, + ); + + const trigger = screen.getByRole('button', { name: 'Rows per page' }); + // Trigger sits near the bottom edge of a short viewport. + trigger.getBoundingClientRect = () => new DOMRect(100, 170, 72, 32); + + fireEvent.click(trigger); + + expect(screen.getByRole('listbox').parentElement).toHaveStyle({ transform: 'translateY(-100%)' }); + }); + + it('keeps the menu below the trigger when there is room', () => { + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 }); + render( + undefined} + />, + ); + + const trigger = screen.getByRole('button', { name: 'Rows per page' }); + trigger.getBoundingClientRect = () => new DOMRect(100, 40, 72, 32); + + fireEvent.click(trigger); + + expect(screen.getByRole('listbox').parentElement).not.toHaveStyle({ transform: 'translateY(-100%)' }); + }); + it('portals the menu so overflow parents do not clip it', () => { render(
diff --git a/packages/trueforge-ui/test/atoms/schedules/SchedulesPage.test.tsx b/packages/trueforge-ui/test/atoms/schedules/SchedulesPage.test.tsx index 4daf4b537..43adb739e 100644 --- a/packages/trueforge-ui/test/atoms/schedules/SchedulesPage.test.tsx +++ b/packages/trueforge-ui/test/atoms/schedules/SchedulesPage.test.tsx @@ -97,12 +97,12 @@ function renderPage( , ); - return { scheduleServer }; + return { scheduleServer, searchAgents: server.searchAgents }; } describe('SchedulesPage', () => { it('lists schedules in the table', async () => { - const { scheduleServer } = renderPage(); + const { scheduleServer, searchAgents } = renderPage(); expect(await screen.findByRole('heading', { name: 'Scheduled Agents' })).toBeInTheDocument(); await waitFor(() => { expect(screen.getByText('daily-digest')).toBeInTheDocument(); @@ -111,6 +111,8 @@ describe('SchedulesPage', () => { expect(screen.getByText('—')).toBeInTheDocument(); expect(screen.getByText('Showing 1')).toBeInTheDocument(); expect(scheduleServer.listSchedules).toHaveBeenCalledWith(expect.objectContaining({ limit: 10 })); + // Agent catalog loads only when the filter opens (infinite scroll), not on mount. + expect(searchAgents).not.toHaveBeenCalled(); }); it('disables schedule mutations without MANAGE or DELETE', async () => { @@ -149,21 +151,31 @@ describe('SchedulesPage', () => { expect(screen.getByRole('button', { name: 'Run now daily-digest' })).toBeDisabled(); }); - it('disables global schedule creation when no listed agent has USE', async () => { - const listPermissions = vi.fn(async () => ({ data: { 'demo-agent': [] } })); + it('leaves Create enabled for All agents and gates USE when a specific agent is selected', async () => { + const listPermissions = vi.fn(async ({ resourceType }): Promise => ({ + data: resourceType === 'agent' ? { 'demo-agent': [] } : {}, + })); renderPage(sampleSchedules, {}, undefined, undefined, { permissions: { listPermissions }, }); + expect(await screen.findByRole('button', { name: 'Create Schedule' })).toBeEnabled(); + expect(listPermissions).not.toHaveBeenCalledWith( + expect.objectContaining({ resourceType: 'agent', resourceIds: expect.arrayContaining(['demo-agent']) }), + ); + + const filter = screen.getByRole('combobox', { name: 'Filter by agent' }); + fireEvent.focus(filter); + fireEvent.click(await screen.findByRole('option', { name: 'demo-agent' })); + await waitFor(() => { expect(listPermissions).toHaveBeenCalledWith({ resourceType: 'agent', resourceIds: ['demo-agent'], }); + expect(screen.getByRole('button', { name: 'Create Schedule' })).toBeDisabled(); }); - expect(screen.getByRole('button', { name: 'Create Schedule' })).toBeDisabled(); }); - it('locks embedded schedules to the supplied agent', async () => { const { scheduleServer } = renderPage(sampleSchedules, {}, undefined, undefined, { agentId: 'demo-agent' }); @@ -489,7 +501,7 @@ describe('SchedulesPage', () => { ]); expect(await screen.findByRole('columnheader', { name: 'Created by' })).toBeInTheDocument(); expect(screen.getByText('bob@example.com')).toBeInTheDocument(); - expect(document.querySelector('[data-slot="avatar-fallback"]')).toHaveTextContent('BO'); + expect(document.querySelector('[data-slot="avatar-fallback"]')).toHaveTextContent(/^B$/); }); it('keeps Created by when filters hide the row that has createdBySubject', async () => { diff --git a/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx b/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx index fab276647..f635c8ca5 100644 --- a/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx +++ b/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx @@ -160,7 +160,7 @@ describe('TrueForgeUI', () => { } const avatar = await screen.findByLabelText('Ada Lovelace'); - expect(avatar).toHaveTextContent('AL'); + expect(avatar.querySelector('[data-slot="avatar-fallback"]')).toHaveTextContent(/^A$/); expect(avatar).toHaveTextContent('Ada Lovelace'); if (layout === 'sidebar') { expect(avatar.closest('aside')).not.toBeNull();