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
+}) => (
+
+)
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. */}
-
+ // 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. */}
+
+ {/* 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
-
+ {/* 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) => (
-
-)
+ )
+}
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 : (
setCatalogOpen(true)}>
- Connect tool
+ {copy.connect}
)}
>
@@ -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 : (
setCatalogOpen(true)}>
- Connect tool
+ {copy.connect}
)}
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.
+
{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 ? (
({
aria-expanded={!collapsed}
className={cn(
"box-border flex w-full cursor-pointer appearance-none items-center gap-1.5",
- "border-0 bg-transparent px-2 pb-1.5 pt-3.5 text-left font-[inherit]",
+ "border-0 bg-transparent pb-1.5 pt-3.5 text-left font-[inherit]",
"text-[13px] text-muted-foreground hover:text-foreground",
// Stuck directly under the column header, so a long
// run still says which group you are reading.
stickyHeader &&
- `sticky ${STICKY.groupTop} z-10 bg-background`,
+ `sticky ${groupTop(hideHeader)} z-10 bg-background`,
FOCUS_RING,
)}
>
@@ -172,9 +202,9 @@ export const ListTable = ({
) : (
{group.label}
@@ -208,7 +238,14 @@ export const ListTable = ({
// ROW's hover rather than on its own — a pin that
// appears only while the pointer is inside its own
// cell is one you have to find before you can see it.
- "group grid w-full items-center gap-3 rounded-md border-0 bg-transparent px-2 py-[13px] text-left",
+ // The fill reaches 12px past the text on each side
+ // while the text itself stays on the table's edge:
+ // the row's BOX grows by the same 12px its padding
+ // gives back, so its content box — and so its grid
+ // tracks — stay identical to the header's.
+ "group grid w-full items-center gap-3 rounded-md border-0 bg-transparent text-left",
+ rowPad,
+ "-mx-3 w-[calc(100%+1.5rem)] px-3",
onOpenRow && "cursor-pointer hover:bg-accent/60",
onOpenRow && FOCUS_RING,
)}
diff --git a/web/packages/agenta-ui/src/list-table/types.ts b/web/packages/agenta-ui/src/list-table/types.ts
index f035ed34131..140e9718552 100644
--- a/web/packages/agenta-ui/src/list-table/types.ts
+++ b/web/packages/agenta-ui/src/list-table/types.ts
@@ -67,5 +67,17 @@ export interface ListTableProps {
* beside it rather than only itself.
*/
stickyHeader?: boolean
+ /**
+ * Keep the column names for screen readers only. For a list whose surroundings already say
+ * what the rows are — a tab named "Sessions" over a single-column list — where a header row
+ * only repeats the tab.
+ */
+ hideHeader?: boolean
+ /**
+ * Row rhythm. `compact` is for the long lists — a sessions page runs to hundreds of rows, and
+ * 13px above and below each one is a screen of air per hundred. The default suits a list a
+ * reader scans once.
+ */
+ density?: "default" | "compact"
className?: string
}