Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/avatar-gradient-initial.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/message-indicator-thinking-orb.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@truefoundry/trueforge-ui': patch
---

Assistant message loading indicator uses ThinkingOrb with left-to-right shimmering Working... text.
5 changes: 5 additions & 0 deletions .changeset/popover-select-flip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@truefoundry/trueforge-ui': patch
---

Flip PopoverSelect menus when the preferred side lacks viewport room (e.g. table rows-per-page).
5 changes: 5 additions & 0 deletions .changeset/schedules-lazy-agents.md
Original file line number Diff line number Diff line change
@@ -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).
5 changes: 5 additions & 0 deletions .changeset/settings-nav-selected.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@truefoundry/trueforge-ui': patch
---

Settings sidebar uses a subtle primary tint for the selected section instead of a solid fill.
3 changes: 2 additions & 1 deletion packages/trueforge-ui/src/atoms/AttachmentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ export function AttachmentCard({
<div className="bg-secondary-bg relative size-full overflow-hidden rounded-[calc(var(--composer-radius,1.5rem)-var(--composer-padding,8px))] border border-primary-button-bg/20">
<Avatar className="size-full rounded-none">
<AvatarImage src={isImage ? previewSrc : undefined} alt={name} className="object-cover" />
<AvatarFallback className="rounded-none">
{/* bg-none drops the default gradient (bg-image group), which bg-secondary-bg alone would not override. */}
<AvatarFallback className="rounded-none bg-secondary-bg bg-none text-text-secondary">
<Icon name="file" size="1.5rem" className="text-text-secondary" />
</AvatarFallback>
</Avatar>
Expand Down
9 changes: 6 additions & 3 deletions packages/trueforge-ui/src/atoms/MessageIndicator.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ThinkingOrb } from 'thinking-orbs';

import { cn } from './lib/cn.js';

export type MessageIndicatorProps = {
Expand All @@ -8,10 +10,11 @@ export function MessageIndicator({ className }: MessageIndicatorProps) {
return (
<span
data-slot="aui_assistant-message-indicator"
className={cn('animate-pulse font-sans', className)}
aria-label="Assistant is working"
role="status"
className={cn('inline-flex items-center gap-1.5 font-sans text-sm text-text-secondary', className)}
>
{'●'}
<ThinkingOrb state="listening" size={20} aria-hidden />
<span className="aui-message-indicator-shimmer">Working...</span>
</span>
);
}
Expand Down
8 changes: 4 additions & 4 deletions packages/trueforge-ui/src/atoms/ThreadListRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -58,10 +58,10 @@ export function ThreadListRow({
})}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-text-primary">{title}</span>
<span className="block truncate text-sm font-normal text-text-primary">{title}</span>
{agentName != null ? (
<span className="mt-0.5 flex min-w-0 items-center gap-1 text-xs text-text-secondary">
<Icon name="agent-2" className="size-3 shrink-0" />
<span className="mt-0.5 flex min-w-0 items-center gap-1 text-[0.75rem] text-text-secondary">
<Icon name="bot" className="shrink-0" />
<span className="truncate">{agentName}</span>
</span>
) : null}
Expand Down
20 changes: 3 additions & 17 deletions packages/trueforge-ui/src/atoms/UserAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -46,9 +34,7 @@ export function UserAvatar({ labeled = false, className }: UserAvatarProps) {
)}
>
<Avatar size="sm">
<AvatarFallback className="bg-primary-button-bg text-primary-button-text">
{getUserInitials(displayName)}
</AvatarFallback>
<AvatarFallback>{getUserInitials(displayName)}</AvatarFallback>
</Avatar>
<span className="w-full truncate text-center text-[0.625rem] leading-tight">{displayName}</span>
</div>
Expand Down
8 changes: 4 additions & 4 deletions packages/trueforge-ui/src/atoms/primitives/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement> & {
Expand Down Expand Up @@ -52,7 +52,7 @@ export function AvatarFallback({ className, ...props }: AvatarFallbackProps) {
<div
data-slot="avatar-fallback"
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-secondary-bg font-medium text-text-secondary',
'flex h-full w-full items-center justify-center rounded-full bg-gradient-to-br from-primary-button-bg/20 to-primary-button-bg/10 font-medium text-primary-button-bg dark:from-primary-button-bg dark:to-primary-button-hover dark:text-primary-button-text',
className,
)}
{...props}
Expand Down
65 changes: 58 additions & 7 deletions packages/trueforge-ui/src/atoms/primitives/PopoverSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,40 @@ import {
} from '../lib/selectClasses.js';
import { themePortalRoot } from '../lib/themePortalRoot.js';

