diff --git a/web/mobile/src/features/agents/AgentActivityFilterMenu.tsx b/web/mobile/src/features/agents/AgentActivityFilterMenu.tsx new file mode 100644 index 00000000000..89d01b5930d --- /dev/null +++ b/web/mobile/src/features/agents/AgentActivityFilterMenu.tsx @@ -0,0 +1,117 @@ +import {useMemo} from "react" + +import {useSessionFilters, type SessionStatusFilter} from "@agenta/sessions/state" +import {FilterMenu, type FilterMenuSection} from "@agenta/ui/filter-menu" +import {CalendarBlank, Clock, Minus, Rows, SquaresFour, Waveform} from "@phosphor-icons/react" + +import type {SessionActivityWindow} from "../sessions/sessionListView" + +import { + isDefaultAgentActivityView, + type AgentActivityGrouping, + type AgentActivityView, +} from "./agentActivityView" + +const ICON = 14 + +/** The status filters get the dot a row paints, so the filter and the row read alike. */ +const StatusDot = ({className}: {className: string}) => ( + +) + +/** + * The overview list's view control — the sessions page's menu minus the rows the page already + * answers: no Type (the tabs are the type) and no Agent (every row is this agent's). + * + * Status is the shared session atom, so a status set here is the one the sessions page shows + * too; the window and the grouping are this screen's own. + */ +export const AgentActivityFilterMenu = ({ + view, + onChange, + waitingCount, + onReset, +}: { + view: AgentActivityView + onChange: (view: AgentActivityView) => void + waitingCount?: number + onReset: () => void +}) => { + const {status, setStatus, archivedOnly, includeArchived} = useSessionFilters() + + const sections = useMemo( + () => [ + { + key: "status", + label: "Status", + icon: , + value: status, + options: [ + {value: "all", label: "All", icon: }, + { + value: "waiting", + label: waitingCount ? `Waiting ${waitingCount}` : "Waiting", + icon: , + }, + { + value: "running", + label: "Running", + icon: , + }, + { + value: "idle", + label: "Idle", + icon: , + }, + ], + onChange: (value) => setStatus(value as SessionStatusFilter), + }, + { + key: "activity", + label: "Last activity", + icon: , + value: view.activity, + options: [ + {value: "all", label: "All", icon: }, + {value: "24h", label: "Today", icon: }, + {value: "7d", label: "Last 7 days", icon: }, + {value: "30d", label: "Last 30 days", icon: }, + ], + onChange: (value) => onChange({...view, activity: value as SessionActivityWindow}), + }, + { + key: "group", + label: "Group by", + icon: , + block: "sort", + value: view.group, + // The default first, as the Status and window rows lead with theirs. + options: [ + {value: "none", label: "None", icon: }, + {value: "date", label: "Date", icon: }, + {value: "status", label: "Status", icon: }, + ], + onChange: (value) => onChange({...view, group: value as AgentActivityGrouping}), + }, + ], + [onChange, setStatus, status, view, waitingCount], + ) + + // The archived atoms narrow this query too, set from the sessions page — so they dot the + // trigger here, and Reset is the way back. + const active = + status !== "all" || archivedOnly || includeArchived || !isDefaultAgentActivityView(view) + + return ( + + ) +} diff --git a/web/mobile/src/features/agents/AgentActivityRowCells.tsx b/web/mobile/src/features/agents/AgentActivityRowCells.tsx new file mode 100644 index 00000000000..9dd6a78a532 --- /dev/null +++ b/web/mobile/src/features/agents/AgentActivityRowCells.tsx @@ -0,0 +1,136 @@ +import {useCallback, useMemo, type MouseEvent as ReactMouseEvent} from "react" + +import { + sessionAutomationKindLabel, + type SessionRowStatusMeta, + type SessionRowVm, +} from "@agenta/sessions/row" +import {InlineRenameInput, useInlineRename, type SessionMenuEntry} from "@agenta/sessions-ui" +import {timeAgo} from "@agenta/shared/utils" +import {ClockClockwise, Lightning} from "@phosphor-icons/react" + +import {cn} from "@/lib/utils" + +import {SessionRowMenu} from "../sessions/SessionRowMenu" + +/** Archived is a state the reader chose. The kebab keeps full weight; unarchiving lives there. */ +const FADED = "opacity-60" + +/** Filled while something is happening, a hollow ring when not. The word is in the tooltip. */ +const StatusDot = ({status}: {status: SessionRowStatusMeta}) => { + const live = status.status === "waiting" || status.status === "running" + return ( + + ) +} + +/** The automations page's two tints: orange for "when something happens", purple for a schedule. */ +const KIND_CHIP: Record<"subscription" | "schedule", string> = { + subscription: "bg-[var(--ag-preset-orange-bg)] text-[var(--ag-preset-orange-text)]", + schedule: "bg-[var(--ag-preset-purple-bg)] text-[var(--ag-preset-purple-text)]", +} + +/** A run's tile: the automations page's kind mark, a bolt for an event and a clock for a schedule. */ +const RunTile = ({vm}: {vm: SessionRowVm}) => { + // A run whose trigger record is gone still ran from one; the neutral tile says "a run". + const kind = vm.automation?.kind ?? null + return ( + + {kind === "schedule" ? ( + + ) : ( + + )} + + ) +} + +/** + * One activity row's cells, in column order: the mark, the title (or its rename editor), the + * time, the kebab. The sessions page's narrow row, with a run tile where the tab is runs. + */ +export const AgentActivityRowCells = ({ + vm, + entries, + onMenuSelect, + onRenameRow, +}: { + vm: SessionRowVm + entries: SessionMenuEntry[] + onMenuSelect: (vm: SessionRowVm, key: string) => void + onRenameRow: (vm: SessionRowVm, name: string) => Promise +}) => { + const onCommit = useCallback((name: string) => onRenameRow(vm, name), [onRenameRow, vm]) + const rename = useInlineRename({current: vm.title, onCommit}) + + const onSelect = useCallback( + (key: string) => { + // Deferred, not run here: the editor must not mount inside the menu's focus trap. + if (key === "rename") return () => rename.start() + onMenuSelect(vm, key) + }, + [onMenuSelect, rename, vm], + ) + + // The row opens the session; every control on it has to say so itself. + const swallow = useCallback((event: ReactMouseEvent) => event.stopPropagation(), []) + + const archived = Boolean(vm.stream.archived_at) + const updated = useMemo( + () => (vm.activityAt ? timeAgo(Date.parse(vm.activityAt)) : "—"), + [vm.activityAt], + ) + + return ( + <> + + {vm.isAutomation ? : } + {rename.renaming ? ( + + + + ) : ( + + {vm.title} + + )} + + + + {updated} + + + + + + + ) +} diff --git a/web/mobile/src/features/agents/AgentActivityTable.tsx b/web/mobile/src/features/agents/AgentActivityTable.tsx new file mode 100644 index 00000000000..6699f7ac1a9 --- /dev/null +++ b/web/mobile/src/features/agents/AgentActivityTable.tsx @@ -0,0 +1,160 @@ +import {useCallback, useMemo, useState} from "react" + +import type {SessionRowVm} from "@agenta/sessions/row" +import { + rowsFromPages, + useSessionFilters, + useSessionList, + useSessionsList, +} from "@agenta/sessions/state" +import {SessionListLoadMore} from "@agenta/sessions-ui" +import {ListTable, type ListTableColumn, type ListTableGroup} from "@agenta/ui/list-table" + +import type {SessionRowVerbs} from "../sessions/SessionListTable" +import {deriveSessionGroups} from "../sessions/sessionListView" +import {SessionsError} from "../sessions/states/SessionsError" +import {SessionsNoMatch} from "../sessions/states/SessionsNoMatch" + +import {AgentActivityRowCells} from "./AgentActivityRowCells" +import { + agentActivityPolicy, + type AgentActivityTab, + type AgentActivityView, +} from "./agentActivityView" +import {AgentActivityEmpty} from "./states/AgentActivityEmpty" + +/** The sessions page's phone columns: the title takes the row, the time sits against the kebab. */ +const columnsFor = (tab: AgentActivityTab): ListTableColumn[] => [ + {key: "title", label: tab === "runs" ? "Run" : "Session", width: "minmax(160px,1fr)"}, + {key: "updated", label: "Updated", width: "64px", headerClassName: "text-right"}, + {key: "actions", label: "Actions", srOnly: true, width: "24px"}, +] +const MIN_WIDTH = 272 + +/** + * One agent's activity, in the frame the sessions page uses: the tab pins the origin, the menu + * cuts and narrows the rest. Pins stay their own group at the top whatever the grouping is. + */ +export const AgentActivityTable = ({ + agentId, + view, + activityFloor, + verbs, + onClearSearch, + onResetView, +}: { + agentId: string + view: AgentActivityView + /** ISO floor from the Last activity facet; undefined = no bound. */ + activityFloor?: string + verbs: SessionRowVerbs + onClearSearch: () => void + onResetView: () => void +}) => { + const policy = useMemo(() => agentActivityPolicy(view.tab), [view.tab]) + const columns = useMemo(() => columnsFor(view.tab), [view.tab]) + const list = useSessionsList({ + agentId, + activityFloor, + defaultPolicy: policy, + automationPolicy: policy, + }) + // The APPLIED term, so the empty state can never name a search that has not run yet. + const term = useSessionFilters().search.trim() + + const [collapsed, setCollapsed] = useState>(() => new Set()) + const toggleGroup = useCallback( + (key: string) => + setCollapsed((current) => { + const next = new Set(current) + if (!next.delete(key)) next.add(key) + return next + }), + [], + ) + + const groups = useMemo[]>(() => { + const out: ListTableGroup[] = [] + for (const source of list.groups) { + if (source.key === "pinned") { + out.push({key: source.key, label: source.label ?? null, rows: source.rows}) + continue + } + // Flat under "none": the tab already names what the rows are. + if (view.group === "none") { + if (source.rows.length) out.push({key: source.key, label: null, rows: source.rows}) + continue + } + for (const derived of deriveSessionGroups(source.rows, view.group)) + out.push({...derived, key: `${source.key}:${derived.key}`}) + } + return out + }, [list.groups, view.group]) + + // Does this agent have ANY session of this kind? The page cannot tell "none yet" from + // "the filters hid them" out of what it holds, so it asks — the sessions page's own probe, + // scoped to the agent. Runs only while the list is empty. + const probe = useSessionList({ + agentId, + originPolicy: policy.origin, + expansions: [], + includeArchived: true, + limit: 1, + enabled: list.isEmpty && !list.isPlaceholder && !list.isPending, + }) + const agentHasRows = rowsFromPages(probe.data?.pages).length > 0 + + if (list.isError) return + + return ( +
+
+ vm.id} + onOpenRow={verbs.open} + collapsedKeys={collapsed} + onToggleGroup={toggleGroup} + empty={ + // Nothing while unsettled: the rows may be a previous query's, or the + // probe may not have said yet. A guess here tells a reader with sessions + // that they have none. + list.isPlaceholder || probe.isPending ? null : agentHasRows ? ( + + ) : ( + + ) + } + renderRow={(vm) => ( + + )} + /> + {list.paging.hasNext ? ( + + ) : null} +
+
+ ) +} diff --git a/web/mobile/src/features/agents/AgentActivityTabs.tsx b/web/mobile/src/features/agents/AgentActivityTabs.tsx new file mode 100644 index 00000000000..30dddc9f408 --- /dev/null +++ b/web/mobile/src/features/agents/AgentActivityTabs.tsx @@ -0,0 +1,50 @@ +import type {ReactNode} from "react" + +import {FOCUS_RING} from "@/lib/interactive" +import {cn} from "@/lib/utils" + +import type {AgentActivityTab} from "./agentActivityView" + +const TAB_BASE = + "box-border cursor-pointer appearance-none border-0 border-b-2 border-solid bg-transparent px-0.5 pb-2.5 font-[inherit] text-[15px] leading-none outline-none transition-colors " + + FOCUS_RING +const TAB_ON = "border-b-foreground font-semibold text-foreground" +const TAB_OFF = "border-b-transparent font-normal text-muted-foreground hover:text-foreground" + +const TABS: {key: AgentActivityTab; label: string}[] = [ + {key: "sessions", label: "Sessions"}, + // Co-equal with Sessions, not a filter of it: a run is one the user configured but did not start. + {key: "runs", label: "Automations"}, +] + +/** The activity list's rail: two tabs on a rule, Home's tab language, with the list's control at the far end. */ +export const AgentActivityTabs = ({ + tab, + onChange, + actions, +}: { + tab: AgentActivityTab + onChange: (tab: AgentActivityTab) => void + /** The filter menu, sitting on the rule's right. */ + actions?: ReactNode +}) => ( +
+ {TABS.map((entry) => ( + + ))} + {actions ? {actions} : null} +
+) diff --git a/web/mobile/src/features/agents/AgentAutomationsCard.tsx b/web/mobile/src/features/agents/AgentAutomationsCard.tsx new file mode 100644 index 00000000000..d1949816298 --- /dev/null +++ b/web/mobile/src/features/agents/AgentAutomationsCard.tsx @@ -0,0 +1,61 @@ +import {useUpcomingTriggers} from "@agenta/entity-ui/agent" +import {Clock, Lightning} from "@phosphor-icons/react" +import {useRouter} from "next/router" + +import {AgentOverviewCard} from "./AgentOverviewCard" +import {AgentOverviewCardRow} from "./AgentOverviewCardRow" +import {AgentOverviewCardError} from "./states/AgentOverviewCardError" +import {AgentOverviewCardSkeleton} from "./states/AgentOverviewCardSkeleton" + +const ICON = 16 + +/** + * What is going to run, soonest first — the agent's schedules and event subscriptions. Runs that + * HAPPENED are in the activity list; this answers "is anything coming". + */ +export const AgentAutomationsCard = ({ + agentId, + agentNames, + base, +}: { + agentId: string + agentNames?: ReadonlyMap + /** `/w/:workspace/p/:project` — a row opens its automation's screen. */ + base: string +}) => { + const router = useRouter() + const {rows, isLoading, hasError, retry} = useUpcomingTriggers({agentId, agentNames}) + + return ( + + {isLoading ? ( + + ) : hasError ? ( + + ) : rows.length === 0 ? ( +

+ No automations bound to this agent yet. +

+ ) : ( + rows.map((row) => ( + + ) : ( + + ) + } + label={row.name} + detail={row.detail} + title={row.tooltip} + onClick={() => void router.push(`${base}/automations/${row.id}`)} + className="text-[13.5px]" + /> + )) + )} +
+ ) +} diff --git a/web/mobile/src/features/agents/AgentComposer.tsx b/web/mobile/src/features/agents/AgentComposer.tsx index 08f4e3493ce..949c28c997a 100644 --- a/web/mobile/src/features/agents/AgentComposer.tsx +++ b/web/mobile/src/features/agents/AgentComposer.tsx @@ -51,9 +51,10 @@ export const AgentComposer = ({ return ( ) } diff --git a/web/mobile/src/features/agents/AgentConfigCard.tsx b/web/mobile/src/features/agents/AgentConfigCard.tsx new file mode 100644 index 00000000000..a2863072ca3 --- /dev/null +++ b/web/mobile/src/features/agents/AgentConfigCard.tsx @@ -0,0 +1,130 @@ +import {useMemo} from "react" + +import {composioLogo, PROVIDERS} from "@agenta/entities/workflow" +import {agentConfigSummary, agentLatestRevisionAtomFamily} from "@agenta/entity-ui/agent" +import {humanizeActionKey} from "@agenta/shared/utils" +import {LogoMarks} from "@agenta/ui/components/presentational" +import {Cpu, FileText, GraduationCap, Plugs, Wrench} from "@phosphor-icons/react" +import {useAtomValue} from "jotai" + +import {AgentOverviewCard} from "./AgentOverviewCard" +import {AgentOverviewCardRow} from "./AgentOverviewCardRow" +import {AgentOverviewCardError} from "./states/AgentOverviewCardError" +import {AgentOverviewCardSkeleton} from "./states/AgentOverviewCardSkeleton" + +const ICON = 16 +const INSTRUCTIONS_FILE = "AGENTS.md" +/** Marks past this collapse to "+N" — a phone row cannot hold a longer run. */ +const MAX_MARKS = 4 + +/** `openrouter/deepseek/deepseek-v4-flash` → `deepseek-v4-flash`: the row is for the model, and the + * route and provider in front of it are what pushed the model off the end. The full id stays in + * the tooltip. */ +const modelName = (id: string): string => id.split("/").filter(Boolean).pop() ?? id + +/** What this agent IS: the playground's config sections, one row each, less the permission default. */ +export const AgentConfigCard = ({ + agentId, + onEdit, +}: { + agentId: string + /** Opens the config for editing; every row leads there too. */ + onEdit: () => void +}) => { + // Configuration lives on a revision, not on the artifact. + const revisionAtom = useMemo(() => agentLatestRevisionAtomFamily(agentId), [agentId]) + const revision = useAtomValue(revisionAtom) + const summary = useMemo( + () => agentConfigSummary(revision.data?.data?.parameters), + [revision.data], + ) + + const marks = useMemo( + () => + summary.integrationKeys.map((key) => ({ + key, + name: PROVIDERS[key]?.label ?? humanizeActionKey(key), + logo: PROVIDERS[key]?.logo ?? composioLogo(key), + })), + [summary.integrationKeys], + ) + + // User MCP servers are a Claude-harness feature; on any other harness the row only offers a + // setting the runtime ignores — unless a server is already configured, which is worth saying. + const showMcp = summary.mcps > 0 || Boolean(summary.harness?.toLowerCase().includes("claude")) + + const skills = + summary.skillNames.length > 0 + ? summary.skillNames.join(", ") + : summary.skills + ? `${summary.skills} ${summary.skills === 1 ? "skill" : "skills"}` + : "No skills" + + return ( + + {revision.isPending ? ( + + ) : revision.isError ? ( + void revision.refetch()} + /> + ) : ( + <> + } + label="Model" + detail={summary.model ? modelName(summary.model) : "Choose a model"} + title={summary.model ?? undefined} + onClick={onEdit} + /> + } + label="Instructions" + detail={ + summary.instructions + ? `${INSTRUCTIONS_FILE} · ${summary.instructionWords}w` + : "Add instructions" + } + onClick={onEdit} + /> + } + label="Integrations" + detail={ + marks.length > 0 ? ( + + ) : summary.tools ? ( + `${summary.tools} enabled` + ) : ( + "No integrations" + ) + } + onClick={onEdit} + /> + {showMcp ? ( + } + label="MCP servers" + detail={summary.mcps ? `${summary.mcps} connected` : "Connect a server"} + onClick={onEdit} + /> + ) : null} + } + label="Skills" + detail={skills} + title={summary.skillNames.join(", ") || undefined} + onClick={onEdit} + /> + + )} + + ) +} diff --git a/web/mobile/src/features/agents/AgentDriveCard.tsx b/web/mobile/src/features/agents/AgentDriveCard.tsx new file mode 100644 index 00000000000..c7df7ffebe6 --- /dev/null +++ b/web/mobile/src/features/agents/AgentDriveCard.tsx @@ -0,0 +1,111 @@ +import {useMemo, useState} from "react" + +import {AGENT_FILES_DIR, agentMountQueryFamily, useSessionDrive} from "@agenta/entities/drive" +import {latestMountFilesQueryFamily, type MountFile} from "@agenta/entities/session" +import {File, Folder} from "@phosphor-icons/react" +import {useAtomValue} from "jotai" +import dynamic from "next/dynamic" + +import {AgentOverviewCard} from "./AgentOverviewCard" +import {AgentOverviewCardRow} from "./AgentOverviewCardRow" +import {AgentOverviewCardSkeleton} from "./states/AgentOverviewCardSkeleton" + +// The whole drive explorer, pulled in only once the drawer is actually opened. +const FilesDrawer = dynamic( + () => import("@agenta/entity-ui/drive").then((mod) => mod.FilesDrawer), + {ssr: false}, +) + +const ICON = 14 +/** The card shows this many, newest first, and never more: the drawer is where the whole drive lives. */ +const LIMIT = 5 + +const formatSize = (bytes: number | null | undefined): string | null => { + if (bytes == null) return null + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +const fileDetail = (file: MountFile): string | null => + file.is_folder + ? file.item_count != null + ? `${file.item_count} item${file.item_count === 1 ? "" : "s"}` + : null + : formatSize(file.size) + +/** + * The agent's OWN drive — the files it carries between runs, not a session's scratch mount. The + * same mount and file queries the shared card reads, so this and the drawer share one cache. + */ +export const AgentDriveCard = ({agentId}: {agentId: string}) => { + const [openPath, setOpenPath] = useState(null) + const [open, setOpen] = useState(false) + + const mountsAtom = useMemo(() => agentMountQueryFamily(agentId), [agentId]) + const mounts = useAtomValue(mountsAtom) + const mountId = mounts.data?.id ?? "" + + const filesAtom = useMemo( + () => latestMountFilesQueryFamily({mountId, limit: LIMIT, order: "recent"}), + [mountId], + ) + const files = useAtomValue(filesAtom) + // No session id: only the agent mount loads, and only once the drawer is open. + const drive = useSessionDrive("", open ? agentId : undefined) + + const rows: MountFile[] = files.data?.files ?? [] + const isPending = mounts.isPending || (Boolean(mountId) && files.isPending) + + const openDrive = (path: string | null) => { + setOpenPath(path) + setOpen(true) + } + + return ( + openDrive(null)} + > + {isPending ? ( + + ) : rows.length === 0 ? ( +

+ {mountId + ? "This agent isn't carrying any files yet." + : "This agent has no drive yet."} +

+ ) : ( + rows.map((file) => ( + : } + label={file.path.split("/").filter(Boolean).pop() || file.path} + detail={fileDetail(file)} + title={file.path} + // The drive folds the agent mount in under `agent-files/`. + onClick={() => openDrive(`${AGENT_FILES_DIR}/${file.path}`)} + className="py-1.5" + /> + )) + )} + + {open ? ( + { + setOpen(false) + setOpenPath(null) + }} + drive={drive} + scope="app" + initialPath={openPath} + driveIds={ + mountId ? [{key: "mount", label: "Drive ID", value: mountId}] : undefined + } + /> + ) : null} +
+ ) +} diff --git a/web/mobile/src/features/agents/AgentFilterMenu.tsx b/web/mobile/src/features/agents/AgentFilterMenu.tsx new file mode 100644 index 00000000000..602e34de22e --- /dev/null +++ b/web/mobile/src/features/agents/AgentFilterMenu.tsx @@ -0,0 +1,141 @@ +import {useMemo} from "react" + +import {FilterMenu, type FilterMenuItem} from "@agenta/ui/filter-menu" +import { + Archive, + Clock, + Minus, + Rows, + SquaresFour, + User, + Users, + Waveform, +} from "@phosphor-icons/react" + +import { + ALL_OWNERS, + DEFAULT_AGENT_LIST_VIEW, + isDefaultAgentListView, + type AgentGrouping, + type AgentListView, + type AgentStatusFilter, + type AgentTypeFilter, +} from "./agentListView" +import type {AgentOwner} from "./useAgentOwners" + +const ICON = 14 + +/** The Status options get the dot a row paints, so the filter and the badge read alike. */ +const StatusDot = ({className}: {className: string}) => ( + +) + +/** + * The agents roster's single view control: which roster is on screen and who made it, then how + * the rows are cut. + * + * Archived is a switch, the way the sessions menu's is: on, it shows the agents that were put away + * INSTEAD of the ones in use, never alongside them, so the page always answers a single question. + * + * Status is the ONE state an agent has here: Waiting when a session of its own is blocked on a + * person, Idle otherwise. No Running — nothing this client reads says an agent is mid-turn, and a + * row that can never be picked is worse than an absent one. Not the automations menu's + * working/paused either: that belongs to a trigger, and an agent has none. + * No sort row either: the roster is newest-first everywhere, and a reader after a name has the + * search field. + * + * Everything the shared `FilterMenu` knows about this screen arrives as props, so the package + * never learns what an agent is and this file never re-implements a row or a check mark. + */ +export const AgentFilterMenu = ({ + view, + onChange, + owners, +}: { + view: AgentListView + onChange: (view: AgentListView) => void + /** The org roster, as `{id, name}` in display order. */ + owners: AgentOwner[] +}) => { + const sections = useMemo( + () => [ + { + key: "owner", + label: "Created by", + icon: , + value: view.owner, + options: [ + {value: ALL_OWNERS, label: "Anyone", icon: }, + ...owners.map((owner) => ({ + value: owner.id, + label: owner.name, + icon: , + })), + ], + emptyText: "No members yet", + onChange: (value) => onChange({...view, owner: value}), + }, + { + key: "status", + label: "Status", + icon: , + value: view.status, + options: [ + {value: "all", label: "All", icon: }, + { + value: "waiting", + label: "Waiting", + icon: , + }, + { + value: "idle", + label: "Idle", + icon: , + }, + ], + onChange: (value) => onChange({...view, status: value as AgentStatusFilter}), + }, + { + kind: "toggle", + key: "archived", + label: "Only archived", + icon: , + checked: view.type === "archived", + onChange: (checked) => + onChange({...view, type: (checked ? "archived" : "active") as AgentTypeFilter}), + }, + { + key: "group", + label: "Group by", + icon: , + block: "sort", + value: view.group, + options: [ + {value: "none", label: "None", icon: }, + {value: "owner", label: "Created by", icon: }, + {value: "status", label: "Status", icon: }, + {value: "activity", label: "Last activity", icon: }, + ], + onChange: (value) => onChange({...view, group: value as AgentGrouping}), + }, + ], + [onChange, owners, view], + ) + + return ( + onChange(DEFAULT_AGENT_LIST_VIEW)} + resetDisabled={isDefaultAgentListView(view)} + /> + ) +} diff --git a/web/mobile/src/features/agents/AgentListScreen.tsx b/web/mobile/src/features/agents/AgentListScreen.tsx index 79cbc914575..80000366a8a 100644 --- a/web/mobile/src/features/agents/AgentListScreen.tsx +++ b/web/mobile/src/features/agents/AgentListScreen.tsx @@ -1,41 +1,67 @@ -import {useMemo} from "react" +import {useCallback, useMemo} from "react" import { agentRosterSearchAtom, agentWorkflowsListQueryStateAtom, matchesAgentQuery, + type Workflow, } from "@agenta/entities/workflow" -import {AgentRosterGrid, useAgentActions, type AgentRosterEntry} from "@agenta/entity-ui/agent" import {useWaitingByAgent} from "@agenta/sessions/state" import {pageContentWidthClass} from "@agenta/ui/components/page-width" -import {FilterRailLayout} from "@agenta/ui/components/presentational" -import {SearchInput} from "@agenta/ui/ui" +import {useFilterMenuView} from "@agenta/ui/filter-menu" +import {ListTableToolbar} from "@agenta/ui/list-table" +import {useQueryClient} from "@tanstack/react-query" import {useAtom, useAtomValue} from "jotai" -import Link from "next/link" import {useRouter} from "next/router" import {PageTitle} from "@/components/PageTitle" import {ScreenScaffold} from "@/components/ScreenScaffold" -import {BROWSE_RAIL_MODE} from "@/lib/browseLayout" import {useBindProjectContext} from "../context/useBindProjectContext" import {AppShell} from "../nav/AppShell" import {NavDrawer} from "../nav/NavDrawer" +import {AgentFilterMenu} from "./AgentFilterMenu" +import {AgentListTable} from "./AgentListTable" +import { + DEFAULT_AGENT_LIST_VIEW, + deriveAgentList, + isDefaultAgentFilters, + type AgentListRow, + type AgentListView, +} from "./agentListView" import {NewAgentAction} from "./NewAgentAction" +import {AgentsEmpty} from "./states/AgentsEmpty" +import {AgentsError} from "./states/AgentsError" +import {AgentsNoMatch} from "./states/AgentsNoMatch" +import {useAgentOwners} from "./useAgentOwners" +import {useArchivedAgents} from "./useArchivedAgents" import {useNewAgentAction} from "./useNewAgentAction" +/** The page column, shared with sessions and automations, so the nav entries line up. */ +const PAGE_FRAME = `${pageContentWidthClass} lg:px-16` + +/** The one place a workflow becomes a row: both views read what this resolves, never the raw + * workflow and never the lookup maps behind it. */ +const toRow = (workflow: Workflow, ownerName: string, waiting: number): AgentListRow => ({ + id: workflow.id, + name: workflow.name || workflow.slug || "Untitled agent", + description: workflow.description ?? null, + updatedAt: workflow.updated_at ?? workflow.created_at ?? null, + ownerName, + createdById: workflow.created_by_id ?? null, + waiting, +}) + /** * The full agent roster — where the nav's Agents entry lands. * - * The roster IS the shared `AgentRosterGrid`: the same cards, waiting badge, create cell and empty - * state the desktop Agents page renders, with the same rename and archive verbs from the shared - * `useAgentActions`. "Open in playground" is the one entry this app drops — /m has no playground - * route — and the shared card omits a menu entry whose handler is missing rather than offering a - * dead one. + * The roster is the shared `ListTable` — the frame the sessions and automations lists use — with + * this app's columns and the shared agent kebab, so a row and an agent's own header offer the + * same verbs. * - * Same browse shape as sessions and templates: title, create and search sit in a pinned toolbar - * above the results, so none of them scrolls away with the roster. + * Search and the filter menu sit in one toolbar above the results, the same row the automations + * list opens with. */ export const AgentListScreen = ({ workspaceId, @@ -51,114 +77,130 @@ export const AgentListScreen = ({ const query = useAtomValue(agentWorkflowsListQueryStateAtom) const [search, setSearch] = useAtom(agentRosterSearchAtom) const waitingByAgent = useWaitingByAgent() - // The shared agent verbs, the same ones the overview header's kebab binds. - const agentActions = useAgentActions() - // This list is already resolved client-side, so the term filters it in place — no refetch. - const agents = useMemo( - () => - (query.data ?? []) - .filter((agent) => matchesAgentQuery(agent, search)) - .map((agent) => ({ - id: agent.id, - name: agent.name || agent.slug || "Untitled agent", - description: agent.description, - updatedAt: agent.updated_at ?? agent.created_at ?? null, - })), - [query.data, search], - ) - const hasQuery = search.trim().length > 0 + const queryClient = useQueryClient() - // Identical content in both shells — a toolbar above the results, or the rail beside them. - // Toolbar shape mirrors the desktop Agents page: [title ... archived link], then - // [search ... create]. - const browseControls = ( -
-
- - {/* From `sm`, 24px is the desktop page-title rung; on a phone it eats the row, so - the title drops to the 16px body ramp — the same shape the sessions bar uses. */} -

- Agents -

- {/* The archived view is a destination, not a control on this list — it rides the - title row so the toolbar below carries only the search and the create action. */} - - Archived agents - -
+ // Grouping is a display preference a reader sets once; the filters are a question they were + // asking at the time, so only the first survives a reload. + const [view, setView] = useFilterMenuView({ + key: "agenta:agents:view", + fallback: DEFAULT_AGENT_LIST_VIEW, + persist: ["group"], + }) + + const showArchived = view.type === "archived" + const archived = useArchivedAgents({projectId, enabled: showArchived}) + // One cached request, shared with the settings screen: the Created by column and the facet + // both name creators, and neither can from an id alone. + const {owners, ownerNames} = useAgentOwners({workspaceId, projectId}) - {/* Desktop's toolbar axis (TableShell): search left and growing, action right. */} -
- - void newAgent.create()} - createFromTemplate={newAgent.createFromTemplate} - base={base} - creating={newAgent.creating} - error={newAgent.error} - align="end" - className="h-control-sm rounded-control-sm px-btn-sm text-btn-sm sm:h-control sm:rounded-control sm:px-btn sm:text-btn-md" - /> -
-
+ // This list is already resolved client-side, so the term filters it in place — no refetch. + // Matched on the WORKFLOW, not on the row: the shared rule reads the slug too, and the row + // has already resolved a name over it. + const rows = useMemo(() => { + // One roster at a time: Archived shows what was put away INSTEAD of what is in use. + const source = showArchived ? archived.agents : (query.data ?? []) + return source + .filter((workflow) => matchesAgentQuery(workflow, search)) + .map((workflow) => + toRow( + workflow, + ownerNames.get(workflow.created_by_id ?? "")?.trim() ?? "", + waitingByAgent.get(workflow.id) ?? 0, + ), + ) + }, [archived.agents, ownerNames, query.data, search, showArchived, waitingByAgent]) + + const groups = useMemo(() => deriveAgentList(rows, view), [rows, view]) + const term = search.trim() + // The archived fetch must not blank rows already on screen: a skeleton over a list that was + // showing nine agents reads as "they went away". It only owns the skeleton when there is + // nothing else to draw — which is exactly the "Only archived" case. + const isLoading = query.isPending || (archived.isPending && rows.length === 0) + // "This project has no agents" is a claim about the project, so it is only ever made about + // the unnarrowed list — a search that matches nothing empties this one too. + const projectHasAgents = (query.data ?? []).length > 0 + const openRow = useCallback( + (row: AgentListRow) => void router.push(`${base}/agents/${row.id}`), + [base, router], + ) + const failed = showArchived ? archived.isError : query.isError + const refetchArchived = archived.refetch + const retry = useCallback(() => { + if (showArchived) void refetchArchived() + // The live roster is a query atom with no refetch handle of its own; the key it is + // cached under is the one `useAgentActions` invalidates after a write. + else void queryClient.invalidateQueries({queryKey: ["workflows"]}) + }, [queryClient, refetchArchived, showArchived]) + const resetFilters = useCallback( + // The grouping survives: it is how the reader chose to read the list, not what they + // narrowed it to. + () => setView({...DEFAULT_AGENT_LIST_VIEW, group: view.group}), + [setView, view.group], ) - const roster = ( - void newAgent.create()} - onOpenOverview={(agent) => void router.push(`${base}/agents/${agent.id}`)} - onRename={(agent) => agentActions.rename({id: agent.id, name: agent.name})} - onArchive={(agent) => agentActions.remove({id: agent.id, name: agent.name})} - emptyText={ - hasQuery - ? `No agents match "${search.trim()}".` - : "Agents you create will show up here." + const emptyState = isLoading ? null : projectHasAgents ? ( + setSearch("") : isDefaultAgentFilters(view) ? undefined : resetFilters } /> + ) : ( + + ) + + const body = failed ? ( + // A failed fetch must not read as an empty project, so the error replaces the results + // rather than sitting under a header row that is no longer describing anything. + + ) : ( + ) return ( <> - {/* Toolbar by default (#5833/#5846) — beside this app's nav rail a filter rail is - the second sidebar those PRs removed. Nothing is lost either way: this surface - has no facets, only an identity row and a search box. */} - - {BROWSE_RAIL_MODE ? ( - - {roster} - - ) : ( + - {roster} +
+ + {/* From `sm`, 24px is the desktop page-title rung; on a phone it + eats the row, so the title drops to the 16px body ramp. */} +

+ Agents +

+ void newAgent.create()} + createFromTemplate={newAgent.createFromTemplate} + base={base} + creating={newAgent.creating} + error={newAgent.error} + align="end" + className="h-control-sm rounded-control-sm px-btn-sm text-btn-sm sm:h-control sm:rounded-control sm:px-btn sm:text-btn-md" + /> +
- )} + } + > +
+ {/* Search belongs to the list, not to the page: it sits on the results' + own left edge so it reads as the control that narrows what is below + it. One control beside it, not three: the facets and the grouping are + rows inside it, so the bar stays a search bar. */} + + } + /> + {body} +
diff --git a/web/mobile/src/features/agents/AgentListTable.tsx b/web/mobile/src/features/agents/AgentListTable.tsx new file mode 100644 index 00000000000..2f8a055778a --- /dev/null +++ b/web/mobile/src/features/agents/AgentListTable.tsx @@ -0,0 +1,112 @@ +import {useCallback, useState} from "react" +import type {ReactNode} from "react" + +import {useMediaQuery} from "@agenta/ui/hooks" +import {ListTable, type ListTableColumn} from "@agenta/ui/list-table" + +import type {AgentListGroup, AgentListRow} from "./agentListView" +import {AgentRowCells} from "./AgentRowCells" + +/** + * The columns, shared by the header row and every body row so the two can never drift. + * + * Identity takes twice the share of the reading columns; the kebab is fixed at the button's own + * width so the reading columns keep their proportions. + * + * A phone drops Created by rather than scrolling it off. Four columns in 375px left the name a + * dozen characters, and on a roster this size the creator is the column a reader consults last — + * the filter menu still asks the question, and Group by → Created by answers it in the headings. + * + * Every column reads from its own left edge, header and cells alike: nothing here is a number, + * and a right-aligned word column has a ragged edge exactly where the eye starts. + */ +const AGENT_COLUMN: ListTableColumn = { + key: "agent", + label: "Agent", + width: "minmax(160px,2fr)", +} +const OWNER_COLUMN: ListTableColumn = { + key: "owner", + label: "Created by", + width: "minmax(96px,1fr)", +} +const lastActiveColumn = (width: string): ListTableColumn => ({ + key: "lastActive", + label: "Last active", + width, +}) +const ACTIONS_COLUMN: ListTableColumn = { + key: "actions", + label: "Actions", + srOnly: true, + width: "24px", +} + +const WIDE_COLUMNS: ListTableColumn[] = [ + AGENT_COLUMN, + OWNER_COLUMN, + lastActiveColumn("96px"), + ACTIONS_COLUMN, +] +const NARROW_COLUMNS: ListTableColumn[] = [AGENT_COLUMN, lastActiveColumn("76px"), ACTIONS_COLUMN] + +/** Tailwind's `sm`. Below it Created by goes; the minima then fit a 375px screen — 76px is + * what the "Last active" header needs to stay on one line beside a "15d ago" cell. */ +const WIDE_QUERY = "(min-width: 640px)" +const WIDE_MIN_WIDTH = 400 +const NARROW_MIN_WIDTH = 300 + +/** + * The agents roster as a table — the list half of the two views, in the same frame the sessions + * and automations lists use. + * + * Every row answers what it is, who made it and when it last changed, and the whole row opens the + * overview because there is nothing else on a row to click. The kebab is the SHARED agent menu, + * so a row and an agent's own header offer the same verbs. + */ +export const AgentListTable = ({ + groups, + isLoading, + onOpen, + empty, +}: { + groups: AgentListGroup[] + isLoading: boolean + onOpen: (row: AgentListRow) => void + empty: ReactNode +}) => { + // Picks the COLUMN SET, not a `display` value: header and body read one array. Asked as + // "wide?" because the shared hook starts false before it reads the viewport, and a phone + // is the common case here — the wide-first paint was visible. + const narrow = !useMediaQuery(WIDE_QUERY) + + const [collapsed, setCollapsed] = useState>(() => new Set()) + const toggleGroup = useCallback( + (key: string) => + setCollapsed((current) => { + const next = new Set(current) + if (!next.delete(key)) next.add(key) + return next + }), + [], + ) + + return ( + row.id} + onOpenRow={onOpen} + collapsedKeys={collapsed} + onToggleGroup={toggleGroup} + empty={empty} + renderRow={(row) => } + /> + ) +} diff --git a/web/mobile/src/features/agents/AgentOverviewBody.tsx b/web/mobile/src/features/agents/AgentOverviewBody.tsx new file mode 100644 index 00000000000..12700426138 --- /dev/null +++ b/web/mobile/src/features/agents/AgentOverviewBody.tsx @@ -0,0 +1,138 @@ +import {useCallback, useMemo} from "react" + +import {AgentOverviewLayout} from "@agenta/entity-ui/agent" +import {resetSessionFiltersAtom, sessionSearchAtom, useSessionsList} from "@agenta/sessions/state" +import {useFilterMenuView} from "@agenta/ui/filter-menu" +import {useSetAtom} from "jotai" + +import {useScrollFade} from "@/lib/useScrollFade" + +import type {SessionRowVerbs} from "../sessions/SessionListTable" +import {activityFloorIso} from "../sessions/sessionListView" + +import {AgentActivityFilterMenu} from "./AgentActivityFilterMenu" +import {AgentActivityTable} from "./AgentActivityTable" +import {AgentActivityTabs} from "./AgentActivityTabs" +import { + agentActivityPolicy, + DEFAULT_AGENT_ACTIVITY_VIEW, + type AgentActivityView, +} from "./agentActivityView" +import {AgentAutomationsCard} from "./AgentAutomationsCard" +import {AgentComposer} from "./AgentComposer" +import {AgentConfigCard} from "./AgentConfigCard" +import {AgentDriveCard} from "./AgentDriveCard" + +/** + * The overview's body, on the shared two-column arrangement: the composer over the activity + * tabs on the left, the agent's own state as three soft cards on the right — Configuration, + * Files, Automations — and the two stacked below `lg`. + */ +export const AgentOverviewBody = ({ + agentId, + agentName, + base, + agentNames, + verbs, + onEditConfig, +}: { + agentId: string + agentName: string + /** `/w/:workspace/p/:project` */ + base: string + agentNames: ReadonlyMap + verbs: SessionRowVerbs + onEditConfig: () => void +}) => { + // The grouping is a preference; the tab, the window and the status are the question of the moment. + const [view, setView] = useFilterMenuView({ + key: "agenta:agent-overview:view", + fallback: DEFAULT_AGENT_ACTIVITY_VIEW, + persist: ["group"], + }) + // Once per facet change, never per render — a `Date.now()` floor would re-key the query forever. + const activityFloor = useMemo(() => activityFloorIso(view.activity), [view.activity]) + + // The SAME arguments the table passes, so both resolve to one query; here only for the count. + const policy = useMemo(() => agentActivityPolicy(view.tab), [view.tab]) + const list = useSessionsList({ + agentId, + activityFloor, + defaultPolicy: policy, + automationPolicy: policy, + }) + + // Home's edge fade: the list says there is more above or below instead of ending in a line. + const fade = useScrollFade() + + const setSearch = useSetAtom(sessionSearchAtom) + const resetFilters = useSetAtom(resetSessionFiltersAtom) + const clearSearch = useCallback(() => setSearch(""), [setSearch]) + const resetView = useCallback(() => { + resetFilters() + setView({...DEFAULT_AGENT_ACTIVITY_VIEW, tab: view.tab}) + }, [resetFilters, setView, view.tab]) + const setTab = useCallback( + (tab: AgentActivityView["tab"]) => setView({...view, tab}), + [setView, view], + ) + + return ( + + +
+ + } + /> + {/* The one scroller. `-mx-3 px-3` is net zero on the content and gives + the rows' 12px hover bleed somewhere to land — a scrollport clips at + its padding box. */} +
+ +
+
+ + } + rail={ + // Only as a side rail. Below `lg` the layout would stack the cards under the + // list, where three of them read as a second page; the agent's own state is a + // tap away in the session workspace. +
+ + + +
+ } + /> + ) +} diff --git a/web/mobile/src/features/agents/AgentOverviewCard.tsx b/web/mobile/src/features/agents/AgentOverviewCard.tsx new file mode 100644 index 00000000000..c0f556d12a5 --- /dev/null +++ b/web/mobile/src/features/agents/AgentOverviewCard.tsx @@ -0,0 +1,41 @@ +import type {ReactNode} from "react" + +import {cn} from "@/lib/utils" + +/** The rail card's action link: 13px, secondary, no chrome — a word at the title's right. */ +const ACTION_CLASS = + "m-0 shrink-0 cursor-pointer appearance-none border-0 bg-transparent p-0 font-[inherit] text-[13px] leading-none text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:text-foreground" + +/** + * One card of the agent overview's rail — Configuration, Files, Automations. A soft fill on the + * page, no border, so the three read as one column of the agent's own state rather than three + * framed panels competing with the activity list beside them. + */ +export const AgentOverviewCard = ({ + title, + action, + onAction, + children, + className, +}: { + title: string + /** The one verb the card offers — "Edit", "Open drive". */ + action?: string + onAction?: () => void + children: ReactNode + className?: string +}) => ( +
+
+

+ {title} +

+ {action && onAction ? ( + + ) : null} +
+ {children} +
+) diff --git a/web/mobile/src/features/agents/AgentOverviewCardRow.tsx b/web/mobile/src/features/agents/AgentOverviewCardRow.tsx new file mode 100644 index 00000000000..1999a1ddec6 --- /dev/null +++ b/web/mobile/src/features/agents/AgentOverviewCardRow.tsx @@ -0,0 +1,72 @@ +import type {ReactNode} from "react" + +import {cn} from "@/lib/utils" + +/** A rail card's row: a mark, a label, and the fact at the right. Rows bleed 8px into the card's padding so the hover fill has room. */ +export const AgentOverviewCardRow = ({ + icon, + label, + detail, + onClick, + title, + className, +}: { + icon: ReactNode + label: ReactNode + /** The fact on the right — text, or a run of marks. */ + detail?: ReactNode + /** Absent ⇒ the row is a fact, not a control. */ + onClick?: () => void + title?: string + className?: string +}) => { + const body = ( + <> + + {icon} + + {/* The label takes what the fact leaves: a long file or automation name truncates, + while a long model id stops at just over half the row so the label stays whole. */} + + {label} + + {typeof detail === "string" ? ( + + {detail} + + ) : detail ? ( + {detail} + ) : null} + + ) + const shared = cn( + "-mx-2 box-border flex w-[calc(100%+16px)] items-center gap-3 rounded-lg px-2 py-[7px]", + className, + ) + // A div with the button role, not a + )} + + {/* Only beside the rail: a narrower header has no room, and the composer is the same verb. */} + + {/* Held back until the record lands: until then the destructive verbs would act on + an agent whose name is unknown. No slug on purpose: the menu offers Copy Slug only + when it has one, and this screen does not want it. */} + {pending ? null : ( + handleSelect("rename")} + onEditDescription={wide ? () => handleSelect("describe") : undefined} + onCloseAutoFocus={handleCloseAutoFocus} + className="mt-0.5 lg:mt-0.5" + /> + )} + + ) +} diff --git a/web/mobile/src/features/agents/AgentRowCells.tsx b/web/mobile/src/features/agents/AgentRowCells.tsx new file mode 100644 index 00000000000..f1f9e2a4a34 --- /dev/null +++ b/web/mobile/src/features/agents/AgentRowCells.tsx @@ -0,0 +1,124 @@ +import {useCallback} from "react" + +import {AgentActionsMenu, AgentChip, useRenameAgent} from "@agenta/entity-ui/agent" +import {InlineRenameInput, useDeferredMenuSelect, useInlineRename} from "@agenta/sessions-ui" + +import {lastActiveLabel, NO_DESCRIPTION, type AgentListRow} from "./agentListView" + +/** + * One agent's cells, in column order. + * + * A component rather than a closure inside the table's `renderRow`, because a row holds state: + * rename happens IN PLACE, the way a session row renames, so the row owns the editor and the + * menu entry that starts it. + */ +export const AgentRowCells = ({ + row, + narrow, + onOpen, +}: { + row: AgentListRow + /** A phone has no Created by column; the cell is dropped, not hidden. */ + narrow: boolean + /** The row's own click already does this; the menu offers it in words. */ + onOpen: (row: AgentListRow) => void +}) => { + const renameAgent = useRenameAgent() + const onCommit = useCallback((name: string) => renameAgent(row.id, name), [renameAgent, row.id]) + const rename = useInlineRename({ + current: row.name, + onCommit, + errorText: "Couldn't rename this agent", + }) + // The editor must not mount inside the menu's focus trap — Radix would restore focus to the + // trigger as the menu closes, and a blur commits — so the verb runs from the close instead. + const {handleSelect, handleCloseAutoFocus} = useDeferredMenuSelect((key) => { + if (key === "rename") return () => rename.start() + }) + + return ( + <> + + {/* The agent's own mark, not a generic robot — a column of identical icons + identifies nothing. */} + + {/* Tight leading and a hairline gap: the frame's own row padding is fixed and + shared, so the two lines are where a roster row can give height back. */} + + {rename.renaming ? ( + event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + + ) : ( + + { + event.stopPropagation() + rename.start() + }} + > + {row.name} + + {/* The same badge a card carries: without it the Status facet + narrows the list to rows that say nothing about why. */} + {row.waiting > 0 ? ( + + {row.waiting} waiting + + ) : null} + + )} + {/* A described agent and an undescribed one have to be the same shape, or a + column of rows jumps height by height as you read down it. */} + + {row.description || NO_DESCRIPTION} + + + + + {narrow ? null : ( + + {row.ownerName || "—"} + + )} + + + {lastActiveLabel(row.updatedAt)} + + + {/* The menu's own clicks are not the row's: without this every menu press would also + open the overview. */} + event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + onOpen(row)} + onRename={() => handleSelect("rename")} + onCloseAutoFocus={handleCloseAutoFocus} + /> + + + ) +} diff --git a/web/mobile/src/features/agents/agentActivityView.ts b/web/mobile/src/features/agents/agentActivityView.ts new file mode 100644 index 00000000000..82929f1b636 --- /dev/null +++ b/web/mobile/src/features/agents/agentActivityView.ts @@ -0,0 +1,36 @@ +import type {SessionListRequestPolicy} from "@agenta/sessions/state" + +import type {SessionActivityWindow, SessionGrouping} from "../sessions/sessionListView" + +/** The two lists the overview's tabs switch between. */ +export type AgentActivityTab = "sessions" | "runs" + +/** How the overview's rows are cut. No "agent": every row here is this agent's. */ +export type AgentActivityGrouping = Exclude + +export interface AgentActivityView { + tab: AgentActivityTab + group: AgentActivityGrouping + activity: SessionActivityWindow +} + +/** Flat and unbounded: the page is one agent's whole history, not a project-wide search. */ +export const DEFAULT_AGENT_ACTIVITY_VIEW: AgentActivityView = { + tab: "sessions", + group: "none", + activity: "all", +} + +/** The tab is the subject, not a filter — it never counts as "narrowed". */ +export const isDefaultAgentActivityView = (view: AgentActivityView): boolean => + view.group === DEFAULT_AGENT_ACTIVITY_VIEW.group && + view.activity === DEFAULT_AGENT_ACTIVITY_VIEW.activity + +/** + * The origin policy a tab pins. Passed as BOTH policies to `useSessionsList` so the tab, not the + * shared Type facet, decides what this list shows — the sessions page's mode must not leak here. + */ +export const agentActivityPolicy = (tab: AgentActivityTab): SessionListRequestPolicy => + tab === "runs" + ? {origin: "trigger-only", expansions: ["trigger"]} + : {origin: "exclude-trigger", expansions: []} diff --git a/web/mobile/src/features/agents/agentListView.ts b/web/mobile/src/features/agents/agentListView.ts new file mode 100644 index 00000000000..56c1091ced4 --- /dev/null +++ b/web/mobile/src/features/agents/agentListView.ts @@ -0,0 +1,161 @@ +import {timeAgo} from "@agenta/shared/utils" + +/** + * How the agents roster is CUT and NARROWED — the half of the view the filter menu owns, kept + * out of the screen so the screen renders what these return rather than deriving it mid-render. + * The same split `sessionListView.ts` makes next door. + * + * Every predicate here runs on rows this client already holds: the roster arrives whole (the + * apps list is deliberately unpaged), so there is nothing to push to the server. `type` is the + * exception — it picks WHICH roster the screen fetches, so by the time rows reach here they all + * match it and a predicate for it would filter nothing. + */ + +export type AgentGrouping = "none" | "owner" | "status" | "activity" + +/** Which roster is on screen. One at a time: an agent is either in use or put away. */ +export type AgentTypeFilter = "active" | "archived" + +/** + * The only state an agent has on this surface. + * + * Not the design's Active/Paused/Needs-attention — those belong to an automation, which has a + * trigger to be paused. An agent is either holding a session that needs a person or it is not, + * and that is the amber badge a row already paints. + */ +export type AgentStatusFilter = "all" | "waiting" | "idle" + +/** `all` is every creator; anything else is a user id. */ +export type AgentOwnerFilter = string + +export interface AgentListView { + owner: AgentOwnerFilter + type: AgentTypeFilter + status: AgentStatusFilter + group: AgentGrouping +} + +export const ALL_OWNERS = "all" + +export const DEFAULT_AGENT_LIST_VIEW: AgentListView = { + owner: ALL_OWNERS, + type: "active", + status: "all", + group: "none", +} + +/** The view control's dot: the filters only, never the grouping — cutting a list into runs + * hides nothing, and a trigger dotted for that would cry wolf. */ +export const isDefaultAgentFilters = (view: AgentListView): boolean => + view.owner === DEFAULT_AGENT_LIST_VIEW.owner && + view.type === DEFAULT_AGENT_LIST_VIEW.type && + view.status === DEFAULT_AGENT_LIST_VIEW.status + +export const isDefaultAgentListView = (view: AgentListView): boolean => + isDefaultAgentFilters(view) && view.group === DEFAULT_AGENT_LIST_VIEW.group + +/** What both views need of an agent, resolved once by the screen. */ +export interface AgentListRow { + id: string + name: string + description: string | null + updatedAt: string | null + /** The creator's display name, resolved once by the screen; empty when unknown. */ + ownerName: string + createdById: string | null + /** Sessions blocked on a person for this agent — the amber badge, and the Status facet. */ + waiting: number +} + +export interface AgentListGroup { + key: string + label: string | null + rows: AgentListRow[] +} + +const DAY_MS = 86_400_000 + +/** The activity buckets the design names, in reading order. */ +const ACTIVITY_BUCKETS = ["This week", "This month", "Older"] as const + +const WAITING = "Waiting" +const IDLE = "Idle" +/** The status buckets, in the order they matter: what needs a person comes first. */ +const STATUS_BUCKETS = [WAITING, IDLE] as const + +/** A creator this client cannot name. A heading is read, not looked up — never the raw uuid. */ +const UNKNOWN_OWNER = "Unknown" + +const activityBucket = (updatedAt: string | null, now: number): string => { + const at = updatedAt ? Date.parse(updatedAt) : NaN + if (!Number.isFinite(at)) return "Older" + const age = now - at + if (age <= 7 * DAY_MS) return "This week" + if (age <= 30 * DAY_MS) return "This month" + return "Older" +} + +/** + * The heading a group is drawn under, per grouping. An empty order sorts alphabetically, which + * is what creator headings want and what a bucket outside a fixed order falls back to. + */ +const GROUP_ORDER: Record = { + none: [], + owner: [], + status: STATUS_BUCKETS, + activity: ACTIVITY_BUCKETS, +} + +/** What a row and a cell both say where an agent has never been described. */ +export const NO_DESCRIPTION = "No description" + +/** The roster's "Last active" cell. Empty rows read as an em dash, not as "just now". */ +export const lastActiveLabel = (updatedAt: string | null): string => + updatedAt ? timeAgo(Date.parse(updatedAt)) : "—" + +const matchesAgentOwner = (row: AgentListRow, owner: AgentOwnerFilter): boolean => + owner === ALL_OWNERS || row.createdById === owner + +const matchesAgentStatus = (row: AgentListRow, status: AgentStatusFilter): boolean => { + if (status === "waiting") return row.waiting > 0 + if (status === "idle") return row.waiting === 0 + return true +} + +const groupLabel = (row: AgentListRow, group: AgentGrouping, now: number): string => { + if (group === "owner") return row.ownerName || UNKNOWN_OWNER + if (group === "status") return row.waiting > 0 ? WAITING : IDLE + return activityBucket(row.updatedAt, now) +} + +/** The filtered rows, cut into groups. */ +export const deriveAgentList = ( + rows: AgentListRow[], + view: AgentListView, + now: number = Date.now(), +): AgentListGroup[] => { + const kept = rows.filter( + (row) => matchesAgentOwner(row, view.owner) && matchesAgentStatus(row, view.status), + ) + + if (view.group === "none") return [{key: "all", label: null, rows: kept}] + + const buckets = new Map() + for (const row of kept) { + const label = groupLabel(row, view.group, now) + const existing = buckets.get(label) + if (existing) existing.push(row) + else buckets.set(label, [row]) + } + + const order = GROUP_ORDER[view.group] + // A label the order does not name sorts after the ones it does, then alphabetically. + const rank = (label: string) => { + const index = order.indexOf(label) + return index < 0 ? order.length : index + } + + return [...buckets.keys()] + .sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)) + .map((label) => ({key: label, label, rows: buckets.get(label) ?? []})) +} diff --git a/web/mobile/src/features/agents/states/AgentActivityEmpty.tsx b/web/mobile/src/features/agents/states/AgentActivityEmpty.tsx new file mode 100644 index 00000000000..653accbf1f3 --- /dev/null +++ b/web/mobile/src/features/agents/states/AgentActivityEmpty.tsx @@ -0,0 +1,34 @@ +import {ChatCircleDots, Lightning} from "@phosphor-icons/react" + +import type {AgentActivityTab} from "../agentActivityView" + +const COPY: Record = { + sessions: { + title: "No sessions yet", + body: "Conversations with this agent will show up here. Start one from the composer above.", + }, + runs: { + title: "No automation runs yet", + body: "Runs from automations bound to this agent will show up here.", + }, +} + +/** The tab's list has nothing in it — under a header row that is still true, so no frame of its own. */ +export const AgentActivityEmpty = ({tab}: {tab: AgentActivityTab}) => { + const copy = COPY[tab] + return ( +
+ + {tab === "runs" ? ( + + ) : ( + + )} + +

{copy.title}

+

+ {copy.body} +

+
+ ) +} diff --git a/web/mobile/src/features/agents/states/AgentOverviewCardError.tsx b/web/mobile/src/features/agents/states/AgentOverviewCardError.tsx new file mode 100644 index 00000000000..10cb20ec319 --- /dev/null +++ b/web/mobile/src/features/agents/states/AgentOverviewCardError.tsx @@ -0,0 +1,22 @@ +import {RefreshCw} from "lucide-react" + +/** A rail card whose section failed to load: one line and the way back, inside the card's own fill. */ +export const AgentOverviewCardError = ({ + message, + onRetry, +}: { + message: string + onRetry: () => void +}) => ( +
+

{message}

+ +
+) diff --git a/web/mobile/src/features/agents/states/AgentOverviewCardSkeleton.tsx b/web/mobile/src/features/agents/states/AgentOverviewCardSkeleton.tsx new file mode 100644 index 00000000000..ed9cc2cd80a --- /dev/null +++ b/web/mobile/src/features/agents/states/AgentOverviewCardSkeleton.tsx @@ -0,0 +1,10 @@ +import {Skeleton} from "@/components/ui/skeleton" + +/** Rows in flight, at the row's own 34px rhythm so the facts land without a shift. */ +export const AgentOverviewCardSkeleton = ({rows = 3}: {rows?: number}) => ( +
+ {Array.from({length: rows}, (_, index) => ( + + ))} +
+) diff --git a/web/mobile/src/features/agents/states/AgentsEmpty.tsx b/web/mobile/src/features/agents/states/AgentsEmpty.tsx new file mode 100644 index 00000000000..87df8aacb6f --- /dev/null +++ b/web/mobile/src/features/agents/states/AgentsEmpty.tsx @@ -0,0 +1,24 @@ +import {Robot} from "@phosphor-icons/react" + +/** + * No agents at all. + * + * Sits INSIDE the table, under the header row, like its sessions and automations counterparts: + * the columns are still true, and a project with no agents is a table with no rows rather than a + * different screen. So it carries no card and no frame of its own. + * + * No button either — New agent is already pinned in the bar above, and a second create control + * two rows below the first is not a shorter path. + */ +export const AgentsEmpty = () => ( +
+ + + +

No agents yet

+

+ An agent is something you can chat with and hand work to. Create one from the button + above, blank or from a template. +

+
+) diff --git a/web/mobile/src/features/agents/states/AgentsError.tsx b/web/mobile/src/features/agents/states/AgentsError.tsx new file mode 100644 index 00000000000..c7aa9355d14 --- /dev/null +++ b/web/mobile/src/features/agents/states/AgentsError.tsx @@ -0,0 +1,23 @@ +import {RefreshCw, TriangleAlert} from "lucide-react" + +import {Button} from "@/components/ui/button" + +/** + * The roster failed to load. + * + * Framed, unlike the empty states: those sit under a header row that is still true, while this + * one replaces the results because there is nothing left standing to hold them. + * + * It exists so a failed fetch cannot read as an empty project — "No agents yet" is a claim, and + * a request that never answered is not evidence for it. + */ +export const AgentsError = ({onRetry}: {onRetry: () => void}) => ( +
+ +

Could not load agents.

+ +
+) diff --git a/web/mobile/src/features/agents/states/AgentsNoMatch.tsx b/web/mobile/src/features/agents/states/AgentsNoMatch.tsx new file mode 100644 index 00000000000..af1d023af9a --- /dev/null +++ b/web/mobile/src/features/agents/states/AgentsNoMatch.tsx @@ -0,0 +1,43 @@ +import {Funnel, MagnifyingGlass} from "@phosphor-icons/react" + +import {Button} from "@/components/ui/button" + +/** + * The project has agents, but none the reader asked for. + * + * Distinct from `AgentsEmpty`: a project whose agents a filter has hidden must not be told it + * has none, and the way out is the control that narrowed it — so this carries the action rather + * than leaving the reader to work out which of three rows is set. + */ +export const AgentsNoMatch = ({ + term, + onClear, +}: { + /** The search that matched nothing. Absent ⇒ the filters are what narrowed it. */ + term?: string + onClear?: () => void +}) => ( +
+ + {term ? : } + +

+ {term ? `Nothing matches “${term}”` : "No agents match these filters"} +

+

+ {term + ? "Try a shorter search, or check the filters — they narrow this list too." + : "Try widening the filters, or including archived agents."} +

+ {onClear ? ( + + ) : null} +
+) diff --git a/web/mobile/src/features/agents/useAgentOwners.ts b/web/mobile/src/features/agents/useAgentOwners.ts new file mode 100644 index 00000000000..5e29abc91b3 --- /dev/null +++ b/web/mobile/src/features/agents/useAgentOwners.ts @@ -0,0 +1,58 @@ +import {useMemo} from "react" + +import {fetchSingleOrg} from "@agenta/entities/organization" +import {useQuery} from "@tanstack/react-query" + +import {useCurrentProject} from "../context/useCurrentProject" + +export interface AgentOwner { + id: string + name: string +} + +/** + * Who can have created an agent — the org's member roster, named the way the roster's rows and + * the Created by facet both need it. + * + * The roster lives on the org's default workspace, not behind a members endpoint, and the + * settings screen already reads it under this exact key: arriving here from Settings costs no + * request, and the two surfaces cannot disagree about a name. + */ +export const useAgentOwners = ({ + workspaceId, + projectId, +}: { + workspaceId: string + projectId: string +}) => { + const project = useCurrentProject(workspaceId, projectId) + const organizationId = project?.organization_id ?? null + + const org = useQuery({ + queryKey: ["selectedOrg", organizationId], + queryFn: () => fetchSingleOrg({organizationId: organizationId!}), + enabled: Boolean(organizationId), + staleTime: 60_000, + }) + + return useMemo(() => { + const members = org.data?.default_workspace?.members ?? [] + const owners: AgentOwner[] = [] + const ownerNames = new Map() + + for (const member of members) { + const id = member.user?.id ? String(member.user.id) : "" + if (!id) continue + // Everyone by their own name, the reader included: "You" among named colleagues + // makes one column say two different kinds of thing. + const name = (member.user.username || member.user.email || "").trim() + if (!name) continue + ownerNames.set(id, name) + owners.push({id, name}) + } + + owners.sort((a, b) => a.name.localeCompare(b.name)) + + return {owners, ownerNames} + }, [org.data]) +} diff --git a/web/mobile/src/features/agents/useArchivedAgents.ts b/web/mobile/src/features/agents/useArchivedAgents.ts new file mode 100644 index 00000000000..0c8e29ecc6e --- /dev/null +++ b/web/mobile/src/features/agents/useArchivedAgents.ts @@ -0,0 +1,56 @@ +import { + ensureAgentFlags, + queryWorkflows, + selectAgentWorkflows, + type Workflow, +} from "@agenta/entities/workflow" +import {useQuery} from "@tanstack/react-query" + +/** + * Archived agents — a SECOND query, deliberately, and only when the reader asks for them. + * + * The roster's own list (`agentWorkflowsListQueryStateAtom`) cannot serve this: it is built on + * the shared apps query, which omits `include_archived` because the nav rail reads "absent from + * that list" as "archived" (#6457). Flipping the flag there would break the rail, so the archived + * set is fetched on its own and merged by the screen. + * + * Agent identity is revision-derived — an archived artifact carries no `is_agent` of its own — + * so the artifacts are checked against the project's shared classification map. That map already + * covers archived workflows and is fetched once per project, so this is one round trip for the + * artifacts and, usually, a cache hit for the flags. `enabled` is still a prop: nothing here runs + * until the Archived facet leaves its default. + */ +/** Stable, so a screen memoising on `agents` does not recompute on every render while disabled. */ +const NONE: Workflow[] = [] + +export const useArchivedAgents = ({projectId, enabled}: {projectId: string; enabled: boolean}) => { + const query = useQuery({ + // Under `agent-workflows` on purpose: `useAgentActions` invalidates that prefix after an + // archive, so an agent this reader just archived leaves the live list and joins this one + // in the same pass. A key of its own would have dropped it from both for a stale time. + queryKey: ["agent-workflows", "archived", projectId], + queryFn: async (): Promise => { + const response = await queryWorkflows({ + projectId, + flags: {is_evaluator: false}, + includeArchived: true, + }) + const archived = (response.workflows ?? []).filter((workflow) => workflow.deleted_at) + if (archived.length === 0) return [] + const agentFlags = await ensureAgentFlags(projectId) + return selectAgentWorkflows(archived, agentFlags) + }, + enabled: enabled && Boolean(projectId), + staleTime: 30_000, + }) + + return { + agents: query.data ?? NONE, + // Pending only counts while the query is actually running: a disabled query is pending + // forever in TanStack, and a list that never stops loading is worse than one without + // archived rows. + isPending: enabled && query.isPending, + isError: query.isError, + refetch: query.refetch, + } +} diff --git a/web/mobile/src/features/sessions/SessionListTable.tsx b/web/mobile/src/features/sessions/SessionListTable.tsx index f0a25544628..b9f0e065b03 100644 --- a/web/mobile/src/features/sessions/SessionListTable.tsx +++ b/web/mobile/src/features/sessions/SessionListTable.tsx @@ -191,6 +191,8 @@ export const SessionListTable = ({ // the minima fit every width this page is read at, so the frame's own // horizontal scroller was never doing anything. stickyHeader + // Compact for the same reason it is sticky: this is the long list. + density="compact" loading={list.isPending} groups={groups} rowKey={(vm) => vm.id} diff --git a/web/mobile/src/features/sessions/SessionRowCells.tsx b/web/mobile/src/features/sessions/SessionRowCells.tsx index c0a4a1fe328..306fa438dba 100644 --- a/web/mobile/src/features/sessions/SessionRowCells.tsx +++ b/web/mobile/src/features/sessions/SessionRowCells.tsx @@ -136,7 +136,7 @@ export const SessionRowCells = ({ `focus-visible`: the editor is focused the moment it mounts. */}
) : ( diff --git a/web/mobile/src/features/sessions/states/SessionsPageSkeleton.tsx b/web/mobile/src/features/sessions/states/SessionsPageSkeleton.tsx index 571fa487551..687074366b4 100644 --- a/web/mobile/src/features/sessions/states/SessionsPageSkeleton.tsx +++ b/web/mobile/src/features/sessions/states/SessionsPageSkeleton.tsx @@ -54,7 +54,7 @@ export const SessionsPageSkeleton = () => ( key={row} // The wide tracks: this renders before the viewport is measured, and the // Agent bar hides below `sm` as the real table does. - className="grid w-full grid-cols-[minmax(160px,2fr)_minmax(120px,1fr)_96px] items-center gap-3 px-2 py-[13px]" + className="grid w-full grid-cols-[minmax(160px,2fr)_minmax(120px,1fr)_96px] items-center gap-3 px-2 py-2" > diff --git a/web/mobile/src/features/settings/SettingsScreen.tsx b/web/mobile/src/features/settings/SettingsScreen.tsx index 3c8ca7ae599..64aba772f19 100644 --- a/web/mobile/src/features/settings/SettingsScreen.tsx +++ b/web/mobile/src/features/settings/SettingsScreen.tsx @@ -11,13 +11,7 @@ import { } from "@agenta/entities/organization" import {useProfile} from "@agenta/entities/profile" import {fetchAllProjects} from "@agenta/entities/project" -import { - getSettingsTabDescription, - getSettingsTabDocs, - getSettingsTabLabel, - getSettingsTabVariant, - type SettingsTabKey, -} from "@agenta/settings" +import {getSettingsTabVariant, type SettingsTabKey} from "@agenta/settings" import {useApiKeys, type SettingsAccess} from "@agenta/settings" import { AccessControlsSection, @@ -40,6 +34,12 @@ import {useRouter} from "next/router" import {ContentRail} from "@/components/ContentRail" import {PageTitle} from "@/components/PageTitle" import {ScreenScaffold} from "@/components/ScreenScaffold" +import { + getMobileSettingsTabDescription, + getMobileSettingsTabDocs, + getMobileSettingsTabLabel, + INTEGRATIONS_SECTION_COPY, +} from "@/lib/integrationsCopy" import {useBindProjectContext} from "../context/useBindProjectContext" import {AppShell} from "../nav/AppShell" @@ -213,7 +213,7 @@ const TabBody = ({ if (!access.canShowTools) return null return ( <> - + {confirmModal} ) @@ -355,9 +355,9 @@ export const SettingsScreen = ({ desktop widths this app now serves. */} { - const tabs = getSettingsSidebarTabs(access).filter( - (tab) => AVAILABLE_SETTINGS_TABS.includes(tab.key) && !tab.isHidden, + const tabs = withMobileSettingsLabels( + getSettingsSidebarTabs(access).filter( + (tab) => AVAILABLE_SETTINGS_TABS.includes(tab.key) && !tab.isHidden, + ), ) return SETTINGS_SCOPES.map((scope) => ({ ...scope, diff --git a/web/mobile/src/features/settings/settingsNavScope.tsx b/web/mobile/src/features/settings/settingsNavScope.tsx index 321b508fc63..3148fdda918 100644 --- a/web/mobile/src/features/settings/settingsNavScope.tsx +++ b/web/mobile/src/features/settings/settingsNavScope.tsx @@ -16,6 +16,8 @@ import { import {useAtomValue} from "jotai" import {useRouter} from "next/router" +import {withMobileSettingsLabels} from "@/lib/integrationsCopy" + import {DrawerProjectSwitcher} from "../nav/DrawerProjectSwitcher" import {lastNonSettingsPathAtom} from "../nav/lastNonSettingsPath" import {useMobileBottomNavItems} from "../nav/useMobileNavItems" @@ -61,8 +63,10 @@ const createSettingsNavScope = (workspaceId: string, projectId: string): Sidebar return useMemo( () => [ ...buildSettingsSidebarSections( - getSettingsSidebarTabs(access).filter((tab) => - AVAILABLE_SETTINGS_TABS.includes(tab.key), + withMobileSettingsLabels( + getSettingsSidebarTabs(access).filter((tab) => + AVAILABLE_SETTINGS_TABS.includes(tab.key), + ), ), // A real href per tab: the drawer closes on link clicks, and the controlled // selection intercepts the navigation before it happens. diff --git a/web/mobile/src/lib/integrationsCopy.ts b/web/mobile/src/lib/integrationsCopy.ts new file mode 100644 index 00000000000..1f980d0cfb0 --- /dev/null +++ b/web/mobile/src/lib/integrationsCopy.ts @@ -0,0 +1,49 @@ +import { + getSettingsTabDescription, + getSettingsTabDocs, + getSettingsTabLabel, + type SettingsAccess, + type SettingsTabDocs, + type SettingsTabKey, +} from "@agenta/settings" + +/** This app says "Integrations" where the shared copy says "Tools", which oss/ee still use. */ +const TAB_LABELS: Partial> = { + tools: "Integrations", +} + +const TAB_DESCRIPTIONS: Partial> = { + tools: "Connect the integrations your agents can use.", +} + +const TAB_DOCS_LABELS: Partial> = { + tools: "About integrations", +} + +export const getMobileSettingsTabLabel = (key: SettingsTabKey, access: SettingsAccess) => + TAB_LABELS[key] ?? getSettingsTabLabel(key, access) + +export const getMobileSettingsTabDescription = (key: SettingsTabKey, access: SettingsAccess) => + TAB_DESCRIPTIONS[key] ?? getSettingsTabDescription(key, access) + +export const getMobileSettingsTabDocs = (key: SettingsTabKey): SettingsTabDocs | undefined => { + const docs = getSettingsTabDocs(key) + const label = TAB_DOCS_LABELS[key] + return docs && label ? {...docs, label} : docs +} + +/** Relabels sidebar/rail entries in place, so both nav surfaces read the same as the page. */ +export const withMobileSettingsLabels = ( + tabs: T[], +): T[] => tabs.map((tab) => (TAB_LABELS[tab.key] ? {...tab, title: TAB_LABELS[tab.key]!} : tab)) + +/** Copy for the shared `GatewayToolsSection`, whose defaults still say "tool" for oss/ee. */ +export const INTEGRATIONS_SECTION_COPY = { + integrationColumn: "Integration", + run: "Run action", + searchPlaceholder: "Search integrations", + connect: "Connect integration", + emptyTitle: "No integrations connected yet", + emptyBody: "Connect an integration to let your agents call it.", + noMatch: (term: string) => `No integrations match “${term}”`, +} as const diff --git a/web/mobile/src/lib/useScrollFade.ts b/web/mobile/src/lib/useScrollFade.ts new file mode 100644 index 00000000000..79e7db74f99 --- /dev/null +++ b/web/mobile/src/lib/useScrollFade.ts @@ -0,0 +1,42 @@ +import {useCallback, useEffect, useRef, useState, type CSSProperties} from "react" + +/** + * The list's own scroll state, as an edge fade — Home's list region, extracted. A hard edge at + * the bottom of a capped box reads as the end of the list; the fade is the signal that there is + * more below, and the top one that there is more above. + * + * Measured on every render, not only on scroll: content height changes under a capped box + * without resizing it, so a list that overflows on arrival has to show the fade before it is + * ever touched (a ResizeObserver on the scroller never fires for that). + */ +export const useScrollFade = () => { + const ref = useRef(null) + const [mask, setMask] = useState("none") + + const readScroll = useCallback(() => { + const el = ref.current + if (!el) return + const max = el.scrollHeight - el.clientHeight + const top = el.scrollTop > 4 + const bottom = max > 4 && el.scrollTop < max - 4 + const next = + !top && !bottom + ? "none" + : `linear-gradient(to bottom, transparent 0, #000 ${top ? "26px" : "0"}, #000 ${ + bottom ? "calc(100% - 34px)" : "100%" + }, transparent 100%)` + // Idempotent, so this is safe to call on every render. + setMask((current) => (current === next ? current : next)) + }, []) + + useEffect(readScroll) + + // The window's width changes how many rows fit; the height they occupy is what the mask reads. + useEffect(() => { + window.addEventListener("resize", readScroll) + return () => window.removeEventListener("resize", readScroll) + }, [readScroll]) + + const style: CSSProperties = {maskImage: mask, WebkitMaskImage: mask} + return {ref, onScroll: readScroll, style} +} diff --git a/web/mobile/tests/unit/agentListView.test.ts b/web/mobile/tests/unit/agentListView.test.ts new file mode 100644 index 00000000000..9f133f06a83 --- /dev/null +++ b/web/mobile/tests/unit/agentListView.test.ts @@ -0,0 +1,117 @@ +import {describe, expect, it} from "vitest" + +import { + ALL_OWNERS, + DEFAULT_AGENT_LIST_VIEW, + deriveAgentList, + isDefaultAgentFilters, + isDefaultAgentListView, + lastActiveLabel, + type AgentListRow, + type AgentListView, +} from "../../src/features/agents/agentListView" + +const NOW = new Date("2026-09-10T12:00:00Z").getTime() +const daysAgo = (days: number) => new Date(NOW - days * 86_400_000).toISOString() + +const row = ( + id: string, + { + updatedAt = daysAgo(1), + createdById = "u1", + ownerName = "You", + waiting = 0, + }: { + updatedAt?: string | null + createdById?: string | null + ownerName?: string + waiting?: number + } = {}, +): AgentListRow => ({id, name: id, description: null, updatedAt, ownerName, createdById, waiting}) + +const view = (patch: Partial = {}): AgentListView => ({ + ...DEFAULT_AGENT_LIST_VIEW, + ...patch, +}) + +const labels = (groups: {label: string | null}[]) => groups.map((group) => group.label) +const ids = (groups: {rows: AgentListRow[]}[]) => + groups.flatMap((group) => group.rows.map((r) => r.id)) + +describe("isDefaultAgentListView", () => { + it("counts the grouping as non-default, but not as a filter", () => { + expect(isDefaultAgentListView(view({group: "activity"}))).toBe(false) + expect(isDefaultAgentFilters(view({group: "activity"}))).toBe(true) + }) + + it("counts a narrowed creator or a swapped roster", () => { + expect(isDefaultAgentFilters(view({owner: "u2"}))).toBe(false) + expect(isDefaultAgentFilters(view({type: "archived"}))).toBe(false) + expect(isDefaultAgentFilters(view({status: "waiting"}))).toBe(false) + }) +}) + +describe("deriveAgentList — filtering", () => { + // `type` is not a predicate here: the screen fetches ONE roster, so every row already + // matches it by the time it arrives. + const rows = [row("mine", {createdById: "u1"}), row("theirs", {createdById: "u2"})] + + it("narrows to one creator, and `all` keeps everyone", () => { + expect(ids(deriveAgentList(rows, view({owner: "u1"}), NOW))).toEqual(["mine"]) + expect(ids(deriveAgentList(rows, view({owner: ALL_OWNERS}), NOW))).toHaveLength(2) + }) + + it("narrows to the agents holding something for a person", () => { + const waiting = [row("busy", {waiting: 2}), row("quiet")] + expect(ids(deriveAgentList(waiting, view({status: "waiting"}), NOW))).toEqual(["busy"]) + expect(ids(deriveAgentList(waiting, view({status: "idle"}), NOW))).toEqual(["quiet"]) + expect(ids(deriveAgentList(waiting, view(), NOW))).toEqual(["busy", "quiet"]) + }) +}) + +describe("deriveAgentList — grouping", () => { + it("draws no heading at all when grouping is off", () => { + expect(labels(deriveAgentList([row("a")], view(), NOW))).toEqual([null]) + }) + + it("orders the activity buckets newest-first and drops empty ones", () => { + const rows = [ + row("old", {updatedAt: daysAgo(90)}), + row("recent", {updatedAt: daysAgo(2)}), + row("mid", {updatedAt: daysAgo(20)}), + ] + const groups = deriveAgentList(rows, view({group: "activity"}), NOW) + expect(labels(groups)).toEqual(["This week", "This month", "Older"]) + expect(ids(groups)).toEqual(["recent", "mid", "old"]) + }) + + it("buckets a row with no timestamp as Older rather than as brand new", () => { + const groups = deriveAgentList( + [row("undated", {updatedAt: null})], + view({group: "activity"}), + NOW, + ) + expect(labels(groups)).toEqual(["Older"]) + }) + + it("puts the agents holding something for a person above the quiet ones", () => { + const rows = [row("quiet"), row("busy", {waiting: 1})] + const groups = deriveAgentList(rows, view({group: "status"}), NOW) + expect(labels(groups)).toEqual(["Waiting", "Idle"]) + expect(ids(groups)).toEqual(["busy", "quiet"]) + }) + + it("names creator headings, and falls back for a creator it cannot name", () => { + const rows = [row("mine", {ownerName: "You"}), row("theirs", {ownerName: ""})] + expect(labels(deriveAgentList(rows, view({group: "owner"}), NOW))).toEqual([ + "Unknown", + "You", + ]) + }) +}) + +describe("lastActiveLabel", () => { + it("reads as an em dash when an agent has no timestamp", () => { + expect(lastActiveLabel(null)).toBe("—") + }) +}) diff --git a/web/packages/agenta-entities/src/workflow/agentTemplates.ts b/web/packages/agenta-entities/src/workflow/agentTemplates.ts index d1b309058d5..a8c51e785b9 100644 --- a/web/packages/agenta-entities/src/workflow/agentTemplates.ts +++ b/web/packages/agenta-entities/src/workflow/agentTemplates.ts @@ -94,9 +94,10 @@ export interface AgentStarterTemplate { requiredIntegrations: RequiredIntegration[] } -/** Provider slug → display label + brand logo URL (Composio logo CDN, the tool catalog's source). */ -const composioLogo = (slug: string) => `https://logos.composio.dev/api/${slug}` +/** An integration slug's brand logo URL (Composio logo CDN, the tool catalog's source). */ +export const composioLogo = (slug: string) => `https://logos.composio.dev/api/${slug}` +/** Provider slug → display label + brand logo URL. */ export const PROVIDERS: Record = { github: {label: "GitHub", logo: composioLogo("github")}, gitlab: {label: "GitLab", logo: composioLogo("gitlab")}, diff --git a/web/packages/agenta-entities/src/workflow/api/api.ts b/web/packages/agenta-entities/src/workflow/api/api.ts index 954b0a34380..30a0ba5b69c 100644 --- a/web/packages/agenta-entities/src/workflow/api/api.ts +++ b/web/packages/agenta-entities/src/workflow/api/api.ts @@ -1057,8 +1057,10 @@ export async function updateWorkflow( projectId: string, payload: UpdateWorkflowPayload, ): Promise { - // Update workflow metadata if non-data fields changed - const hasMetadataChanges = payload.name || payload.description || payload.flags || payload.tags + // Update workflow metadata if non-data fields changed. Description is checked for presence, + // not truth: an empty string is how a description is cleared. + const hasMetadataChanges = + payload.name || payload.description !== undefined || payload.flags || payload.tags if (hasMetadataChanges) { await axios.put( `${getAgentaApiUrl()}/workflows/${payload.id}`, diff --git a/web/packages/agenta-entities/src/workflow/index.ts b/web/packages/agenta-entities/src/workflow/index.ts index c3433908d54..a1de1d485a7 100644 --- a/web/packages/agenta-entities/src/workflow/index.ts +++ b/web/packages/agenta-entities/src/workflow/index.ts @@ -490,6 +490,7 @@ export { agentTemplateSeed, categoryFromSlug, categorySlug, + composioLogo, templateBuilderMessage, templateCategories, templateProviderSlugs, diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index b5ea02f5ff6..f0790f2acb2 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -438,6 +438,8 @@ export interface WorkflowListRef { deleted_at: string | null created_at: string | null updated_at: string | null + /** Creator user id — the "Created by" column and facet on the agents roster. */ + created_by_id: string | null } /** @@ -461,6 +463,7 @@ export function toWorkflowListRef(w: Workflow): WorkflowListRef { deleted_at: w.deleted_at ?? null, created_at: w.created_at ?? null, updated_at: w.updated_at ?? null, + created_by_id: w.created_by_id ?? null, } } @@ -2867,6 +2870,7 @@ export function seedCreatedWorkflowCache( deleted_at: revision.deleted_at ?? null, created_at: revision.created_at ?? null, updated_at: revision.updated_at ?? null, + created_by_id: revision.created_by_id ?? null, } store.set(workflowLocalServerDataAtomFamily(revision.id), revision) diff --git a/web/packages/agenta-entity-ui/src/agent/AgentActionsMenu.tsx b/web/packages/agenta-entity-ui/src/agent/AgentActionsMenu.tsx index 2a2a2a2fd1b..8e023a8df5d 100644 --- a/web/packages/agenta-entity-ui/src/agent/AgentActionsMenu.tsx +++ b/web/packages/agenta-entity-ui/src/agent/AgentActionsMenu.tsx @@ -2,39 +2,60 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@agenta/ui/ui" -import {Copy, DotsThreeVertical, PencilSimple, Trash} from "@phosphor-icons/react" +import { + Archive, + Copy, + DotsThreeVertical, + Note, + PencilSimple, + TextAlignLeft, +} from "@phosphor-icons/react" import {useAgentActions, type AgentActionTarget} from "./useAgentActions" export interface AgentActionsMenuProps { agent: AgentActionTarget + /** + * Opens the agent's configuration. Omit on that page itself — a surface that cannot go + * anywhere must not offer the trip. + */ + onOpen?: () => void /** * Host overrides. The desktop has its own app-management modals (which also refresh its apps * cache), so it passes them; a host without one falls through to [[useAgentActions]]. */ onRename?: () => void + /** Edits the description in place. Absent means no item: the desktop edits it in its rename modal. */ + onEditDescription?: () => void + /** Named for the prop's history; the verb it stands for is Archive. */ onDelete?: () => void /** Custom-workflow "Configure" — only the desktop has that flow, so absent means no item. */ onConfigure?: () => void /** Which edge the menu hangs from. A trigger near the right edge wants `end`. */ align?: "start" | "end" + /** The close's focus restore. A host whose rename mounts an editor suppresses it here. */ + onCloseAutoFocus?: (event: Event) => void className?: string } /** - * THE agent kebab: rename, copy id, copy slug, delete. One definition for the desktop overview - * header and the mobile screen, so the two can't offer different verbs for the same object. - * Copying is entirely the menu's business; rename and delete defer to the host when it has a - * richer flow. + * THE agent kebab: open, rename, copy slug, archive. One definition for the desktop overview + * header and the mobile screens, so they can't offer different verbs for the same object. + * Copying is entirely the menu's business; open, rename and archive defer to the host — and an + * entry whose handler is missing is dropped rather than offered dead. */ export const AgentActionsMenu = ({ agent, + onOpen, onRename, + onEditDescription, onDelete, onConfigure, align = "start", + onCloseAutoFocus, className, }: AgentActionsMenuProps) => { const actions = useAgentActions() @@ -51,7 +72,19 @@ export const AgentActionsMenu = ({ - + {/* Sized to its longest verb rather than to a number: at a fixed 180 "Open + configuration" wrapped to two lines and stood twice as tall as the rows under it. */} + + {onOpen ? ( + + + Open configuration + + ) : null} {onConfigure ? ( @@ -63,22 +96,29 @@ export const AgentActionsMenu = ({ Rename )} - void actions.copy(agent.id, "ID")}> - - Copy ID - + {onEditDescription ? ( + + + Edit description + + ) : null} {agent.slug ? ( void actions.copy(agent.slug!, "Slug")}> Copy Slug ) : null} + {/* Set apart: everything above is reversible in a keystroke, and this one takes + the agent off the roster. */} + + {/* Archive, not Delete: the verb behind it has always been `archiveWorkflow`, + and the modal it opens is already titled "Archive". */} actions.remove(agent))} > - - Delete + + Archive agent diff --git a/web/packages/agenta-entity-ui/src/agent/AgentCard.tsx b/web/packages/agenta-entity-ui/src/agent/AgentCard.tsx index fa92008663b..2e55bd5799c 100644 --- a/web/packages/agenta-entity-ui/src/agent/AgentCard.tsx +++ b/web/packages/agenta-entity-ui/src/agent/AgentCard.tsx @@ -9,7 +9,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@agenta/ui/ui" -import {DotsThreeIcon, Note, PencilSimple, Rocket, Trash} from "@phosphor-icons/react" +import {Archive, DotsThreeIcon, Note, PencilSimple, Rocket} from "@phosphor-icons/react" import {useAgentIconChrome} from "./agentIcon" import {Tip} from "./Tip" @@ -163,9 +163,11 @@ export const AgentCard = ({ event.stopPropagation()}> + {/* The same words and marks [[AgentActionsMenu]] uses: one object, one set of + verbs, whichever surface offers them. */} - Open overview + Open configuration {onOpenPlayground ? ( @@ -183,8 +185,8 @@ export const AgentCard = ({ <> - - Archive + + Archive agent ) : null} diff --git a/web/packages/agenta-entity-ui/src/agent/AgentConfigSummaryCard.tsx b/web/packages/agenta-entity-ui/src/agent/AgentConfigSummaryCard.tsx index a36560b0ca3..33078d23c27 100644 --- a/web/packages/agenta-entity-ui/src/agent/AgentConfigSummaryCard.tsx +++ b/web/packages/agenta-entity-ui/src/agent/AgentConfigSummaryCard.tsx @@ -41,15 +41,40 @@ const emptyAction = (label: string) => ({summary: label, status: "default" as co // required-but-empty warning keeps a color. const stated = (summary: string) => ({summary, status: "default" as const}) +/** The tools row's noun; a host that calls it something else passes its own. */ +export interface AgentConfigSummaryCopy { + toolsTitle: string + toolsCount: (count: number) => string + toolsAdd: string + toolsNone: string +} + +const DEFAULT_COPY: AgentConfigSummaryCopy = { + toolsTitle: "Tools", + toolsCount: (count) => `${count} enabled`, + toolsAdd: "Add tools", + toolsNone: "None enabled", +} + export interface AgentConfigSummaryCardProps { appId: string /** Opens the editing surface (the playground on desktop). Absent = read-only host (mobile): * the Edit action and the row-level "opens elsewhere" affordances are hidden. */ onEdit?: () => void + /** Overrides the tools row's wording; defaults to the "tool" noun oss/ee use. */ + copy?: Partial } /** What this agent IS, in one read-only card, built on the playground panel's own row primitives. */ -export const AgentConfigSummaryCard = ({appId, onEdit}: AgentConfigSummaryCardProps) => { +export const AgentConfigSummaryCard = ({ + appId, + onEdit, + copy: copyOverrides, +}: AgentConfigSummaryCardProps) => { + const copy = useMemo( + () => ({...DEFAULT_COPY, ...copyOverrides}), + [copyOverrides], + ) // Configuration lives on a revision, not on the artifact — reading the artifact gave a // workflow with no parameters, so every row said "Not set". const revisionAtom = useMemo(() => agentLatestRevisionAtomFamily(appId), [appId]) @@ -91,10 +116,10 @@ export const AgentConfigSummaryCard = ({appId, onEdit}: AgentConfigSummaryCardPr { key: "tools", icon: , - title: "Tools", + title: copy.toolsTitle, ...(summary.tools - ? stated(`${summary.tools} enabled`) - : emptyAction(onEdit ? "Add tools" : "None enabled")), + ? stated(copy.toolsCount(summary.tools)) + : emptyAction(onEdit ? copy.toolsAdd : copy.toolsNone)), }, { key: "mcps", diff --git a/web/packages/agenta-entity-ui/src/agent/AgentOverviewBody.tsx b/web/packages/agenta-entity-ui/src/agent/AgentOverviewBody.tsx index 8d2f8c9f1c1..8a8a2095e1e 100644 --- a/web/packages/agenta-entity-ui/src/agent/AgentOverviewBody.tsx +++ b/web/packages/agenta-entity-ui/src/agent/AgentOverviewBody.tsx @@ -3,7 +3,7 @@ import type {ReactNode} from "react" import {SessionListCard, type SessionListCardProps} from "@agenta/sessions-ui" import {PanelSurface} from "@agenta/ui/components/presentational" -import {AgentConfigSummaryCard} from "./AgentConfigSummaryCard" +import {AgentConfigSummaryCard, type AgentConfigSummaryCopy} from "./AgentConfigSummaryCard" import {AgentFilesCard} from "./AgentFilesCard" import {AgentOverviewLayout} from "./AgentOverviewLayout" import {NextTriggersSection} from "./NextTriggersSection" @@ -34,6 +34,8 @@ export interface AgentOverviewBodyProps { alwaysShowPin?: boolean /** Display names for the triggers section's bound-agent labels. */ agentNames?: Map + /** Overrides the config card's tools-row wording, for a host that names the concept its way. */ + configCopy?: Partial } /** @@ -56,6 +58,7 @@ export const AgentOverviewBody = ({ onRenameRow, alwaysShowPin, agentNames, + configCopy, }: AgentOverviewBodyProps) => ( - + {/* Scoped to this agent. Automation RUNS say what already happened; an agent whose schedule quietly stopped looks identical there. */} diff --git a/web/packages/agenta-entity-ui/src/agent/AgentOverviewLayout.tsx b/web/packages/agenta-entity-ui/src/agent/AgentOverviewLayout.tsx index 9c4bf43e6ee..ab2dae2e45f 100644 --- a/web/packages/agenta-entity-ui/src/agent/AgentOverviewLayout.tsx +++ b/web/packages/agenta-entity-ui/src/agent/AgentOverviewLayout.tsx @@ -10,6 +10,13 @@ export interface AgentOverviewLayoutProps { rail: ReactNode /** Host spacing — the stacked gap below lg is content rhythm, not layout. */ className?: string + /** + * Who scrolls. `frame` (default): this frame is the one scroller and both columns move + * together. `columns`: the frame stays put and each column owns its own height — the host + * gives one child of `main` a `min-h-0 flex-1 overflow-y-auto` and that is what scrolls, + * with everything above it (a composer, a tab rail) pinned. + */ + scroll?: "frame" | "columns" } /** @@ -27,16 +34,38 @@ export interface AgentOverviewLayoutProps { * height (the desktop page asks the layout for its full-height frame, mobile's `ScreenScaffold` * takes `fill`), or `flex-1` has no space to resolve against. */ -export const AgentOverviewLayout = ({main, rail, className}: AgentOverviewLayoutProps) => ( -
-
{main}
-
- {rail} +export const AgentOverviewLayout = ({ + main, + rail, + className, + scroll = "frame", +}: AgentOverviewLayoutProps) => { + const columns = scroll === "columns" + return ( +
+
+ {main} +
+
+ {rail} +
-
-) + ) +} diff --git a/web/packages/agenta-entity-ui/src/agent/NextTriggersSection.tsx b/web/packages/agenta-entity-ui/src/agent/NextTriggersSection.tsx index 2c86559835c..f36b671e15a 100644 --- a/web/packages/agenta-entity-ui/src/agent/NextTriggersSection.tsx +++ b/web/packages/agenta-entity-ui/src/agent/NextTriggersSection.tsx @@ -1,162 +1,16 @@ -import {useCallback, useMemo} from "react" - -import { - describeCron, - nextCronRuns, - triggerBoundAgentId, - useTriggerSchedules, - useTriggerSubscriptions, -} from "@agenta/entities/gatewayTrigger" -import {nowTickAtom} from "@agenta/shared/state" -import {dayjs} from "@agenta/shared/utils" import {PanelSection} from "@agenta/ui/components/presentational" import {SkeletonBlock} from "@agenta/ui/ui" import {LightningIcon} from "@phosphor-icons/react" -import {useAtomValue} from "jotai" import {SectionLoadError} from "./SectionLoadError" import {Tip} from "./Tip" +import {useUpcomingTriggers, type UseUpcomingTriggersArgs} from "./useUpcomingTriggers" -const LIST_SIZE = 5 - -/** `formatDay` renders in UTC; a next-run time is only meaningful in the reader's own day. */ -const formatNextRun = (at: Date) => { - const run = dayjs(at) - const days = run.startOf("day").diff(dayjs().startOf("day"), "day") - if (days === 0) return run.format("HH:mm") - if (days === 1) return `tomorrow ${run.format("HH:mm")}` - if (days < 7) return run.format("ddd HH:mm") - return run.format("D MMM HH:mm") -} - -/** - * What is going to fire, soonest first. - * - * Automations already appear on these pages as runs that HAPPENED. That answers "did it work", - * never "is anything coming" — a schedule that silently stopped firing looks identical to one - * that has simply not come round yet. Schedules project forward from their own cron expression; - * event subscriptions have no next time by nature, so they say what they are instead and sort - * after everything dated. - */ -interface UpcomingTrigger { - id: string - /** What it does. Falls back to the cadence in words, never to a cron expression. */ - name: string - /** Which agent runs it, and how often. */ - subtitle: string - detail: string - /** Absent for event subscriptions — they fire when the world does. */ - at: Date | null - tooltip: string -} - -export interface NextTriggersSectionProps { - /** Scope to one agent's triggers. On that agent's own page the binding is the premise, so - * rows drop the agent name and lead with the cadence instead. */ - agentId?: string - /** Agent display names by workflow id. The classified agents list is app state, so the app - * hands the names over; an unknown id reads as "Unassigned agent". */ - agentNames?: ReadonlyMap -} +export type NextTriggersSectionProps = UseUpcomingTriggersArgs +/** What is going to fire, soonest first — the rows `useUpcomingTriggers` derives, in the rail's panel chrome. */ export const NextTriggersSection = ({agentId, agentNames}: NextTriggersSectionProps = {}) => { - const { - schedules, - isLoading: schedulesLoading, - error: schedulesError, - refetch: refetchSchedules, - } = useTriggerSchedules() - const { - subscriptions, - isLoading: subscriptionsLoading, - error: subscriptionsError, - refetch: refetchSubscriptions, - } = useTriggerSubscriptions() - // The shared minute clock: a projected next-run time that never re-computes freezes and ends - // up in the past. - const nowTick = useAtomValue(nowTickAtom) - - const rows = useMemo(() => { - const describeAgent = (references: unknown) => { - const boundId = triggerBoundAgentId(references as never) - return (boundId && agentNames?.get(boundId)) || "Unassigned agent" - } - const isInScope = (references: unknown) => - !agentId || triggerBoundAgentId(references as never) === agentId - - const scheduled = schedules - .filter( - (schedule) => - schedule.flags?.is_active !== false && - !schedule.deleted_at && - isInScope(schedule.data?.references), - ) - .map((schedule, index) => { - const expression = schedule.data?.schedule ?? "" - const [next] = nextCronRuns(expression, 1) - const cadence = describeCron(expression) - const agent = describeAgent(schedule.data?.references) - return { - id: schedule.id ?? `schedule-${index}`, - // An unnamed schedule reads as its cadence, never as "5 * * * *". - name: schedule.name || cadence, - // Scoped to one agent, an unnamed schedule's title already IS the cadence. - subtitle: agentId - ? schedule.name - ? cadence - : "" - : schedule.name - ? `${agent} · ${cadence}` - : agent, - detail: next ? formatNextRun(next) : "—", - at: next ?? null, - tooltip: cadence, - } - }) - - const evented = subscriptions - .filter( - (subscription) => - subscription.flags?.is_active !== false && - isInScope(subscription.data?.references), - ) - .map((subscription, index) => { - const eventKey = subscription.data?.event_key ?? "" - const agent = describeAgent(subscription.data?.references) - return { - id: subscription.id ?? `subscription-${index}`, - name: subscription.name || eventKey || "Event automation", - subtitle: agentId - ? subscription.name - ? eventKey - : "" - : eventKey && subscription.name - ? `${agent} · ${eventKey}` - : agent, - detail: "on event", - at: null, - tooltip: eventKey ? `Fires on ${eventKey}` : "Fires when its event arrives", - } - }) - - // Dated first and soonest-first; undated (event) triggers keep their own order after them. - return [...scheduled, ...evented] - .sort((a, b) => { - if (a.at && b.at) return a.at.getTime() - b.at.getTime() - if (a.at) return -1 - if (b.at) return 1 - return 0 - }) - .slice(0, LIST_SIZE) - // nowTick is a dep on purpose: it is what re-projects the next runs each minute. - }, [schedules, subscriptions, agentNames, agentId, nowTick]) - - const isLoading = schedulesLoading || subscriptionsLoading - const hasError = Boolean(schedulesError || subscriptionsError) - const retry = useCallback(() => { - if (schedulesError) void refetchSchedules() - if (subscriptionsError) void refetchSubscriptions() - }, [schedulesError, subscriptionsError, refetchSchedules, refetchSubscriptions]) + const {rows, isLoading, hasError, retry} = useUpcomingTriggers({agentId, agentNames}) return ( diff --git a/web/packages/agenta-entity-ui/src/agent/agentConfigSummary.ts b/web/packages/agenta-entity-ui/src/agent/agentConfigSummary.ts index 570827eff29..a2c2532667b 100644 --- a/web/packages/agenta-entity-ui/src/agent/agentConfigSummary.ts +++ b/web/packages/agenta-entity-ui/src/agent/agentConfigSummary.ts @@ -16,6 +16,9 @@ export interface AgentConfigSummary { /** The brief itself, raw — `InstructionsFileRow` derives its own preview from the markdown. */ instructions: string | null tools: number + /** The integration behind each gateway-connection tool (`linear`, `github`), in tool order, + * deduplicated — so an overview can show the marks rather than only the count. */ + integrationKeys: string[] mcps: number skills: number /** Display names of the agent's skills (embed refs by their sibling name/slug, inline @@ -45,6 +48,12 @@ const skillName = (entry: unknown): string | null => { return slug } +/** A gateway-connection tool's integration key; null for a custom or builtin tool. */ +const integrationKey = (entry: unknown): string | null => + isRecord(entry) && entry.type === "gateway_connection" + ? str(nested(entry, "connection")?.integration) + : null + const nested = (parent: unknown, key: string): Record | null => { if (!isRecord(parent)) return null const child = parent[key] @@ -70,6 +79,13 @@ export function agentConfigSummary(parameters: unknown): AgentConfigSummary { instructionWords: instructions ? instructions.split(/\s+/).filter(Boolean).length : null, instructions, tools: count(agent.tools), + integrationKeys: Array.isArray(agent.tools) + ? [ + ...new Set( + agent.tools.map(integrationKey).filter((key): key is string => Boolean(key)), + ), + ] + : [], mcps: count(agent.mcps), skills: count(agent.skills), skillNames: Array.isArray(agent.skills) diff --git a/web/packages/agenta-entity-ui/src/agent/index.ts b/web/packages/agenta-entity-ui/src/agent/index.ts index b1a12361884..3cb4009b784 100644 --- a/web/packages/agenta-entity-ui/src/agent/index.ts +++ b/web/packages/agenta-entity-ui/src/agent/index.ts @@ -20,7 +20,16 @@ export { type AgentPickerTriggerVariant, } from "./AgentPicker" export {NextTriggersSection, type NextTriggersSectionProps} from "./NextTriggersSection" -export {AgentConfigSummaryCard, type AgentConfigSummaryCardProps} from "./AgentConfigSummaryCard" +export { + useUpcomingTriggers, + type UpcomingTrigger, + type UseUpcomingTriggersArgs, +} from "./useUpcomingTriggers" +export { + AgentConfigSummaryCard, + type AgentConfigSummaryCardProps, + type AgentConfigSummaryCopy, +} from "./AgentConfigSummaryCard" export {agentConfigSummary, prettifyKind, type AgentConfigSummary} from "./agentConfigSummary" export {agentLatestRevisionAtomFamily} from "./state" export {AgentCardGrid, type AgentCardGridProps} from "./AgentCardGrid" @@ -39,5 +48,10 @@ export { export {AgentIconPopover} from "./AgentIconPopover" export {AgentIdentity, type AgentIdentityProps, type AgentIdentitySize} from "./AgentIdentity" export {AGENT_CHIP_BOX, AGENT_CHIP_FALLBACK, AGENT_FOCUS_RING} from "./chrome" -export {useAgentActions, useRenameAgent, type AgentActionTarget} from "./useAgentActions" +export { + useAgentActions, + useRenameAgent, + useUpdateAgentDescription, + type AgentActionTarget, +} from "./useAgentActions" export {AgentIntroCard, capabilityLabel} from "./AgentIntroCard" diff --git a/web/packages/agenta-entity-ui/src/agent/useAgentActions.tsx b/web/packages/agenta-entity-ui/src/agent/useAgentActions.tsx index 06029b14503..4852e8b6fef 100644 --- a/web/packages/agenta-entity-ui/src/agent/useAgentActions.tsx +++ b/web/packages/agenta-entity-ui/src/agent/useAgentActions.tsx @@ -34,6 +34,27 @@ export const useRenameAgent = () => { ) } +/** The description write on its own, for an inline editor. An empty string clears it. */ +export const useUpdateAgentDescription = () => { + const queryClient = useQueryClient() + const projectId = useAtomValue(projectIdAtom) ?? "" + + return useCallback( + async (id: string, description: string): Promise => { + try { + await updateWorkflow(projectId, {id, description}) + } catch { + message.error("Couldn't update this agent's description") + return false + } + void queryClient.invalidateQueries({queryKey: ["workflows"]}) + void queryClient.invalidateQueries({queryKey: ["agent-workflows"]}) + return true + }, + [projectId, queryClient], + ) +} + /** * Everything you can do to an agent from its own surfaces, defined once — the same shape * [[useSessionActions]] gives sessions. @@ -89,15 +110,15 @@ export const useAgentActions = () => { const remove = useCallback( (target: AgentActionTarget) => { modal.confirm({ - title: "Delete agent", + title: "Archive agent", content: `"${target.name?.trim() || "This agent"}" will be archived along with its variants and revisions. Its past sessions stay readable.`, - okText: "Delete", + okText: "Archive", okButtonProps: {danger: true}, onOk: async () => { try { await archiveWorkflow(projectId, target.id) } catch { - message.error("Couldn't delete this agent") + message.error("Couldn't archive this agent") return } revalidate() diff --git a/web/packages/agenta-entity-ui/src/agent/useUpcomingTriggers.ts b/web/packages/agenta-entity-ui/src/agent/useUpcomingTriggers.ts new file mode 100644 index 00000000000..4b8eeb5df6d --- /dev/null +++ b/web/packages/agenta-entity-ui/src/agent/useUpcomingTriggers.ts @@ -0,0 +1,164 @@ +import {useCallback, useMemo} from "react" + +import { + describeCron, + nextCronRuns, + triggerBoundAgentId, + useTriggerSchedules, + useTriggerSubscriptions, +} from "@agenta/entities/gatewayTrigger" +import {nowTickAtom} from "@agenta/shared/state" +import {dayjs} from "@agenta/shared/utils" +import {useAtomValue} from "jotai" + +const LIST_SIZE = 5 + +/** `formatDay` renders in UTC; a next-run time is only meaningful in the reader's own day. */ +const formatNextRun = (at: Date) => { + const run = dayjs(at) + const days = run.startOf("day").diff(dayjs().startOf("day"), "day") + if (days === 0) return run.format("HH:mm") + if (days === 1) return `tomorrow ${run.format("HH:mm")}` + if (days < 7) return run.format("ddd HH:mm") + return run.format("D MMM HH:mm") +} + +/** + * What is going to fire, soonest first. + * + * Automations already appear on these pages as runs that HAPPENED. That answers "did it work", + * never "is anything coming" — a schedule that silently stopped firing looks identical to one + * that has simply not come round yet. Schedules project forward from their own cron expression; + * event subscriptions have no next time by nature, so they say what they are instead and sort + * after everything dated. + */ +export interface UpcomingTrigger { + id: string + /** A schedule or an event subscription — the mark a row leads with. */ + kind: "schedule" | "event" + /** What it does. Falls back to the cadence in words, never to a cron expression. */ + name: string + /** Which agent runs it, and how often. */ + subtitle: string + detail: string + /** Absent for event subscriptions — they fire when the world does. */ + at: Date | null + tooltip: string +} + +export interface UseUpcomingTriggersArgs { + /** Scope to one agent's triggers. On that agent's own page the binding is the premise, so + * rows drop the agent name and lead with the cadence instead. */ + agentId?: string + /** Agent display names by workflow id. The classified agents list is app state, so the app + * hands the names over; an unknown id reads as "Unassigned agent". */ + agentNames?: ReadonlyMap +} + +/** + * The rows behind the Automations rail card — derived once here so the desktop panel and a host + * with its own card chrome list the same triggers in the same order. + */ +export const useUpcomingTriggers = ({agentId, agentNames}: UseUpcomingTriggersArgs = {}) => { + const { + schedules, + isLoading: schedulesLoading, + error: schedulesError, + refetch: refetchSchedules, + } = useTriggerSchedules() + const { + subscriptions, + isLoading: subscriptionsLoading, + error: subscriptionsError, + refetch: refetchSubscriptions, + } = useTriggerSubscriptions() + // The shared minute clock: a projected next-run time that never re-computes freezes and ends + // up in the past. + const nowTick = useAtomValue(nowTickAtom) + + const rows = useMemo(() => { + const describeAgent = (references: unknown) => { + const boundId = triggerBoundAgentId(references as never) + return (boundId && agentNames?.get(boundId)) || "Unassigned agent" + } + const isInScope = (references: unknown) => + !agentId || triggerBoundAgentId(references as never) === agentId + + const scheduled = schedules + .filter( + (schedule) => + schedule.flags?.is_active !== false && + !schedule.deleted_at && + isInScope(schedule.data?.references), + ) + .map((schedule, index): UpcomingTrigger => { + const expression = schedule.data?.schedule ?? "" + const [next] = nextCronRuns(expression, 1) + const cadence = describeCron(expression) + const agent = describeAgent(schedule.data?.references) + return { + id: schedule.id ?? `schedule-${index}`, + kind: "schedule", + // An unnamed schedule reads as its cadence, never as "5 * * * *". + name: schedule.name || cadence, + // Scoped to one agent, an unnamed schedule's title already IS the cadence. + subtitle: agentId + ? schedule.name + ? cadence + : "" + : schedule.name + ? `${agent} · ${cadence}` + : agent, + detail: next ? formatNextRun(next) : "—", + at: next ?? null, + tooltip: cadence, + } + }) + + const evented = subscriptions + .filter( + (subscription) => + subscription.flags?.is_active !== false && + isInScope(subscription.data?.references), + ) + .map((subscription, index): UpcomingTrigger => { + const eventKey = subscription.data?.event_key ?? "" + const agent = describeAgent(subscription.data?.references) + return { + id: subscription.id ?? `subscription-${index}`, + kind: "event", + name: subscription.name || eventKey || "Event automation", + subtitle: agentId + ? subscription.name + ? eventKey + : "" + : eventKey && subscription.name + ? `${agent} · ${eventKey}` + : agent, + detail: "on event", + at: null, + tooltip: eventKey ? `Fires on ${eventKey}` : "Fires when its event arrives", + } + }) + + // Dated first and soonest-first; undated (event) triggers keep their own order after them. + return [...scheduled, ...evented] + .sort((a, b) => { + if (a.at && b.at) return a.at.getTime() - b.at.getTime() + if (a.at) return -1 + if (b.at) return 1 + return 0 + }) + .slice(0, LIST_SIZE) + // nowTick is a dep on purpose: it is what re-projects the next runs each minute. + }, [schedules, subscriptions, agentNames, agentId, nowTick]) + + const isLoading = schedulesLoading || subscriptionsLoading + const hasError = Boolean(schedulesError || subscriptionsError) + const retry = useCallback(() => { + if (schedulesError) void refetchSchedules() + if (subscriptionsError) void refetchSubscriptions() + }, [schedulesError, subscriptionsError, refetchSchedules, refetchSubscriptions]) + + return {rows, isLoading, hasError, retry} +} diff --git a/web/packages/agenta-entity-ui/tests/unit/agentConfigSummary.test.ts b/web/packages/agenta-entity-ui/tests/unit/agentConfigSummary.test.ts index 126062ece75..269ff5dc109 100644 --- a/web/packages/agenta-entity-ui/tests/unit/agentConfigSummary.test.ts +++ b/web/packages/agenta-entity-ui/tests/unit/agentConfigSummary.test.ts @@ -24,6 +24,7 @@ describe("agentConfigSummary", () => { instructionWords: 10, instructions: "You are a friendly agent.\n\n- Greet the user warmly.", tools: 2, + integrationKeys: [], mcps: 0, skills: 0, skillNames: [], @@ -74,6 +75,23 @@ describe("agentConfigSummary", () => { }) }) +describe("integrationKeys", () => { + it("names the integration behind each gateway connection, once", () => { + const summary = agentConfigSummary({ + agent: { + tools: [ + {type: "gateway_connection", connection: {integration: "linear"}}, + {type: "gateway_connection", connection: {integration: "github"}}, + {type: "gateway_connection", connection: {integration: "linear"}}, + {name: "bash"}, + ], + }, + }) + expect(summary.tools).toBe(4) + expect(summary.integrationKeys).toEqual(["linear", "github"]) + }) +}) + describe("skillNames", () => { it("names embed refs by sibling name, falls back to the referenced slug", () => { const summary = agentConfigSummary({ diff --git a/web/packages/agenta-home-ui/src/NewAgentButton.tsx b/web/packages/agenta-home-ui/src/NewAgentButton.tsx index 6fed840427f..d7a3336c982 100644 --- a/web/packages/agenta-home-ui/src/NewAgentButton.tsx +++ b/web/packages/agenta-home-ui/src/NewAgentButton.tsx @@ -75,11 +75,20 @@ export const NewAgentButton = ({ - - - Blank agent - - Configure model, instructions and tools yourself + {/* A tile, like the templates under it: the entries are alternatives to each + other, so they read as one list rather than a button above a list. */} + + + + + New agent + {/* One line, like the template rows under it: a subtitle that wraps makes + the first entry taller than every entry it is offered beside. */} + + Start with an empty agent and shape it in chat diff --git a/web/packages/agenta-sessions-ui/src/InlineRenameInput.tsx b/web/packages/agenta-sessions-ui/src/InlineRenameInput.tsx index 7f3129e2636..db605b8fe7e 100644 --- a/web/packages/agenta-sessions-ui/src/InlineRenameInput.tsx +++ b/web/packages/agenta-sessions-ui/src/InlineRenameInput.tsx @@ -10,10 +10,13 @@ import type {InlineRename} from "./useInlineRename" */ const InlineRenameInput = ({ rename, + ariaLabel = "Session name", className, inputRef, }: { rename: InlineRename + /** What the field is called. Names the entity being renamed, not the control. */ + ariaLabel?: string className?: string /** For a host that has to re-claim focus after the input mounts (see the nav rail). */ inputRef?: RefObject @@ -22,7 +25,7 @@ const InlineRenameInput = ({ ref={inputRef} autoFocus value={rename.draft} - aria-label="Session name" + aria-label={ariaLabel} onChange={(event) => rename.setDraft(event.target.value)} onBlur={() => void rename.commit()} onKeyDown={(event) => { diff --git a/web/packages/agenta-sessions-ui/src/useInlineRename.ts b/web/packages/agenta-sessions-ui/src/useInlineRename.ts index 4de43f5525a..26aaeebe569 100644 --- a/web/packages/agenta-sessions-ui/src/useInlineRename.ts +++ b/web/packages/agenta-sessions-ui/src/useInlineRename.ts @@ -7,6 +7,10 @@ export interface InlineRenameOptions { current: string | null | undefined /** Persists the new name. `false` surfaces an error and leaves the row alone. */ onCommit: (name: string) => Promise + /** What a failed commit says. Defaults to the session wording this started as. */ + errorText?: string + /** Let a blank draft commit — for a field that can be cleared, like a description. */ + allowEmpty?: boolean } export interface InlineRename { @@ -29,7 +33,12 @@ export interface InlineRename { * guarded by a ref, not by state: blur fires before keydown, so Enter would otherwise save a * second time against a row that has already left the editing state. */ -export const useInlineRename = ({current, onCommit}: InlineRenameOptions): InlineRename => { +export const useInlineRename = ({ + current, + onCommit, + errorText = "Couldn't rename this session", + allowEmpty = false, +}: InlineRenameOptions): InlineRename => { const [renaming, setRenaming] = useState(false) const [draft, setDraft] = useState("") const committedRef = useRef(false) @@ -50,9 +59,9 @@ export const useInlineRename = ({current, onCommit}: InlineRenameOptions): Inlin committedRef.current = true const name = draft.trim() setRenaming(false) - if (!name || name === (current ?? "")) return - if (!(await onCommit(name))) message.error("Couldn't rename this session") - }, [current, draft, onCommit]) + if ((!name && !allowEmpty) || name === (current ?? "")) return + if (!(await onCommit(name))) message.error(errorText) + }, [allowEmpty, current, draft, errorText, onCommit]) return {renaming, draft, setDraft, start, commit, cancel} } diff --git a/web/packages/agenta-settings-ui/src/index.ts b/web/packages/agenta-settings-ui/src/index.ts index 02947a8bfc5..1acdff0283d 100644 --- a/web/packages/agenta-settings-ui/src/index.ts +++ b/web/packages/agenta-settings-ui/src/index.ts @@ -76,6 +76,7 @@ export {DomainsSection, type DomainsSectionProps} from "./access/DomainsSection" export {SsoProvidersSection, type SsoProvidersSectionProps} from "./access/SsoProvidersSection" export { default as GatewayToolsSection, + type GatewayToolsSectionCopy, type GatewayToolsSectionProps, } from "./tools/GatewayToolsSection" export {default as IntegrationGrid} from "./tools/IntegrationGrid" diff --git a/web/packages/agenta-settings-ui/src/tools/GatewayToolsSection.tsx b/web/packages/agenta-settings-ui/src/tools/GatewayToolsSection.tsx index 76be44d758c..dd7b38a7d24 100644 --- a/web/packages/agenta-settings-ui/src/tools/GatewayToolsSection.tsx +++ b/web/packages/agenta-settings-ui/src/tools/GatewayToolsSection.tsx @@ -30,14 +30,45 @@ const AUTH_SCHEME_LABELS: Record = { api_key: "API Key", } +/** Nouns for the connected rows; a host that calls them something else passes its own. */ +export interface GatewayToolsSectionCopy { + integrationColumn: string + run: string + searchPlaceholder: string + connect: string + emptyTitle: string + emptyBody: string + noMatch: (term: string) => string +} + +const DEFAULT_COPY: GatewayToolsSectionCopy = { + integrationColumn: "Tool", + run: "Run tool", + searchPlaceholder: "Search tools", + connect: "Connect tool", + emptyTitle: "No tools connected yet", + emptyBody: "Connect a tool to let your agents call it.", + noMatch: (term) => `No tools match “${term}”`, +} + export interface GatewayToolsSectionProps { /** Destructive confirmation — the desktop's AlertPopup, a sheet elsewhere. */ confirm?: ConfirmDestructive /** Hides connect/run and skips the catalog drawer, whose schema form is still antd-backed. */ readOnly?: boolean + /** Overrides the row noun; defaults to the "tool" wording oss/ee use. */ + copy?: Partial } -export default function GatewayToolsSection({confirm, readOnly}: GatewayToolsSectionProps) { +export default function GatewayToolsSection({ + confirm, + readOnly, + copy: copyOverrides, +}: GatewayToolsSectionProps) { + const copy = useMemo( + () => ({...DEFAULT_COPY, ...copyOverrides}), + [copyOverrides], + ) const {connections, isLoading, refetch} = useToolConnectionsQuery() const {handleDelete, handleRefresh, handleRevoke, invalidateConnections} = useToolConnectionActions() @@ -244,7 +275,7 @@ export default function GatewayToolsSection({confirm, readOnly}: GatewayToolsSec }, { key: "integration_key", - title: "Tool", + title: copy.integrationColumn, width: 180, render: (record) => {record.integration_key}, }, @@ -278,7 +309,7 @@ export default function GatewayToolsSection({confirm, readOnly}: GatewayToolsSec : "-", }, ], - [logoFor], + [logoFor, copy.integrationColumn], ) return ( <> @@ -296,7 +327,7 @@ export default function GatewayToolsSection({confirm, readOnly}: GatewayToolsSec { key: "run", hidden: readOnly, - label: "Run tool", + label: copy.run, icon: , onClick: () => openExecution(record), }, @@ -325,7 +356,7 @@ export default function GatewayToolsSection({confirm, readOnly}: GatewayToolsSec }, ]} search={{ - placeholder: "Search tools", + placeholder: copy.searchPlaceholder, value: searchTerm, onChange: setSearchTerm, disabled: isLoading, @@ -338,7 +369,7 @@ export default function GatewayToolsSection({confirm, readOnly}: GatewayToolsSec {readOnly ? null : ( )} @@ -347,7 +378,7 @@ export default function GatewayToolsSection({confirm, readOnly}: GatewayToolsSec searchTerm.trim() ? ( ) : ( - No tools connected yet + {copy.emptyTitle} - Connect a tool to let your agents call it. + {copy.emptyBody}
} > {readOnly ? null : ( )} diff --git a/web/packages/agenta-ui/src/list-table/ListTable.tsx b/web/packages/agenta-ui/src/list-table/ListTable.tsx index 6e7c335e296..e2deec509cf 100644 --- a/web/packages/agenta-ui/src/list-table/ListTable.tsx +++ b/web/packages/agenta-ui/src/list-table/ListTable.tsx @@ -31,6 +31,9 @@ const SKELETON_WIDTHS = ["w-4/5", "w-3/5", "w-2/3", "w-1/2", "w-3/4"] */ const STICKY = {height: "h-9", groupTop: "top-9"} as const +/** With no visible header, a stuck group heading sits at the scroller's own top. */ +const groupTop = (hideHeader: boolean) => (hideHeader ? "top-0" : STICKY.groupTop) + /** * The list frame every table-shaped screen in this app shares: a header row, optional group * headings that collapse, and rows that open. @@ -58,9 +61,13 @@ export const ListTable = ({ onToggleGroup, empty, stickyHeader = false, + hideHeader = false, + density = "default", className, }: ListTableProps) => { const grid = gridTemplate(columns) + // One value for the rows AND their skeleton, so loading holds the rhythm the rows arrive in. + const rowPad = density === "compact" ? "py-2" : "py-[13px]" const isEmpty = groups.every((group) => group.rows.length === 0) return ( @@ -69,19 +76,35 @@ export const ListTable = ({ // with no vertical range of its own — a `sticky` header inside it has nothing to stick to // and never moves. So a sticky table hands the overflow up to the page's own scroller, // which already scrolls both axes, and the header sticks to THAT. -
+ // `-mx-3 px-3` is net zero on the content box — the header and the tracks do not move — + // but it is what a row's bleed has to land in. A scrollport is clipped at its PADDING + // box, so without the padding here the 12px a row reaches past its text is cut flat on + // both sides, and the hover fill ends in a square edge instead of a rounded one. +
@@ -105,7 +128,7 @@ export const ListTable = ({ {Array.from({length: skeletonRows}, (_, row) => (
{columns.map((column, index) => ( @@ -132,7 +155,7 @@ export const ListTable = ({ ) : isEmpty ? ( empty ) : ( - groups.map((group) => { + groups.map((group, groupIndex) => { const collapsed = collapsedKeys?.has(group.key) ?? false return ( // A box per group, not a Fragment: `sticky` is bounded by the @@ -142,7 +165,14 @@ export const ListTable = ({ // of the flow. A box per group makes each heading hand off to the // next as its own run ends. Layout is unchanged: every row is its own // grid, and this parent is a plain block either way. -
+
{group.label === null ? null : onToggleGroup ? (