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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-agents-delete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@truefoundry/trueforge-ui': minor
---

Add deletion controls to the Agents Library and wire the bundled TrueForge adapter to the existing agent delete API.
4 changes: 2 additions & 2 deletions docs/ui-sdk/reference/server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ createTrueFoundryServer(options: CreateTrueFoundryServerOptions): TrueFoundrySer

Options: `{ chatServer, getCapabilities, getModels, getSkills, getMcp, searchAgents, saveAgent, deleteAgent?, catalog? }`.
Combines a chat server with your agent-management callbacks into a complete `AgentUIServer`.
`deleteAgent` and `catalog` are the only optional entries; if you omit `deleteAgent`, calling it
throws.
`deleteAgent` and `catalog` are the only optional entries; if you omit `deleteAgent`, the Agents
Library hides its delete controls.

## Context

Expand Down
4 changes: 2 additions & 2 deletions docs/ui-sdk/setup-custom-servers/custom-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ Spreading `nextPageToken` in only when present keeps the result valid under `exa

<AccordionGroup>
<Accordion title="deleteSession / deleteAgent">
In a hand-written `AgentUIServer` like the one above, omitting these leaves the corresponding affordances unavailable rather than broken. (This differs from the [`createTrueFoundryServer` composition helper](/ui-sdk/setup-custom-servers/connect-with-truefoundry#truefoundry-chat-your-own-builder), where an omitted `deleteAgent` throws when called.)
Omitting these leaves the corresponding affordances unavailable rather than broken, including when using the [`createTrueFoundryServer` composition helper](/ui-sdk/setup-custom-servers/connect-with-truefoundry#truefoundry-chat-your-own-builder).
</Accordion>

<Accordion title="downloadSandboxFile">
Expand Down Expand Up @@ -208,4 +208,4 @@ Attachment handling defaults to `trueFoundryAttachmentAdapter`. Override it thro

```tsx
<TrueForgeUI server={server} layout="sidebar" adapters={{ attachments: myAdapter }} />
```
```
Original file line number Diff line number Diff line change
Expand Up @@ -279,12 +279,7 @@ export function createTrueFoundryServer<
getMcp: opts.getMcp,
searchAgents: opts.searchAgents,
saveAgent: opts.saveAgent,
async deleteAgent(req: { agentName: string }) {
if (!opts.deleteAgent) {
throw new Error('deleteAgent is host-owned. Pass deleteAgent to createTrueFoundryServer.');
}
await opts.deleteAgent(req);
},
...(opts.deleteAgent !== undefined ? { deleteAgent: opts.deleteAgent } : {}),

