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