const MENU_GAP_PX = 4;
/** Matches `max-h-64` on the shared select menu chrome. */
const MENU_MAX_HEIGHT_PX = 256;

type MenuPlacement = 'top' | 'bottom';

function estimateMenuHeight({ optionCount, hasFooter }: { optionCount: number; hasFooter: boolean }): number {
// Approximate option row (`text-sm` + `py-1.5`) plus menu `p-1` padding.
const rows = Math.max(optionCount, 1) * 32;
const footer = hasFooter ? 40 : 0;
return Math.min(8 + rows + footer, MENU_MAX_HEIGHT_PX);
}

function resolveMenuPlacement({
preferred,
spaceAbove,
spaceBelow,
menuHeight,
}: {
preferred: MenuPlacement;
spaceAbove: number;
spaceBelow: number;
menuHeight: number;
}): MenuPlacement {
if (preferred === 'bottom') {
if (spaceBelow >= 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<T extends string> = {
value: T;
label: string;
Expand All @@ -25,8 +59,8 @@ type CommonPopoverSelectProps<T extends string> = {
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;
Expand All @@ -50,14 +84,16 @@ export type PopoverSelectProps<T extends string> = CommonPopoverSelectProps<T> &

export function PopoverSelect<T extends string>(props: PopoverSelectProps<T>) {
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<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const listboxRef = useRef<HTMLDivElement>(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) {
Expand All @@ -69,21 +105,36 @@ export function PopoverSelect<T extends string>(props: PopoverSelectProps<T>) {
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;
Expand Down Expand Up @@ -178,7 +229,7 @@ export function PopoverSelect<T extends string>(props: PopoverSelectProps<T>) {
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()}
>
Expand Down
49 changes: 13 additions & 36 deletions packages/trueforge-ui/src/atoms/schedules/SchedulesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 = {
Expand Down Expand Up @@ -166,7 +163,8 @@ export function SchedulesPage({ agentId }: SchedulesPageProps) {
const [runsByScheduleId, setRunsByScheduleId] = useState<Record<string, ScheduleRun[]>>({});
const [runsLoading, setRunsLoading] = useState(false);
const [runningScheduleIds, setRunningScheduleIds] = useState<ReadonlySet<string>>(() => new Set());
const [agentOptions, setAgentOptions] = useState<AgentOption[]>([]);
/** Names learned from picker picks; no mount-time catalog drain. */
const [agentLabelById, setAgentLabelById] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [nameQuery, setNameQuery] = useState(() => filtersFromSearch(window.location.search).nameQuery);
Expand All @@ -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<DrawerState>(() => initialDrawerState(agentId));
const [pendingDelete, setPendingDelete] = useState<Schedule | null>(null);
const [pageSize, setPageSize] = useState(() => clampPageSize(DEFAULT_TABLE_PAGE_SIZE));
Expand Down Expand Up @@ -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<string, string>();
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();
Expand Down Expand Up @@ -427,12 +398,18 @@ export function SchedulesPage({ agentId }: SchedulesPageProps) {
{agentId === undefined ? (
<AgentSearchPicker
value={agentFilter}
selectedLabel={agentFilter === 'all' ? 'All agents' : (agentNameById.get(agentFilter) ?? agentFilter)}
selectedLabel={agentFilter === 'all' ? 'All agents' : (agentLabelById[agentFilter] ?? agentFilter)}
onValueChange={value => {
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"
Expand Down Expand Up @@ -508,7 +485,7 @@ export function SchedulesPage({ agentId }: SchedulesPageProps) {
<TableBody>
{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 (
<TableRow key={schedule.id}>
<TableCell className="text-text-primary font-medium">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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={() => {
Expand Down
Loading
Loading