getGatewayClients: () => ({ client, privateClient }),
};
Expand Down
18 changes: 9 additions & 9 deletions packages/trueforge-ui/docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -562,13 +562,14 @@ const server = createTrueFoundryServer({

### Catalog — complete for this UI

| UI call | Required response fields |
| --------------------------- | ---------------------------------------------------------- |
| `getModels()` | `name`, `provider`, `apiModel`, `modelId` |
| `getSkills()` | `id`, `name`; `fqn?`, `description?` |
| `getMcp()` | `id`, `name`; `description?` |
| `searchAgents(req?)` | `name`; optional display fields on `AgentLibraryEntry` |
| `saveAgent` / `deleteAgent` | On port; **not called by UI yet** (optional until Save UI) |
| UI call | Required response fields |
| -------------------- | --------------------------------------------------------- |
| `getModels()` | `name`, `provider`, `apiModel`, `modelId` |
| `getSkills()` | `id`, `name`; `fqn?`, `description?` |
| `getMcp()` | `id`, `name`; `description?` |
| `searchAgents(req?)` | `name`; optional display fields on `AgentLibraryEntry` |
| `saveAgent` | Called by the Save Agent flow |
| `deleteAgent` | Called from the Agents Library when the method is present |

### Chat — method list complete; standalone BYO is not

Expand All @@ -588,8 +589,7 @@ const server = createTrueFoundryServer({
enabled.

**On the port but unused by this UI today:**
`listOwnedSessions`, non-stream `createTurn`, `deleteSession`,
`saveAgent`, `deleteAgent`.
`listOwnedSessions`, non-stream `createTurn`.

### v1 BYO guidance

Expand Down
240 changes: 181 additions & 59 deletions packages/trueforge-ui/src/atoms/AgentsLibrary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

import { useEffect, useState } from 'react';

import { useToasterOptional } from '../containers/ToasterContainer.js';
import { Icon } from '../icons/Icon.js';
import { useOptionalServer } from '../server/ServerContext.js';
import { libraryAgentId, useOptionalShellMode } from '../server/ShellModeContext.js';
import type { AgentLibraryEntry, AgentSpec } from '../server/types.js';
import { getErrorMessage } from '../utils/getErrorMessage.js';
import { auiButtonClass } from './lib/buttonClasses.js';
import { cn } from './lib/cn.js';
import { useSearchAgentsList } from './lib/useSearchAgentsList.js';
Expand All @@ -22,8 +25,10 @@ export type AgentsLibraryProps = {
type AgentLibraryRowProps = {
agent: AgentLibraryEntry;
showEdit: boolean;
showDelete: boolean;
onTry: () => void;
onEdit: () => void;
onDelete: () => void;
};

/** Short label for model fqns like `provider/gpt-4.1` → `gpt-4.1`. */
Expand All @@ -32,7 +37,7 @@ function displayModelLabel(modelName: string): string {
return slash >= 0 ? modelName.slice(slash + 1) : modelName;
}

function AgentLibraryRow({ agent, showEdit, onTry, onEdit }: AgentLibraryRowProps) {
function AgentLibraryRow({ agent, showEdit, showDelete, onTry, onEdit, onDelete }: AgentLibraryRowProps) {
const spec = agent.agentSpec;
const modelName = spec?.model.name;
const skillsCount = spec?.skills?.length ?? 0;
Expand Down Expand Up @@ -80,6 +85,21 @@ function AgentLibraryRow({ agent, showEdit, onTry, onEdit }: AgentLibraryRowProp
</span>
) : null}
<span className="flex shrink-0 items-center gap-1.5">
{showDelete ? (
<button
type="button"
aria-label={`Delete agent ${agent.name}`}
title={`Delete agent ${agent.name}`}
className={auiButtonClass({
variant: 'ghost',
size: 'icon',
className: 'size-8 text-failure-bg hover:bg-failure-bg/12 hover:text-failure-bg',
})}
onClick={onDelete}
>
<Icon name="trash" className="size-3.5" />
</button>
) : null}
{showEdit ? (
<button
type="button"
Expand Down Expand Up @@ -113,9 +133,15 @@ function AgentLibraryRow({ agent, showEdit, onTry, onEdit }: AgentLibraryRowProp

export function AgentsLibrary({ open, onOpenChange, onSelectAgent }: AgentsLibraryProps) {
const shell = useOptionalShellMode();
const server = useOptionalServer();
const toaster = useToasterOptional();
const [query, setQuery] = useState('');
const [agentPendingDelete, setAgentPendingDelete] = useState<AgentLibraryEntry | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);

const canEdit = shell?.isComposerEnabled === true;
const canDelete = typeof server?.deleteAgent === 'function';
const agentsListEpoch = shell?.agentsListEpoch ?? 0;

useEffect(() => {
Expand All @@ -132,6 +158,8 @@ export function AgentsLibrary({ open, onOpenChange, onSelectAgent }: AgentsLibra
const closeLibrary = () => {
onOpenChange(false);
setQuery('');
setAgentPendingDelete(null);
setDeleteError(null);
};

const handleTry = (agent: AgentLibraryEntry) => {
Expand All @@ -155,68 +183,162 @@ export function AgentsLibrary({ open, onOpenChange, onSelectAgent }: AgentsLibra
});
};

const requestDelete = (agent: AgentLibraryEntry) => {
setDeleteError(null);
setAgentPendingDelete(agent);
};

const closeDeleteConfirmation = () => {
if (deleting) return;
setAgentPendingDelete(null);
setDeleteError(null);
};

const confirmDelete = async () => {
const agent = agentPendingDelete;
const deleteAgent = server?.deleteAgent;
if (agent == null || deleteAgent === undefined || deleting) return;

setDeleting(true);
setDeleteError(null);
try {
await deleteAgent({ agentName: agent.name });

const deletedAgentId = libraryAgentId(agent);
if (shell?.historyAgentFilter === deletedAgentId) {
shell.setHistoryAgentFilter(null);
}
if (
shell?.mode.status === 'active' &&
(shell.mode.agentId === deletedAgentId || shell.mode.agentName === agent.name)
) {
if (shell.isComposerEnabled) {
shell.openDraft();
} else {
shell.openLibraryHome();
}
}

shell?.invalidateAgentsList();
setAgentPendingDelete(null);
// Let the native dialog close before ToastStack chooses its portal target.
window.setTimeout(() => toaster?.showSuccess({ title: `${agent.name} deleted` }), 0);
} catch (caught) {
if (toaster == null) {
setDeleteError(getErrorMessage(caught, 'Could not delete agent.'));
} else {
toaster.showError(caught);
}
} finally {
setDeleting(false);
}
};

return (
<CenteredModal open={open} onOpenChange={onOpenChange} title="Agents Library">
<div className="bg-secondary-bg/40 flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b border-border px-4 py-3">
<SearchInput query={query} setQuery={setQuery} placeholder="Search agents" />
{isSearching ? (
<p className="text-text-secondary mt-1.5 text-xs" role="status">
Searching…
</p>
) : null}
<>
<CenteredModal open={open} onOpenChange={onOpenChange} title="Agents Library">
<div className="bg-secondary-bg/40 flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b border-border px-4 py-3">
<SearchInput query={query} setQuery={setQuery} placeholder="Search agents" />
{isSearching ? (
<p className="text-text-secondary mt-1.5 text-xs" role="status">
Searching…
</p>
) : null}
</div>
<div
ref={listRef}
className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2"
role="menu"
aria-label="Agents"
>
{isInitialLoading ? (
<div className="flex flex-col gap-2 p-1" role="status" aria-label="Loading agents">
{Array.from({ length: 6 }, (_, i) => (
<Skeleton key={i} className="h-11 w-full rounded-md" />
))}
</div>
) : error ? (
<p className="text-failure-bg px-3 py-8 text-center text-sm">{error}</p>
) : agents.length === 0 ? (
<p className="text-text-secondary px-3 py-8 text-center text-sm">
{query.trim()
? `No agents match "${query.trim()}".`
: 'No agents yet. Build one in a chat, then save it as an agent.'}
</p>
) : (
<>
{agents.map(agent => {
const agentSpec = agent.agentSpec;
const showEdit = canEdit && agentSpec != null;
return (
<AgentLibraryRow
key={libraryAgentId(agent)}
agent={agent}
showEdit={showEdit}
showDelete={canDelete}
onTry={() => handleTry(agent)}
onEdit={() => {
if (agentSpec != null) handleEdit(agent, agentSpec);
}}
onDelete={() => requestDelete(agent)}
/>
);
})}
{hasMore ? (
<div ref={sentinelRef} className="flex h-8 shrink-0 items-center justify-center" aria-hidden>
{loadingMore ? (
<span className="text-text-secondary text-xs" role="status">
Loading more…
</span>
) : null}
</div>
) : null}
</>
)}
</div>
</div>
<div
ref={listRef}
className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2"
role="menu"
aria-label="Agents"
>
{isInitialLoading ? (
<div className="flex flex-col gap-2 p-1" role="status" aria-label="Loading agents">
{Array.from({ length: 6 }, (_, i) => (
<Skeleton key={i} className="h-11 w-full rounded-md" />
))}
</div>
) : error ? (
<p className="text-failure-bg px-3 py-8 text-center text-sm">{error}</p>
) : agents.length === 0 ? (
<p className="text-text-secondary px-3 py-8 text-center text-sm">
{query.trim()
? `No agents match "${query.trim()}".`
: 'No agents yet. Build one in a chat, then save it as an agent.'}
</CenteredModal>

<CenteredModal
open={agentPendingDelete != null}
onOpenChange={nextOpen => {
if (!nextOpen) closeDeleteConfirmation();
}}
title={agentPendingDelete == null ? 'Delete agent' : `Delete ${agentPendingDelete.name}?`}
description="This removes the agent from the library. Existing chats will stay in your history."
contentSized
>
<div className="flex flex-col gap-4 p-5">
{deleteError != null ? (
<p
className="rounded-md border border-failure-bg/30 bg-failure-bg/10 px-3 py-2 text-sm text-failure-bg"
role="alert"
>
{deleteError}
</p>
) : (
<>
{agents.map(agent => {
const agentSpec = agent.agentSpec;
const showEdit = canEdit && agentSpec != null;
return (
<AgentLibraryRow
key={libraryAgentId(agent)}
agent={agent}
showEdit={showEdit}
onTry={() => handleTry(agent)}
onEdit={() => {
if (agentSpec != null) handleEdit(agent, agentSpec);
}}
/>
);
})}
{hasMore ? (
<div ref={sentinelRef} className="flex h-8 shrink-0 items-center justify-center" aria-hidden>
{loadingMore ? (
<span className="text-text-secondary text-xs" role="status">
Loading more…
</span>
) : null}
</div>
) : null}
</>
)}
) : null}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
className={auiButtonClass({ variant: 'outline' })}
disabled={deleting}
onClick={closeDeleteConfirmation}
>
Cancel
</button>
<button
type="button"
className={auiButtonClass({ variant: 'destructive' })}
disabled={deleting}
onClick={() => void confirmDelete()}
>
{deleting ? 'Deleting…' : 'Delete agent'}
</button>
</div>
</div>
</div>
</CenteredModal>
</CenteredModal>
</>
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,5 +111,14 @@ export function createHarnessBuilderServer(
const created = await client.agents.create({ name: agentName, manifest });
return { agentId: created.data.id };
},

async deleteAgent({ agentName }) {
const { data } = await client.agents.list();
const existing = data.find(agent => agent.name === agentName);
if (existing === undefined) {
return;
}
await client.agents.delete(existing.id);
},
};
}
Loading