From 97177fe0e38a09c03bf80681f156ffd89fe21fb4 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Thu, 10 Sep 2026 23:07:00 +0600 Subject: [PATCH 01/17] feat(frontend): carry created_by_id through the workflow list ref The artifact already returns it and `toWorkflowListRef` dropped it, so no surface built on the apps list could name an agent's creator. Additive: existing consumers are untouched. --- web/packages/agenta-entities/src/workflow/state/store.ts | 4 ++++ 1 file changed, 4 insertions(+) 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) From 27cd38a0221bc0d6cc2ec5cdf62ed5a8e7284e96 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Thu, 10 Sep 2026 23:07:01 +0600 Subject: [PATCH 02/17] refactor(frontend): let the inline-rename pair name any entity The hook's failure message and the input's accessible name were hardcoded to sessions. Both now take the entity's own wording and default to what they said before, so every existing call site reads identically. --- .../agenta-sessions-ui/src/InlineRenameInput.tsx | 5 ++++- .../agenta-sessions-ui/src/useInlineRename.ts | 12 +++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) 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..ca346b19705 100644 --- a/web/packages/agenta-sessions-ui/src/useInlineRename.ts +++ b/web/packages/agenta-sessions-ui/src/useInlineRename.ts @@ -7,6 +7,8 @@ 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 } export interface InlineRename { @@ -29,7 +31,11 @@ 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", +}: InlineRenameOptions): InlineRename => { const [renaming, setRenaming] = useState(false) const [draft, setDraft] = useState("") const committedRef = useRef(false) @@ -51,8 +57,8 @@ export const useInlineRename = ({current, onCommit}: InlineRenameOptions): Inlin 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 (!(await onCommit(name))) message.error(errorText) + }, [current, draft, errorText, onCommit]) return {renaming, draft, setDraft, start, commit, cancel} } From 564c4525b55f9b94136f35253e29d2e8416e4c62 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Thu, 10 Sep 2026 23:07:02 +0600 Subject: [PATCH 03/17] feat(frontend): give the agent kebab open and archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy ID goes: an id is a debugging detail, and the slug beside it is the one people quote. Open configuration arrives, dropped on a surface that passes no handler — the overview itself. Delete becomes Archive agent, with the archive mark. The verb behind it has always been `archiveWorkflow` and the modal it opens is already titled Archive, so the menu was the only thing calling it a delete; AgentCard's own menu now uses the same words and marks. The content also forwards onCloseAutoFocus, which is what lets a host start an inline rename from the menu: the editor must not mount inside the menu's focus trap. --- .../src/agent/AgentActionsMenu.tsx | 48 ++++++++++++++----- .../agenta-entity-ui/src/agent/AgentCard.tsx | 10 ++-- .../src/agent/useAgentActions.tsx | 6 +-- 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/agent/AgentActionsMenu.tsx b/web/packages/agenta-entity-ui/src/agent/AgentActionsMenu.tsx index 2a2a2a2fd1b..7c00554ef54 100644 --- a/web/packages/agenta-entity-ui/src/agent/AgentActionsMenu.tsx +++ b/web/packages/agenta-entity-ui/src/agent/AgentActionsMenu.tsx @@ -2,39 +2,50 @@ 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} 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 + /** 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, onDelete, onConfigure, align = "start", + onCloseAutoFocus, className, }: AgentActionsMenuProps) => { const actions = useAgentActions() @@ -51,7 +62,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 +86,23 @@ export const AgentActionsMenu = ({ Rename )} - void actions.copy(agent.id, "ID")}> - - Copy ID - {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/useAgentActions.tsx b/web/packages/agenta-entity-ui/src/agent/useAgentActions.tsx index 06029b14503..ac1a41a927c 100644 --- a/web/packages/agenta-entity-ui/src/agent/useAgentActions.tsx +++ b/web/packages/agenta-entity-ui/src/agent/useAgentActions.tsx @@ -89,15 +89,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() From 27ecdf65f5f76c2ea2ba97f6c163d0bd31774ee4 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Thu, 10 Sep 2026 23:07:03 +0600 Subject: [PATCH 04/17] feat(frontend): match the New agent menu to the design copy Blank agent becomes New agent over one line of what it does, and the entry takes a tile like the templates under it so the menu reads as one list of alternatives rather than a button above a list. --- .../agenta-home-ui/src/NewAgentButton.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) 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 From a13714decbde2e44e547aa01157772302caa234f Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Thu, 10 Sep 2026 23:07:04 +0600 Subject: [PATCH 05/17] fix(frontend): stop the first row touching a sticky list header A sticky header drops its own bottom margin, or a 4px slot of rows shows through as they pass under it. The first run pays it back as padding instead, so a row's hover fill no longer sits flush on the rule. --- web/packages/agenta-ui/src/list-table/ListTable.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/web/packages/agenta-ui/src/list-table/ListTable.tsx b/web/packages/agenta-ui/src/list-table/ListTable.tsx index 6e7c335e296..d22398a4b34 100644 --- a/web/packages/agenta-ui/src/list-table/ListTable.tsx +++ b/web/packages/agenta-ui/src/list-table/ListTable.tsx @@ -132,7 +132,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 +142,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 ? ( +
+) 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} +
+) From 611d0ee6cf61e10cf941a4e004b44134280c2f85 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Fri, 11 Sep 2026 12:38:51 +0600 Subject: [PATCH 11/17] feat(frontend): redesign the mobile agent overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overview reads the way the Agent Overview design does: a description and an Open chat verb in the header, Home's composer with voice, then one activity list under Sessions | Automation runs tabs with the shared filter menu (status, window, grouping) instead of two stacked cards. The rail is three soft cards owned by this app — Configuration with stacked integration marks, Files with Open drive, Automations with a clock or bolt per trigger — built on the sessions table's frame and the entity-ui hooks, so no desktop chrome changes. Usage is dropped, as designed. --- .../agents/AgentActivityFilterMenu.tsx | 117 +++++++++++++ .../features/agents/AgentActivityRowCells.tsx | 136 +++++++++++++++ .../features/agents/AgentActivityTable.tsx | 160 ++++++++++++++++++ .../src/features/agents/AgentActivityTabs.tsx | 50 ++++++ .../features/agents/AgentAutomationsCard.tsx | 61 +++++++ .../src/features/agents/AgentComposer.tsx | 3 +- .../src/features/agents/AgentConfigCard.tsx | 130 ++++++++++++++ .../src/features/agents/AgentDriveCard.tsx | 111 ++++++++++++ .../src/features/agents/AgentOverviewBody.tsx | 138 +++++++++++++++ .../src/features/agents/AgentOverviewCard.tsx | 41 +++++ .../features/agents/AgentOverviewCardRow.tsx | 72 ++++++++ .../features/agents/AgentOverviewScreen.tsx | 111 ++++-------- .../features/agents/AgentOverviewTitle.tsx | 107 ++++++++++++ .../src/features/agents/agentActivityView.ts | 36 ++++ .../agents/states/AgentActivityEmpty.tsx | 34 ++++ .../agents/states/AgentOverviewCardError.tsx | 22 +++ .../states/AgentOverviewCardSkeleton.tsx | 10 ++ web/mobile/src/lib/integrationsCopy.ts | 8 - web/mobile/src/lib/useScrollFade.ts | 42 +++++ 19 files changed, 1302 insertions(+), 87 deletions(-) create mode 100644 web/mobile/src/features/agents/AgentActivityFilterMenu.tsx create mode 100644 web/mobile/src/features/agents/AgentActivityRowCells.tsx create mode 100644 web/mobile/src/features/agents/AgentActivityTable.tsx create mode 100644 web/mobile/src/features/agents/AgentActivityTabs.tsx create mode 100644 web/mobile/src/features/agents/AgentAutomationsCard.tsx create mode 100644 web/mobile/src/features/agents/AgentConfigCard.tsx create mode 100644 web/mobile/src/features/agents/AgentDriveCard.tsx create mode 100644 web/mobile/src/features/agents/AgentOverviewBody.tsx create mode 100644 web/mobile/src/features/agents/AgentOverviewCard.tsx create mode 100644 web/mobile/src/features/agents/AgentOverviewCardRow.tsx create mode 100644 web/mobile/src/features/agents/AgentOverviewTitle.tsx create mode 100644 web/mobile/src/features/agents/agentActivityView.ts create mode 100644 web/mobile/src/features/agents/states/AgentActivityEmpty.tsx create mode 100644 web/mobile/src/features/agents/states/AgentOverviewCardError.tsx create mode 100644 web/mobile/src/features/agents/states/AgentOverviewCardSkeleton.tsx create mode 100644 web/mobile/src/lib/useScrollFade.ts 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/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 + {/* 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")} + onCloseAutoFocus={handleCloseAutoFocus} + className="mt-0.5 sm:-mt-0.5" + /> + )} + + ) +} 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/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/lib/integrationsCopy.ts b/web/mobile/src/lib/integrationsCopy.ts index d78e967075c..1f980d0cfb0 100644 --- a/web/mobile/src/lib/integrationsCopy.ts +++ b/web/mobile/src/lib/integrationsCopy.ts @@ -47,11 +47,3 @@ export const INTEGRATIONS_SECTION_COPY = { emptyBody: "Connect an integration to let your agents call it.", noMatch: (term: string) => `No integrations match “${term}”`, } as const - -/** Copy for the agent overview's config card, whose tools row says "Tools" for oss/ee. */ -export const AGENT_CONFIG_INTEGRATIONS_COPY = { - toolsTitle: "Integrations", - toolsCount: (count: number) => `${count} enabled`, - toolsAdd: "Add integrations", - toolsNone: "None enabled", -} 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} +} From 575c1a989763bdc374563536bada5f0ef3b1a6e9 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Fri, 11 Sep 2026 17:04:21 +0600 Subject: [PATCH 12/17] fix(frontend): read archived agents through the shared classification map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main replaced filterAgentWorkflows(workflows, latestRevisions) with selectAgentWorkflows over a per-project agent-flags map (#classify agents once per project), and this hook still called the old name — the mobile typecheck and image build failed on it after the rebase. ensureAgentFlags is the better fit anyway: the shared map already covers archived workflows, so the second round trip this hook used to make for their latest revisions is now a cache hit. --- .../src/features/agents/useArchivedAgents.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/web/mobile/src/features/agents/useArchivedAgents.ts b/web/mobile/src/features/agents/useArchivedAgents.ts index 4a07715e871..0c8e29ecc6e 100644 --- a/web/mobile/src/features/agents/useArchivedAgents.ts +++ b/web/mobile/src/features/agents/useArchivedAgents.ts @@ -1,7 +1,7 @@ import { - fetchWorkflowsBatch, - filterAgentWorkflows, + ensureAgentFlags, queryWorkflows, + selectAgentWorkflows, type Workflow, } from "@agenta/entities/workflow" import {useQuery} from "@tanstack/react-query" @@ -14,9 +14,11 @@ import {useQuery} from "@tanstack/react-query" * 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. * - * Two round trips — the artifacts, then their latest revisions — because agent identity is - * revision-derived and an archived artifact carries no `is_agent` of its own. That cost is why - * `enabled` is a prop: nothing here runs until the Archived facet leaves its default. + * 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[] = [] @@ -35,11 +37,8 @@ export const useArchivedAgents = ({projectId, enabled}: {projectId: string; enab }) const archived = (response.workflows ?? []).filter((workflow) => workflow.deleted_at) if (archived.length === 0) return [] - const latestRevisions = await fetchWorkflowsBatch( - projectId, - archived.map((workflow) => workflow.id), - ) - return filterAgentWorkflows(archived, latestRevisions) + const agentFlags = await ensureAgentFlags(projectId) + return selectAgentWorkflows(archived, agentFlags) }, enabled: enabled && Boolean(projectId), staleTime: 30_000, From c912925f0cef38bc833c1b4bdbb0492548d67974 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Thu, 10 Sep 2026 13:15:55 +0600 Subject: [PATCH 13/17] feat(frontend): call tools "integrations" in the mobile app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway-tool surface is a list of connected apps (Gmail, Slack), and "tool" also names the tool CALLS a transcript shows — two things under one word. This app now says Integrations for the connected-app concept: the settings tab, its nav entries, the connections table, and the agent overview's config row. oss/ee keep their wording, so the rename is a mobile copy layer (web/mobile/src/lib/integrationsCopy.ts) over shared components that now take an optional copy prop and default to the old strings. --- .../features/agents/AgentOverviewScreen.tsx | 3 + .../src/features/settings/SettingsScreen.tsx | 22 +++---- .../src/features/settings/SettingsTabRail.tsx | 7 ++- .../features/settings/settingsNavScope.tsx | 8 ++- web/mobile/src/lib/integrationsCopy.ts | 63 +++++++++++++++++++ .../src/agent/AgentConfigSummaryCard.tsx | 37 +++++++++-- .../src/agent/AgentOverviewBody.tsx | 7 ++- .../agenta-entity-ui/src/agent/index.ts | 6 +- web/packages/agenta-settings-ui/src/index.ts | 1 + .../src/tools/GatewayToolsSection.tsx | 55 +++++++++++++--- 10 files changed, 177 insertions(+), 32 deletions(-) create mode 100644 web/mobile/src/lib/integrationsCopy.ts diff --git a/web/mobile/src/features/agents/AgentOverviewScreen.tsx b/web/mobile/src/features/agents/AgentOverviewScreen.tsx index e3870cbc9fd..f0151d7c7d9 100644 --- a/web/mobile/src/features/agents/AgentOverviewScreen.tsx +++ b/web/mobile/src/features/agents/AgentOverviewScreen.tsx @@ -11,6 +11,7 @@ import {useAtomValue, useSetAtom} from "jotai" import {PageTitle} from "@/components/PageTitle" import {ScreenScaffold} from "@/components/ScreenScaffold" import {Skeleton} from "@/components/ui/skeleton" +import {AGENT_CONFIG_INTEGRATIONS_COPY} from "@/lib/integrationsCopy" import {useStartBlankSession} from "../chat/useStartBlankSession" import {useBindProjectContext} from "../context/useBindProjectContext" @@ -137,6 +138,8 @@ export const AgentOverviewScreen = ({ } agentId={agentId} agentNames={agentNames} + // This app says "Integrations" where oss/ee still say "Tools". + configCopy={AGENT_CONFIG_INTEGRATIONS_COPY} usage={} sessionsHref={`${base}/sessions`} automationSessionsHref={`${base}/sessions?mode=${sessionRouteModes.automation}`} 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..2f5438a2b5a --- /dev/null +++ b/web/mobile/src/lib/integrationsCopy.ts @@ -0,0 +1,63 @@ +import { + getSettingsTabDescription, + getSettingsTabDocs, + getSettingsTabLabel, + type SettingsAccess, + type SettingsTabDocs, + type SettingsTabKey, +} from "@agenta/settings" + +/** + * This app calls the gateway-tool surface "Integrations", not "Tools" — the row a user connects + * is Gmail or Slack, not a single callable function, and "tool" collides with the tool CALLS a + * transcript shows. The shared `@agenta/settings` copy still says "Tools" for oss/ee, so the + * rename lives here rather than in the package: flip these tables into `navigation.ts` the day + * the desktop renames too. + */ +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 + +/** Copy for the agent overview's config card, whose tools row says "Tools" for oss/ee. */ +export const AGENT_CONFIG_INTEGRATIONS_COPY = { + toolsTitle: "Integrations", + toolsCount: (count: number) => `${count} enabled`, + toolsAdd: "Add integrations", + toolsNone: "None enabled", +} as const diff --git a/web/packages/agenta-entity-ui/src/agent/AgentConfigSummaryCard.tsx b/web/packages/agenta-entity-ui/src/agent/AgentConfigSummaryCard.tsx index a36560b0ca3..be15b4d41d1 100644 --- a/web/packages/agenta-entity-ui/src/agent/AgentConfigSummaryCard.tsx +++ b/web/packages/agenta-entity-ui/src/agent/AgentConfigSummaryCard.tsx @@ -41,15 +41,44 @@ 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. Defaults to "tool", which is what oss/ee call the concept; a host that + * names it differently (the mobile app says "integration") passes its own instead of forking + * the card. + */ +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 +120,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/index.ts b/web/packages/agenta-entity-ui/src/agent/index.ts index b1a12361884..af033da4b38 100644 --- a/web/packages/agenta-entity-ui/src/agent/index.ts +++ b/web/packages/agenta-entity-ui/src/agent/index.ts @@ -20,7 +20,11 @@ export { type AgentPickerTriggerVariant, } from "./AgentPicker" export {NextTriggersSection, type NextTriggersSectionProps} from "./NextTriggersSection" -export {AgentConfigSummaryCard, type AgentConfigSummaryCardProps} from "./AgentConfigSummaryCard" +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" 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..99aecd979f5 100644 --- a/web/packages/agenta-settings-ui/src/tools/GatewayToolsSection.tsx +++ b/web/packages/agenta-settings-ui/src/tools/GatewayToolsSection.tsx @@ -30,14 +30,49 @@ const AUTH_SCHEME_LABELS: Record = { api_key: "API Key", } +/** + * Nouns for the connected rows. The defaults say "tool", which is what oss/ee call this page; + * a host that names the concept differently (the mobile app says "integration") passes its own + * rather than forking the section. + */ +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 +279,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 +313,7 @@ export default function GatewayToolsSection({confirm, readOnly}: GatewayToolsSec : "-", }, ], - [logoFor], + [logoFor, copy.integrationColumn], ) return ( <> @@ -296,7 +331,7 @@ export default function GatewayToolsSection({confirm, readOnly}: GatewayToolsSec { key: "run", hidden: readOnly, - label: "Run tool", + label: copy.run, icon: , onClick: () => openExecution(record), }, @@ -325,7 +360,7 @@ export default function GatewayToolsSection({confirm, readOnly}: GatewayToolsSec }, ]} search={{ - placeholder: "Search tools", + placeholder: copy.searchPlaceholder, value: searchTerm, onChange: setSearchTerm, disabled: isLoading, @@ -338,7 +373,7 @@ export default function GatewayToolsSection({confirm, readOnly}: GatewayToolsSec {readOnly ? null : ( )} @@ -347,7 +382,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 : ( )} From 185e2dea622abe69f25ae373b04ffa7636dfe663 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Fri, 11 Sep 2026 14:43:16 +0600 Subject: [PATCH 14/17] feat(frontend): edit an agent's description in place on mobile The description under the overview's title edits the way the name does: tap it (or Edit description in the kebab) and it becomes a one-line input; Enter commits, Esc cancels, blank clears. An agent with no description shows the same line as an 'Add a description' prompt, so the header keeps one shape. The line truncates at the composer column's width. Package pieces, all additive: useUpdateAgentDescription beside useRenameAgent, an optional Edit description entry on AgentActionsMenu, allowEmpty on useInlineRename, and updateWorkflow treating an empty description as a change rather than skipping the request. --- .../features/agents/AgentOverviewTitle.tsx | 73 +++++++++++++++---- .../agenta-entities/src/workflow/api/api.ts | 6 +- .../src/agent/AgentActionsMenu.tsx | 18 ++++- .../agenta-entity-ui/src/agent/index.ts | 7 +- .../src/agent/useAgentActions.tsx | 21 ++++++ .../agenta-sessions-ui/src/useInlineRename.ts | 7 +- 6 files changed, 112 insertions(+), 20 deletions(-) diff --git a/web/mobile/src/features/agents/AgentOverviewTitle.tsx b/web/mobile/src/features/agents/AgentOverviewTitle.tsx index 54f57170ce2..352e49df5f5 100644 --- a/web/mobile/src/features/agents/AgentOverviewTitle.tsx +++ b/web/mobile/src/features/agents/AgentOverviewTitle.tsx @@ -5,12 +5,16 @@ import { AgentChip, AgentIconPopover, useRenameAgent, + useUpdateAgentDescription, } from "@agenta/entity-ui/agent" import {InlineRenameInput, useDeferredMenuSelect, useInlineRename} from "@agenta/sessions-ui" +import {useMediaQuery} from "@agenta/ui/hooks" import {ChatCircleDots} from "@phosphor-icons/react" import {Button} from "@/components/ui/button" import {Skeleton} from "@/components/ui/skeleton" +import {FOCUS_RING} from "@/lib/interactive" +import {cn} from "@/lib/utils" /** * Who the agent is, as the overview's header row: the roster's tile (icon picker behind it), the @@ -41,21 +45,39 @@ export const AgentOverviewTitle = ({ onCommit, errorText: "Couldn't rename this agent", }) + // The description edits the same way, one line under the name. Blank is a real value here: + // it is how a description is removed. + const updateDescription = useUpdateAgentDescription() + const onCommitDescription = useCallback( + (next: string) => updateDescription(agentId, next), + [agentId, updateDescription], + ) + const describe = useInlineRename({ + current: description ?? "", + onCommit: onCommitDescription, + errorText: "Couldn't update this agent's description", + allowEmpty: true, + }) // The editor must not mount inside the menu's focus trap, so the verb runs from the close. const {handleSelect, handleCloseAutoFocus} = useDeferredMenuSelect((key) => { if (key === "rename") return () => rename.start() + if (key === "describe") return () => describe.start() }) + // Tailwind's `lg`, where the rail sits beside the list and the header has room for the rest. + const wide = useMediaQuery("(min-width: 1024px)") return ( <> {/* The row is top-aligned so a two-line description hangs under the title rather - than pushing everything to its middle. On a phone each control nudges down so its - centre sits on the title line; from `sm` the tile's top edge sits on the title's. */} + than pushing everything to its middle. Below `lg` each control nudges down so its + centre sits on the title line; from `lg` the tile's top edge sits on the title's + glyphs. */} - {/* The playground bar's 24px on a phone, the roster's 32 from `sm`. */} - + {/* The playground bar's 24px below `lg`, the roster's 32 beside the rail. `mt-1` puts + its top on the title's cap height, not on the line box above it. */} + -
+
{pending ? ( ) : rename.renaming ? ( @@ -73,19 +95,41 @@ export const AgentOverviewTitle = ({ {name} )} - {description ? ( -

- {description} -

- ) : null} + {/* Only from `lg`, beside the rail. One line, never wider than the composer + column beneath it: the rail takes a third of the page plus the 40px gap, so + the line stops there. Below `lg` the header is the name alone. */} + {pending || !wide ? null : describe.renaming ? ( + + ) : ( + // A button either way: the text is its own edit affordance, and an agent + // with no description gets the same line as a prompt, so the header keeps + // one shape. + + )}
- {/* A phone's header has no room beside the name; the composer below is the same verb. */} + {/* Only beside the rail: a narrower header has no room, and the composer is the same verb. */}