feat(ui): bring the Refine-v2 layout to the agent page (#18378)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The agent page is where an operator reads and changes one agent.
> - The page grew one tab at a time. Ten tabs sit in one flat list, the
header holds many buttons, and the audit links leave the agent.
> - A design pass, called Refine-v2, made a calmer layout for this page.
It ran in a prototype on fixture data only.
> - This pull request moves the Refine-v2 layout and behavior into the
live agent page. Where the prototype and the live page differ, the live
behavior stays. For example, the floating save bar stays on screen.
> - The benefit is a shorter navigation, edits in place, and audit data
that stays inside the agent.

## Linked Issues or Issue Description

Refs #13716
Refs #9166

Both pull requests mount plugin tabs or slots on the agent page. They
touch the same files, so one of the three needs a rebase after the
others merge.

**Subsystem affected**

The agent detail page in `ui/` (`ui/src/pages/AgentDetail.tsx`, its
sidebar, header actions, and the shared agent config form).

**Problem or motivation**

The agent page is hard to scan. The navigation shows ten items in one
list. The header shows Run now, Pause, Clear error, and Assign task as
separate buttons. The name, title, and manager are form fields deep in
the Harness tab. Activity, Runs, Costs, and Budgets open company-wide
pages, so the operator loses the agent context. Each tab uses a
different card and table style.

**Proposed solution**

Use one layout system for every agent tab. Group the navigation into
Agent and Audit. Edit the name and title in the header. Move the work
actions into the actions menu. Show audit views inside the agent. Keep
the persistent save bar and add a status dot to it.

**Alternatives considered**

We considered a flag that lets operators opt in to the new layout. We
did not use a flag, because two layouts on one page double the test
surface. We also considered moving the save button to the end of the
scrolling content, as the prototype did. We kept the floating save bar,
because it is always visible.

**Roadmap alignment**

`ROADMAP.md` has no item for the agent page. This change is a UI
refinement of an existing page. It adds one optional field to an
existing API request and no database schema change.

## What Changed

- **Navigation.** The agent sidebar shows two groups. Agent has
Overview, Identity, Harness / Runtime, Secrets & variables, Permissions
/ Trust, and Advanced. Audit has Activity, Runs, Costs, and Budgets.
Skills, Tools, Channels, and API Keys leave the navigation. Their URLs
still open. The Instructions tab is now Identity, at `/identity`. The
old `/instructions` URL redirects.
- **Header.** The agent character shows at a larger size. The name and
title edit in place. Enter or blur saves. Escape cancels. An error
restores the old value. A rename updates the URL. While a tab has
unsaved changes, the header refuses the edit and asks the operator to
save or discard first. The third line shows the title and a link to the
manager. A crown button to the right of the name replaces the "Set as my
primary" link and the separate primary indicator. It is outlined and
dimmed until the agent is your primary, then fills with a short pop.
Replacing an existing primary asks for confirmation first, and the pop
plays after the dialog closes. Reduced motion skips the pop. Run now,
Pause or Resume, Clear error, and Agent history move into the actions
menu. Assign task is the primary button. For an agent that still needs
setup, Finish setup is the primary button and Assign task moves into the
menu.
- **Agent history.** A new dialog in the actions menu replaces the
Revisions entry. Each row shows a plain-language summary, the person or
agent who made the change, the date, and Restore. Restore asks for
confirmation. A failed restore shows its reason inside the confirmation.
- **Overview.** Four metric tiles show activity this week, runs this
week, costs this month, and budget used. Each tile links to its audit
view. When the list behind a count is capped, the tile shows a lower
bound, for example 200+. The activity tile follows live updates.
Profile, Skills, and Recent tasks panels follow. The Profile panel lists
direct reports under their label in at most two rows, then a "+N more"
button that expands the list. The Latest run card is removed.
- **Identity.** The file preview shows the raw text. A pencil button
switches to a plain text editor. Copy and Delete are icon buttons. A
chip next to the file name says "Managed by you" or "Managed
externally". Per-file history and unsaved edits stay.
- **External instructions.** "Advanced settings" opens Root path and
Entry file fields and a Load profile button. Load profile asks the
server to switch only if the root directory and the entry file already
exist. If either is missing, the server refuses and the panel shows the
reason. The entry file can have any name inside the root.
- **Instructions bundle API.** `PATCH /agents/:id/instructions-bundle`
accepts an optional `requireExistingEntry` flag (`packages/shared`
validator, `server` service, `ui` client). With the flag, the server
checks the root directory and entry file before it creates or copies
anything. Without the flag, the behavior does not change.
- **Harness / Runtime.** The config form gets a `panels` layout: Harness
and Execution side by side, then the runtime test, then environment
variables. The identity and run-policy sections are hidden from this
tab.
- **Secrets & variables and Advanced.** These tabs use the same filled
panels and table headers.
- **Permissions / Trust.** The trust presets show as selectable cards.
An Allowed connectors panel shows up to three connector logos and links
to Apps, filtered to installed connectors. Capabilities sit in their own
panel. Changes on this tab save at once, with no save bar.
- **Audit inside the agent.** Activity, Runs, Costs, and Budgets render
inside the agent page. Runs is locked to the current agent. A failed
budget incident action shows its reason under the incident.
- **Save bar.** The floating save bar stays. It shows an amber dot for
unsaved changes and a green dot with "Changes saved" after a save.
Discard stays.
- **Leave guard.** If the form has unsaved changes and the operator
clicks a link, a dialog asks to keep editing or discard the changes.
Browser back and the header actions keep the native confirm.
- **Apps.** `/apps?source=installed` opens the Apps page on the
Installed filter.
- **Tokens.** New `--agent-detail-*` tokens in `ui/src/index.css` hold
every spacing and fill value. Each fill is a mix of foreground into
background, so one value works in light and dark themes.
- **Tests and maps.** Tests cover the navigation model, sidebar, header
menu, Overview layout, Identity editor, and helpers. The
agent-configuration recipe in `maps/feature-map/agents.md` describes the
new page.

## Verification

- `cd ui && npx vitest run`: 849 test files and 10,081 tests pass.
- `cd server && npx vitest run
src/__tests__/agent-instructions-routes.test.ts
src/__tests__/agent-instructions-service.test.ts`: 40 tests pass.
- `npx playwright test --config tests/e2e/playwright.config.ts
tests/e2e/onboarding.spec.ts
tests/e2e/chat-adapters-ui-providers.spec.ts`: 26 tests pass.
- `cd ui && pnpm typecheck` passes. This includes the Storybook catalog
check. The `server` and `packages/shared` typechecks pass.
- `cd ui && pnpm build` passes.
- `pnpm run check:token-gates` reports all gates clean.
- `node scripts/check-maps.mjs` and `node scripts/storybook-catalog.mjs
--check` pass.
- Manual check on a local instance, in light and dark themes:
1. Open an agent. Confirm the sidebar shows the Agent and Audit groups.
2. Click the agent name in the header. Type a new name and press Enter.
Confirm the URL changes. Rename it back.
3. Open the actions menu. Open Agent history. Confirm the rows and the
Restore confirmation.
4. Open Identity. Click the pencil. Change a line. Confirm the save bar
shows the amber dot. Click a sidebar link. Confirm the discard dialog.
Click Keep editing, then save. Confirm the green dot.
5. Open Harness / Runtime, Secrets & variables, and Permissions / Trust.
Confirm the panels and the trust cards.
6. Open Runs under Audit. Confirm the list shows only this agent and the
page stays inside the agent.

## Risks

- This changes a high-traffic page for every operator at once. There is
no flag to switch back.
- Run policy and Reports to are no longer editable on the agent page.
The header shows the manager as a link only.
- Skills, Tools, Channels, and API Keys are reachable by URL only.
Operators who use them from the sidebar must use the URL.
- The pull request conflicts with #13716 and #9166 in `AgentDetail.tsx`
and the agent sidebar.
- Storybook stories for the agent page and `DESIGN.md` are not updated
in this pull request.
- The new `requireExistingEntry` flag is opt-in. Existing API callers
keep the old behavior, which creates a missing external directory and
entry file.
- I ran two e2e spec files locally, not the full `pnpm test:e2e` or
`pnpm test:release-smoke`. CI runs the full e2e suite. I did not run the
repo-wide `pnpm -r typecheck`, because the local toolchain has no Rust.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), in Claude Code, with
reasoning and tool use (shell, file edits, and a browser for visual
checks). A person directed every design decision.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
TonioandClaude Opus 5.5 authored and GitHub committed 2026-11-01 22:27:09 -08:00
1 parent 1a6017d646
commit 8ea240218d
40 files changed
+2660 -784

No files matched your search

+24 -8
View File
@@ -85,14 +85,28 @@ resource allocation waits for approval.
### `agent-configuration`
Open `/agents/:agentId` and the configuration/instruction surfaces it exposes.
The agent nav has two sections: **Agent** (Overview, Identity at
`/agents/:agentId/identity`, Harness / Runtime, Secrets & variables,
Permissions / Trust, Advanced) and **Audit** (Activity, Runs, Costs, Budgets),
which render inside the agent, filtered to it. Skills, Tools, Channels, API Keys
and Revisions are not in the nav but still open by URL. Edit the name and title
in place in the header (Enter or blur saves, Escape cancels); while a tab has
unsaved changes, the header refuses the edit and asks to save or discard first.
Assign task is the header's one button (Finish setup while setup is pending);
Run now, Pause/Resume, Clear error, **Agent history** (a dialog listing
revisions with a confirmed Restore), Duplicate, Copy ID, Reset sessions and
Terminate are in the actions menu. On Identity, **Advanced settings** →
**Load profile** switches to an external root only when the root directory and
entry file already exist; otherwise the server refuses and the panel shows why.
Open **Advanced** at `/agents/:agentId/advanced` for the compact agent
[YAML configuration](../../doc/agent-configuration-yaml.md). Edit a name, budget,
runtime setting, and adapter key; save, reload, and check the ordinary settings
and Revisions. Try malformed YAML, an unknown field, omitted settings, a removed adapter key,
and Agent history. Try malformed YAML, an unknown field, omitted settings, a removed adapter key,
and a concurrent settings edit. Errors must retain the draft; stale saves return
a conflict. Unchanged secret markers must preserve their stored values. Navigating
away with a draft must show the existing unsaved-change guard. **Reload saved**
a conflict. Unchanged secret markers must preserve their stored values. Following
a link away from a draft must open the Discard unsaved changes? dialog; Back/Forward
keeps the browser confirmation. **Reload saved**
discards the draft. Also edit instruction text, skill pins, direct tool installs,
channel settings/resources, permissions/trust/project boundaries, secret operations,
and API key operations. Verify each result in its owning tab. Revision restore is
@@ -132,12 +146,13 @@ Use the roster’s organization view; `/org` redirects to the roster in the stre
### `personal-primary-agent`
Open an agent profile and choose **Set as my primary**. Inspect its crown on the
profile/roster and its position in the Agents sidebar, then open a new task or Chat.
Open an agent profile and choose the outlined crown beside its name (**Set as my
primary**). Inspect the filled crown on the profile, the crown on the roster and
its position in the Agents sidebar, then open a new task or Chat.
### `agent-api-keys`
Open `/agents/:agentId/api-keys` → **API Keys** with an authenticated person who can manage agents. The operator CLI also supports `paperclipai token agent create -C <company-id> --agent <agent-id> --name <label>`, `token agent list -C <company-id> --agent <agent-id>`, and `token agent revoke <key-id> -C <company-id> --agent <agent-id>`.
Open `/agents/:agentId/api-keys` → **API Keys** (by URL; it is not in the agent nav) with an authenticated person who can manage agents. The operator CLI also supports `paperclipai token agent create -C <company-id> --agent <agent-id> --name <label>`, `token agent list -C <company-id> --agent <agent-id>`, and `token agent revoke <key-id> -C <company-id> --agent <agent-id>`.
### `managed-agent-cli`
@@ -274,9 +289,10 @@ Manual: Inspect a manager and report, change the relationship through the offere
### `personal-primary-agent`
Automated: [primary-agent context](../../ui/src/context/PrimaryAgentProvider.test.tsx)
Automated: [primary-agent context](../../ui/src/context/PrimaryAgentProvider.test.tsx),
[the profile crown](../../ui/src/components/primary-agent/PrimaryAgentPresentation.test.tsx)
and [primary-agent routes](../../server/src/__tests__/primary-agent.test.ts)
cover selection, company/user scope, and request contracts.
cover selection, replacement confirmation, company/user scope, and request contracts.
Manual: Select an available agent, reload, and inspect the retained personal choice.
Replace it and verify confirmation is required. Leave or terminate the selected
+1
View File
@@ -28,6 +28,7 @@ export const updateAgentInstructionsBundleSchema = z.object({
rootPath: z.string().trim().min(1).nullable().optional(),
entryFile: z.string().trim().min(1).optional(),
clearLegacyPromptTemplate: z.boolean().optional().default(false),
requireExistingEntry: z.boolean().optional(),
});
export type UpdateAgentInstructionsBundle = z.infer<typeof updateAgentInstructionsBundleSchema>;
@@ -122,6 +122,38 @@ describe("agent instructions service", () => {
await expect(fs.readFile(path.join(result.bundle.managedRootPath, "docs", "TOOLS.md"), "utf8")).resolves.toBe("## Tools\n");
});
it("refuses an external root without the entry file when the entry must already exist", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-home-");
const managedRoot = path.join(paperclipHome, "instances", "test-instance", "companies", "company-1", "agents", "agent-1", "instructions");
const externalRoot = await makeTempDir("paperclip-agent-instructions-existing-external-");
cleanupDirs.add(paperclipHome);
cleanupDirs.add(externalRoot);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
await fs.mkdir(managedRoot, { recursive: true });
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
const svc = agentInstructionsService();
const agent = makeAgent({
instructionsBundleMode: "managed",
instructionsRootPath: managedRoot,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: path.join(managedRoot, "AGENTS.md"),
});
const missingRoot = path.join(externalRoot, "missing");
await expect(svc.updateBundle(agent, { mode: "external", rootPath: missingRoot, entryFile: "AGENTS.md", requireExistingEntry: true }))
.rejects.toThrow(`No directory exists at ${missingRoot}`);
await expect(fs.stat(missingRoot)).rejects.toThrow();
await expect(svc.updateBundle(agent, { mode: "external", rootPath: externalRoot, entryFile: "AGENTS", requireExistingEntry: true }))
.rejects.toThrow(`AGENTS was not found in ${externalRoot}`);
expect(await fs.readdir(externalRoot)).toEqual([]);
await fs.writeFile(path.join(externalRoot, "AGENTS"), "# External Agent\n", "utf8");
const result = await svc.updateBundle(agent, { mode: "external", rootPath: externalRoot, entryFile: "AGENTS", requireExistingEntry: true });
expect(result.bundle).toMatchObject({ mode: "external", rootPath: externalRoot, entryFile: "AGENTS" });
await expect(fs.readFile(path.join(externalRoot, "AGENTS"), "utf8")).resolves.toBe("# External Agent\n");
});
it("creates the target entry file when switching to a new external root", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-home-");
const managedRoot = path.join(
+15
View File
@@ -123,6 +123,17 @@ function normalizeRelativeFilePath(candidatePath: string): string {
return instructionPath(candidatePath);
}
async function assertExistingExternalEntry(rootPath: string, entryFile: string) {
const root = await statIfExists(rootPath);
if (!root?.isDirectory()) {
throw unprocessable(`No directory exists at ${rootPath}`);
}
const entry = await statIfExists(path.join(rootPath, entryFile));
if (!entry?.isFile()) {
throw unprocessable(`${entryFile} was not found in ${rootPath}`);
}
}
function resolvePathWithinRoot(rootPath: string, relativePath: string): string {
const normalizedRelativePath = normalizeRelativeFilePath(relativePath);
const absoluteRoot = path.resolve(rootPath);
@@ -622,6 +633,7 @@ export function agentInstructionsService(db?: Db) {
rootPath?: string | null;
entryFile?: string;
clearLegacyPromptTemplate?: boolean;
requireExistingEntry?: boolean;
},
): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record<string, unknown> }> {
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
@@ -647,6 +659,9 @@ export function agentInstructionsService(db?: Db) {
}
await assertInstructionPathSafe(nextRootPath, nextEntryFile);
if (nextMode === "external" && input.requireExistingEntry) {
await assertExistingExternalEntry(nextRootPath, nextEntryFile);
}
await fs.mkdir(nextRootPath, { recursive: true });
const existingFiles = await listFilesRecursive(nextRootPath);
+3 -3
View File
@@ -476,9 +476,9 @@ test.describe.serial("native chat adapter UI", () => {
await expect(
page.getByRole("link", { name: "Connect a channel" }),
).toBeVisible();
await expect(
page.getByRole("navigation", { name: "Maya navigation" }).getByRole("link", { name: "Channels", exact: true }),
).toBeVisible();
const agentNavigation = page.getByRole("navigation", { name: "Maya navigation" });
await expect(agentNavigation.getByRole("link", { name: "Identity", exact: true })).toBeVisible();
await expect(agentNavigation.getByRole("link", { name: "Channels", exact: true })).toHaveCount(0);
await expect.poll(() => mock.chatEndpointListReads).toBeGreaterThan(0);
if (!enabled) {
await expect(page.getByText("Connect AgentMail from Connectors.", { exact: true })).toBeVisible();
+1
View File
@@ -180,6 +180,7 @@ export const agentsApi = {
rootPath?: string | null;
entryFile?: string;
clearLegacyPromptTemplate?: boolean;
requireExistingEntry?: boolean;
},
companyId?: string,
) => api.patch<AgentInstructionsBundle>(agentPath(id, companyId, "/instructions-bundle"), data),
@@ -6,6 +6,7 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { History } from "lucide-react";
import { AgentActionButtons } from "./AgentActionButtons";
import { useAgentLifecycleStatus } from "./AgentLifecycleStatus";
import { queryKeys } from "../lib/queryKeys";
@@ -277,6 +278,57 @@ describe("AgentActionButtons", () => {
expect(document.body.textContent).toContain("Reset Sessions");
});
it("moves work actions into the menu, leaving Assign task as the one button", async () => {
const openHistory = vi.fn();
render(makeAgent({ status: "error" }), {
assignLabel: "Assign task",
workActionsInMenu: true,
menuItems: [{ label: "Agent history", icon: History, onSelect: openHistory }],
});
await flushReact();
expect([...container.querySelectorAll("button")].map((button) => button.textContent?.trim()).filter(Boolean)).toEqual(["Assign task"]);
await act(async () => {
container.querySelector<HTMLButtonElement>('[aria-label="Open actions for Alpha Agent"]')?.click();
});
await flushReact();
const items = [...document.body.querySelectorAll("[data-slot='popover-content'] button, [data-radix-popper-content-wrapper] button")].map((button) => button.textContent?.trim());
expect(items).toEqual(["Run Heartbeat", "Clear error", "Agent history", "Duplicate agent", "Copy agent ID", "Reset sessions", "Terminate"]);
await act(async () => {
[...document.body.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Agent history")?.click();
});
expect(openHistory).toHaveBeenCalledTimes(1);
await act(async () => {
container.querySelector<HTMLButtonElement>('[aria-label="Open actions for Alpha Agent"]')?.click();
});
await flushReact();
await act(async () => {
document.body.querySelector<HTMLButtonElement>('[aria-label="Clear error and return agent to idle"]')?.click();
});
await flushReact();
expect(mockAgentsApi.clearError).toHaveBeenCalledWith("agent-1", "company-1");
});
it("keeps Finish setup as the one button while setup is pending in menu mode", async () => {
render(makeAgent({ status: "paused", lifecycleState: "paused", pauseReason: "setup" }), {
assignLabel: "Assign task",
workActionsInMenu: true,
});
await flushReact();
expect([...container.querySelectorAll("button")].map((button) => button.textContent?.trim()).filter(Boolean)).toEqual(["Finish setup"]);
await act(async () => {
container.querySelector<HTMLButtonElement>('[aria-label="Open actions for Alpha Agent"]')?.click();
});
await flushReact();
const items = [...document.body.querySelectorAll("[data-slot='popover-content'] button, [data-radix-popper-content-wrapper] button")].map((button) => button.textContent?.trim());
expect(items).toEqual(["Assign task", "Duplicate agent", "Copy agent ID", "Reset sessions", "Terminate"]);
});
it("calls clearError and refreshes agent-related queries", async () => {
render(makeAgent({ status: "error" }));
await flushReact();
+180 -1
View File
@@ -33,7 +33,7 @@ import { AgentStatusBadge } from "./StatusBadge";
import { agentsApi } from "../api/agents";
import { ApiError } from "../api/client";
import { queryKeys } from "../lib/queryKeys";
import { agentRouteRef } from "../lib/utils";
import { agentRouteRef, cn } from "../lib/utils";
import { pendingAgentSetupChoice } from "../lib/agent-setup-choice";
import { copyTextToClipboard } from "../lib/clipboard";
import { useDialogActions } from "../context/DialogContext";
@@ -174,6 +174,8 @@ export function AgentActionButtons({
onTerminateSuccess,
pauseConfirm,
hideTerminate = false,
workActionsInMenu = false,
menuItems = [],
children,
className,
}: {
@@ -199,6 +201,10 @@ export function AgentActionButtons({
pauseConfirm?: { title: string; description: ReactNode };
/** Hide the Terminate action (e.g. built-in agents are undeletable). */
hideTerminate?: boolean;
/** Put Run now, Pause/Resume and Clear error in the overflow menu, leaving Assign task (or Finish setup, while setup is pending) as the one button. */
workActionsInMenu?: boolean;
/** Extra overflow items shown after the work actions when `workActionsInMenu` is on. */
menuItems?: ReadonlyArray<{ label: string; icon: typeof Plus; onSelect: () => void; disabled?: boolean }>;
/**
* Optional inline error reporter. When provided it is used instead of a toast
* for action failures (preserves the detail page's inline error banner). When
@@ -364,6 +370,179 @@ export function AgentActionButtons({
: null;
const persistentProviderTrace = runtimeDebug?.providerTrace === "raw";
const finishSetup = () => {
if (!confirmNavigationStart(agentActionStartedDirtyRef)) return;
navigate(`/agents/new?${new URLSearchParams({
name: agent.name, adapterType: agent.adapterType, setupAgentId: agent.id,
runnerProvider: String(agent.adapterConfig.provider === "acpx" ? agent.adapterConfig.acpxAgent : agent.adapterConfig.provider ?? "codex"),
...(agent.adapterType === "paperclip_runner" ? { runner: "paperclip" } : ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(agent.adapterType) ? { runner: "legacy" } : {}),
...pendingAgentSetupChoice(agent),
})}`);
};
const pauseDialog = pauseConfirm ? (
<AlertDialog open={pauseConfirmOpen} onOpenChange={setPauseConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{pauseConfirm.title}</AlertDialogTitle>
<AlertDialogDescription asChild>
<div>{pauseConfirm.description}</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => agentAction.mutate("pause")}>
Pause anyway
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null;
if (workActionsInMenu) {
const item = "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-50";
const icon = "size-4 shrink-0 text-muted-foreground";
const select = (action: () => void) => () => {
setMoreOpen(false);
action();
};
return (
<div className={className ?? "flex shrink-0 items-center gap-2"}>
{persistentProviderTrace ? (
<span
className="hidden items-center gap-1 rounded-md border border-primary/30 bg-primary/10 px-2 py-1 text-xs font-medium text-primary lg:inline-flex"
title="Exact provider traffic will be captured for future runs and retained for up to 24 hours."
>
<Bug className="h-3.5 w-3.5" />
Raw tracing on
</span>
) : null}
{children}
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" aria-label={`Open actions for ${agent.name}`}>
<MoreHorizontal className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-52 p-1" align="end">
{needsSetup ? (
<button
type="button"
className={item}
disabled={assignAndRunDisabled}
title={workActionsDisabled ? workActionsDisabledReason : undefined}
onClick={select(() => openNewIssue({ assigneeAgentId: agent.id }))}
>
<Plus className={icon} />
{assignLabel}
</button>
) : showRun ? (
<button
type="button"
className={item}
disabled={assignAndRunDisabled}
title={workActionsDisabled ? workActionsDisabledReason : undefined}
onClick={select(() => {
if (navigateToRunOnInvoke && !confirmNavigationStart(agentActionStartedDirtyRef)) return;
agentAction.mutate("invoke");
})}
>
<Play className={icon} />
{runLabel}
</button>
) : null}
{needsSetup ? null : isError ? (
<button
type="button"
className={cn(item, "text-destructive")}
disabled={clearErrorDisabled}
aria-label="Clear error and return agent to idle"
onClick={select(() => agentAction.mutate("clear_error"))}
>
<CheckCircle2 className={cn(icon, "text-destructive")} />
Clear error
</button>
) : isPaused ? (
<button type="button" className={item} disabled={pauseResumeDisabled} onClick={select(() => agentAction.mutate("resume"))}>
<Play className={icon} />
Resume
</button>
) : (
<button
type="button"
className={item}
disabled={pauseResumeDisabled}
onClick={select(() => (pauseConfirm ? setPauseConfirmOpen(true) : agentAction.mutate("pause")))}
>
<Pause className={icon} />
Pause
</button>
)}
<div role="separator" className="-mx-1 my-1 h-px bg-border" />
{menuItems.map(({ label, icon: Icon, onSelect, disabled: itemDisabled }) => (
<button key={label} type="button" className={item} disabled={itemDisabled} onClick={select(onSelect)}>
<Icon className={icon} />
{label}
</button>
))}
<button type="button" className={item} disabled={duplicateAgent.isPending} onClick={handleDuplicateAgent}>
{duplicateAgent.isPending ? <Loader2 className={cn(icon, "animate-spin")} /> : <Copy className={icon} />}
Duplicate agent
</button>
<button
type="button"
className={item}
onClick={select(() => {
void copyTextToClipboard(agent.id).catch(() => {
pushToast({ title: "Copy failed", body: "Clipboard access is unavailable.", tone: "error" });
});
})}
>
<Copy className={icon} />
Copy agent ID
</button>
<button type="button" className={item} onClick={select(() => resetTaskSession.mutate())}>
<RotateCcw className={icon} />
Reset sessions
</button>
{hideTerminate ? null : (
<>
<div role="separator" className="-mx-1 my-1 h-px bg-border" />
<button
type="button"
className={cn(item, "text-destructive")}
onClick={select(() => {
if (onTerminateSuccess && !confirmNavigationStart(agentActionStartedDirtyRef)) return;
agentAction.mutate("terminate");
})}
>
<Trash2 className={cn(icon, "text-destructive")} />
Terminate
</button>
</>
)}
</PopoverContent>
</Popover>
{needsSetup ? (
<Button size={size} disabled={disabled} onClick={finishSetup}>
<Play className="size-4" />
Finish setup
</Button>
) : (
<Button
size={size}
onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
disabled={assignAndRunDisabled}
title={workActionsDisabled ? workActionsDisabledReason : undefined}
>
<Plus className="size-4" />
{assignLabel}
</Button>
)}
{pauseDialog}
</div>
);
}
return (
<div className={className ?? "flex items-center gap-1 sm:gap-2 shrink-0"}>
{persistentProviderTrace ? (
+39 -22
View File
@@ -145,7 +145,9 @@ type AgentConfigFormProps = {
/** Keep variable bindings beside secret access in a unified edit surface. */
environmentVariablesPlacement?: "configuration" | "secrets";
/** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */
sectionLayout?: "inline" | "cards";
sectionLayout?: "inline" | "cards" | "panels";
/** Sections to leave out entirely (by `data-config-section` key). */
hiddenSections?: readonly string[];
/** Optional settings composition; sorting changes DOM order as well as visual order. */
sectionOrder?: readonly string[];
sectionTitles?: Record<string, string>;
@@ -323,12 +325,21 @@ function clampDelayMsFromSeconds(value: number) {
return clampInteger(value, 0, MAX_TURN_CONTINUATION_MAX_DELAY_SEC) * 1000;
}
function ConfigSections({ order, className, children }: {
function ConfigSections({ order, hidden, className, children }: {
order?: readonly string[];
hidden?: readonly string[];
className: string;
children: ReactNode;
}) {
if (!order) return <div className={className}>{children}</div>;
const keyOf = (child: ReactNode) =>
isValidElement<{ "data-config-section"?: string }>(child) ? child.props["data-config-section"] : undefined;
const visible = hidden?.length
? Children.toArray(children).filter((child) => {
const key = keyOf(child);
return !key || !hidden.includes(key);
})
: children;
if (!order) return <div className={className}>{visible}</div>;
const rank = (child: ReactNode) => {
const key = isValidElement<{ "data-config-section"?: string }>(child)
? child.props["data-config-section"]
@@ -336,7 +347,7 @@ function ConfigSections({ order, className, children }: {
const index = key ? order.indexOf(key) : -1;
return index < 0 ? order.length : index;
};
const sections = Children.toArray(children).sort((a, b) => rank(a) - rank(b));
const sections = Children.toArray(visible).sort((a, b) => rank(a) - rank(b));
return <div className={className}>{sections}</div>;
}
@@ -345,7 +356,12 @@ function ConfigSections({ order, className, children }: {
export function AgentConfigForm(props: AgentConfigFormProps) {
const { mode, adapterModels: externalModels } = props;
const isCreate = mode === "create";
const cards = props.sectionLayout === "cards";
const panels = props.sectionLayout === "panels";
const cards = props.sectionLayout === "cards" || panels;
const cardBody = panels
? "space-y-3 rounded-lg bg-(--agent-detail-tile) p-(--agent-detail-module-inset)"
: "border border-border rounded-lg p-4 space-y-3";
const untitled = (key: string) => panels && props.sectionTitles?.[key] === "";
const showAdapterTypeField = props.showAdapterTypeField ?? true;
const showInlineAdapterTestEnvironmentFeedback = !props.onTestFeedbackChange;
const showCreateRunPolicySection = props.showCreateRunPolicySection ?? true;
@@ -1539,7 +1555,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
if (!isCreate && props.content === "secrets") {
return (
<div className={cn("relative", cards && "space-y-6")}>
<div className={cn("relative", panels ? "flex flex-col gap-(--agent-detail-band-gap)" : cards && "space-y-6")}>
{isDirty && !props.hideInlineSave && (
<div className="sticky top-0 z-10 flex items-center justify-end border-b border-primary/20 bg-background/90 px-4 py-2 backdrop-blur-sm">
<div className="flex items-center gap-3">
@@ -1557,7 +1573,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? <h3 className="mb-3 text-sm font-medium">Environment variables</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Environment variables</div>
}
<div className={cn(cards ? "rounded-lg border border-border p-4" : "px-4 pb-3")}>
<div data-agent-tile={panels ? "" : undefined} className={cn(cards ? (panels ? "rounded-lg bg-(--agent-detail-tile) p-(--agent-detail-module-inset)" : "rounded-lg border border-border p-4") : "px-4 pb-3")}>
{environmentVariablesEditor}
</div>
</div>
@@ -1568,7 +1584,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? <h3 className="mb-3 text-sm font-medium">Secret access</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Secret access</div>
}
<div className={cn(cards ? "space-y-3 rounded-lg border border-border p-4" : "space-y-3 px-4 pb-3")}>
<div data-agent-tile={panels ? "" : undefined} className={cn(cards ? cardBody : "space-y-3 px-4 pb-3")}>
<p className="text-xs text-muted-foreground">{help.secretAccess}</p>
<AgentSecretAccessEditor
config={{ ...config, ...overlay.adapterConfig }}
@@ -1619,7 +1635,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{upgradeWarnings.map(warning => <p key={warning.field} className="text-sm text-muted-foreground">{warning.message}</p>)}
</div>
) : null}
<ConfigSections order={props.sectionOrder} className={cn("relative", cards && "space-y-6")}>
<ConfigSections order={props.sectionOrder} hidden={props.hiddenSections} className={cn("relative", panels ? "agent-config-panels" : cards && "space-y-6")}>
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
{isDirty && !props.hideInlineSave && (
<div className="sticky top-0 z-10 flex items-center justify-end px-4 py-2 bg-background/90 backdrop-blur-sm border-b border-primary/20">
@@ -1643,7 +1659,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? <h3 className="text-sm font-medium mb-3">{props.sectionTitles?.["identity"] ?? "Identity"}</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Identity</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<div data-agent-tile={panels ? "" : undefined} className={cn(cards ? cardBody : "px-4 pb-3 space-y-3")}>
<Field label="Name" hint={help.name}>
<DraftInput
value={eff("identity", "name", props.agent.name)}
@@ -1706,10 +1722,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
// Render the environment read-only instead of the selectable picker.
<div data-config-section="environment" className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
{cards
? <h3 className="text-sm font-medium mb-3">Environment</h3>
? (untitled("environment") ? null : <h3 className="text-sm font-medium mb-3">Environment</h3>)
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Environment</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<div data-agent-tile={panels ? "" : undefined} className={cn(cards ? cardBody : "px-4 pb-3 space-y-3")}>
<Field
label="Default environment"
hint="This instance runs all agents in the Kubernetes sandbox. Local execution is disabled."
@@ -1731,10 +1747,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
) : showEnvironmentOverrideControl ? (
<div data-config-section="environment" className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
{cards
? <h3 className="text-sm font-medium mb-3">Environment</h3>
? (untitled("environment") ? null : <h3 className="text-sm font-medium mb-3">Environment</h3>)
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Environment</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<div data-agent-tile={panels ? "" : undefined} className={cn(cards ? cardBody : "px-4 pb-3 space-y-3")}>
<Field label="Environment override">
<div className="space-y-2">
<SelectPopover
@@ -1763,9 +1779,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{/* ---- Adapter ---- */}
<div data-config-section="adapter" className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
<div className={cn(cards ? "flex items-center justify-between mb-3" : "px-4 py-2 flex items-center justify-between gap-2")}>
{untitled("adapter") && !showInlineAdapterTestEnvironmentButton ? null : <div className={cn(cards ? "flex items-center justify-between mb-3" : "px-4 py-2 flex items-center justify-between gap-2")}>
{cards
? <h3 className="text-sm font-medium">{props.sectionTitles?.["adapter"] ?? "Adapter"}</h3>
? (untitled("adapter") ? <span /> : <h3 className="text-sm font-medium">{props.sectionTitles?.["adapter"] ?? "Adapter"}</h3>)
: <span className="text-xs font-medium text-muted-foreground">Adapter</span>
}
{showInlineAdapterTestEnvironmentButton && (
@@ -1780,8 +1796,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{testActionPending ? `${testActionLabel}...` : testActionLabel}
</Button>
)}
</div>
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
</div>}
<div data-agent-tile={panels ? "" : undefined} className={cn(cards ? cardBody : "px-4 pb-3 space-y-3")}>
{showAdapterTypeField && isGrokBotRunner && (
<Field label="Harness" hint={help.adapterType}>
<div className="inline-flex items-center gap-1.5">
@@ -2028,10 +2044,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{(
<div data-config-section="configuration" className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="text-sm font-medium mb-3">Configuration</h3>
? (untitled("configuration") ? null : <h3 className="text-sm font-medium mb-3">Configuration</h3>)
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Configuration</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<div data-agent-tile={panels ? "" : undefined} className={cn(cards ? cardBody : "px-4 pb-3 space-y-3")}>
{!isCreate && typeof config.bootstrapPromptTemplate === "string" && config.bootstrapPromptTemplate && (
<>
<Field label="Bootstrap prompt (legacy)" hint={help.bootstrapPrompt}>
@@ -2151,7 +2167,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? <h3 className="text-sm font-medium mb-3">Environment variables</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Environment variables</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<div data-agent-tile={panels ? "" : undefined} className={cn(cards ? cardBody : "px-4 pb-3 space-y-3")}>
{isLocal ? environmentVariablesEditor : renderAdapterFields("environment")}
</div>
</div>
@@ -2164,7 +2180,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> Run Policy</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2"><Heart className="h-3 w-3" /> Run Policy</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<div data-agent-tile={panels ? "" : undefined} className={cn(cards ? cardBody : "px-4 pb-3 space-y-3")}>
<ToggleWithNumber
label="Heartbeat on interval"
hint={help.heartbeatInterval}
@@ -2362,6 +2378,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{props.compactTestFeedback && showInlineAdapterTestEnvironmentFeedback && showAdapterTestEnvironmentButton && (
<RuntimeTestCard
tone={panels ? "tile" : "card"}
variant={isExternalRunner ? "prerequisites" : "connection"}
metadataOnly={accountBoundCatalog}
state={testActionPending ? "running" : testActionError || testEnvironment.error ? "fail" : testResult?.status ?? "idle"}
@@ -36,7 +36,7 @@ vi.mock("./SidebarNavItem", () => ({
vi.mock("@/plugins/slots", () => ({ usePluginSlots: () => ({ slots: [{ pluginKey: "test.plugin", id: "insights", displayName: "Insights" }] }) }));
describe("AgentContextualSidebar", () => {
it.each([false, true])("keeps Channels available for default email regardless of the chat setting (%s)", (enabled) => {
it.each([false, true])("lists plugin tabs and keeps Channels and Tools out of the nav (%s)", (enabled) => {
const client = new QueryClient();
client.setQueryData(queryKeys.instance.experimentalSettings, { enableChatConnectors: enabled });
const markup = renderToStaticMarkup(<QueryClientProvider client={client}><MemoryRouter>
@@ -44,11 +44,11 @@ describe("AgentContextualSidebar", () => {
</MemoryRouter></QueryClientProvider>);
expect(markup).toContain("Insights");
expect(markup).toContain("plugin%3Atest.plugin%3Ainsights");
expect(markup.includes('href="/agents/agent/channels"')).toBe(true);
expect(markup).toContain('href="/agents/agent/tools"');
expect(markup).not.toContain('href="/agents/agent/channels"');
expect(markup).not.toContain('href="/agents/agent/tools"');
client.clear();
});
it("renders local definition/runtime/governance links and scoped Audit links", () => {
it("renders the Agent and Audit sections with audit pages inside the agent", () => {
const queryClient = new QueryClient();
const markup = renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
@@ -65,9 +65,12 @@ describe("AgentContextualSidebar", () => {
expect(markup).toContain('href="/agents/codexcoder/overview"');
expect(markup).toContain('href="/agents/codexcoder/advanced"');
expect(markup).toContain('href="/agents/codexcoder/permissions"');
expect(markup).toContain('href="/agents/codexcoder/api-keys"');
expect(markup).toContain('href="/activity?mode=agents&amp;agentId=agent-1"');
expect(markup).toContain('href="/activity/runs?agentId=agent-1"');
expect(markup).toContain('href="/agents/codexcoder/identity"');
expect(markup).not.toContain('href="/agents/codexcoder/api-keys"');
expect(markup).toContain('href="/agents/codexcoder/activity"');
expect(markup).toContain('href="/agents/codexcoder/runs"');
expect(markup).toContain('href="/agents/codexcoder/budgets"');
expect(markup).toContain("Secrets &amp; variables");
expect(markup).toContain("Harness / Runtime");
expect(markup).toContain("Permissions / Trust");
});
+32 -64
View File
@@ -1,9 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import {
Activity,
BadgeDollarSign,
BookOpenText,
FileCode2,
Fingerprint,
Gauge,
History,
KeyRound,
Library,
@@ -25,36 +25,32 @@ import { contextualSidebarStyles } from "./contextual-sidebar-styles";
import {
AGENT_DETAIL_NAVIGATION,
agentDetailHref,
agentScopedAuditHref,
type AgentLocalDetailView,
} from "@/pages/agent-detail-navigation";
const localIcons = {
overview: Sparkles,
instructions: BookOpenText,
instructions: Fingerprint,
skills: Library,
runtime: Settings2,
secrets: ShieldCheck,
secrets: KeyRound,
tools: Wrench,
channels: MessageSquare,
permissions: ShieldCheck,
"api-keys": KeyRound,
revisions: History,
advanced: FileCode2,
activity: Activity,
runs: PlayCircle,
costs: ReceiptText,
budgets: Gauge,
} satisfies Record<AgentLocalDetailView, typeof Sparkles>;
const auditItems = [
{ section: "activity", label: "Activity", icon: Activity },
{ section: "runs", label: "Runs", icon: PlayCircle },
{ section: "costs", label: "Costs", icon: ReceiptText },
{ section: "budgets", label: "Budgets", icon: BadgeDollarSign },
] as const;
export function AgentContextualSidebar({
agentRef,
agentId,
agentName,
labels = { secrets: "Secrets & variables" },
labels,
}: {
agentRef: string;
agentId?: string;
@@ -86,60 +82,32 @@ export function AgentContextualSidebar({
className={contextualSidebarStyles.nav}
>
{AGENT_DETAIL_NAVIGATION.map((section) => (
<div
key={section.label}
data-slot="contextual-sidebar-section"
className={contextualSidebarStyles.section}
>
<p
data-slot="contextual-sidebar-section-label"
className={contextualSidebarStyles.sectionLabel}
>
{section.label}
</p>
<div data-slot="contextual-sidebar-group" className={contextualSidebarStyles.group}>
{section.items
.map((item) => {
const href = agentDetailHref(agentRef, item.value);
return (
<SidebarNavItem
key={item.value}
to={href}
label={labels?.[item.value] ?? item.label}
icon={localIcons[item.value]}
/>
);
})}
<div key={section.label} className="contents">
{section.label === "Audit" && pluginTabs.length > 0 ? (
<div data-slot="contextual-sidebar-section" className={contextualSidebarStyles.section}>
<p className={contextualSidebarStyles.sectionLabel}>Apps</p>
<div className={contextualSidebarStyles.group}>
{pluginTabs.map(slot => <SidebarNavItem key={`${slot.pluginKey}:${slot.id}`} to={`/agents/${encodeURIComponent(agentRef)}/${encodeURIComponent(`plugin:${slot.pluginKey}:${slot.id}`)}`} label={slot.displayName} icon={Wrench} />)}
</div>
</div>
) : null}
<div data-slot="contextual-sidebar-section" className={contextualSidebarStyles.section}>
<p data-slot="contextual-sidebar-section-label" className={contextualSidebarStyles.sectionLabel}>
{section.label}
</p>
<div data-slot="contextual-sidebar-group" className={contextualSidebarStyles.group}>
{section.items.map((item) => (
<SidebarNavItem
key={item.value}
to={agentDetailHref(agentRef, item.value)}
label={labels?.[item.value] ?? item.label}
icon={localIcons[item.value]}
/>
))}
</div>
</div>
</div>
))}
{pluginTabs.length > 0 && <div data-slot="contextual-sidebar-section" className={contextualSidebarStyles.section}>
<p className={contextualSidebarStyles.sectionLabel}>Apps</p>
<div className={contextualSidebarStyles.group}>
{pluginTabs.map(slot => <SidebarNavItem key={`${slot.pluginKey}:${slot.id}`} to={`/agents/${encodeURIComponent(agentRef)}/${encodeURIComponent(`plugin:${slot.pluginKey}:${slot.id}`)}`} label={slot.displayName} icon={Wrench} />)}
</div>
</div>}
<div data-slot="contextual-sidebar-section" className={contextualSidebarStyles.section}>
<p
data-slot="contextual-sidebar-section-label"
className={contextualSidebarStyles.sectionLabel}
>
Audit
</p>
<div data-slot="contextual-sidebar-group" className={contextualSidebarStyles.group}>
{resolvedId ? auditItems.map((item) => (
<SidebarNavItem
key={item.section}
to={agentScopedAuditHref(resolvedId, item.section)}
label={item.label}
icon={item.icon}
/>
)) : (
<p className="px-2 py-1.5 text-xs text-muted-foreground">Loading audit links…</p>
)}
</div>
</div>
</nav>
</ContextualSidebarFrame>
);
+5 -2
View File
@@ -54,6 +54,7 @@ export function RuntimeTestCard({
disabled = false,
variant = "connection",
metadataOnly = false,
tone = "card",
}: {
state: TestState | "warn";
result: AdapterEnvironmentTestResult | null;
@@ -62,6 +63,7 @@ export function RuntimeTestCard({
disabled?: boolean;
variant?: "connection" | "prerequisites";
metadataOnly?: boolean;
tone?: "card" | "tile";
}) {
const missingRuntime = state === "fail"
? result?.checks.find(check => check.code === "acpx_runtime_package_missing")
@@ -85,9 +87,10 @@ export function RuntimeTestCard({
return (
<section
aria-label="Runtime test"
className="rounded-lg border border-border bg-card"
data-agent-tile={tone === "tile" ? "" : undefined}
className={tone === "tile" ? "rounded-lg bg-(--agent-detail-tile)" : "rounded-lg border border-border bg-card"}
>
<div className="flex items-start gap-3 p-4 sm:items-center">
<div className={cn("flex items-start gap-3 sm:items-center", tone === "tile" ? "p-(--agent-detail-module-inset)" : "p-4")}>
<span
aria-hidden="true"
className={cn(
+70 -20
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import type { AgentPermissions, TrustPreset } from "@paperclipai/shared";
import { Lock, ShieldAlert } from "lucide-react";
import { Check, Lock, ShieldAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Field, CollapsibleSection } from "./agent-config-primitives";
import {
@@ -60,6 +60,8 @@ export function TrustPresetSection({
candidatesLoading,
allowSingleIssue = true,
projectAccessEditor,
layout = "select",
aside,
}: {
permissions: Partial<AgentPermissions> | null | undefined;
onChange: (permissions: Partial<AgentPermissions>) => void;
@@ -70,6 +72,9 @@ export function TrustPresetSection({
candidatesLoading?: boolean;
allowSingleIssue?: boolean;
projectAccessEditor?: ReactNode;
/** "cards" shows the presets as selectable cards beside `aside` (the agent page). */
layout?: "select" | "cards";
aside?: ReactNode;
}) {
const [policyOpen, setPolicyOpen] = useState(false);
const preset = getTrustPreset(permissions);
@@ -106,24 +111,7 @@ export function TrustPresetSection({
const targetCandidates = targetType === "project" ? projectCandidates : issueCandidates;
const boundaryValue = boundaryTarget?.type === targetType ? boundaryTarget.id : "";
return (
<div>
<h3 className="mb-3 text-sm font-medium">Trust</h3>
<div className="rounded-lg border border-border p-4 space-y-3">
<Field label="Trust preset" hint="Choose how broadly this agent can read and act on Paperclip work objects.">
<select
className={inputClass}
value={preset}
onChange={(event) => handlePresetChange(event.target.value)}
disabled={disabled}
>
<option value="standard">{TRUST_PRESET_LABELS.standard}</option>
<option value="low_trust_review">{TRUST_PRESET_LABELS.low_trust_review}</option>
</select>
</Field>
<p className="text-xs text-muted-foreground">{TRUST_PRESET_DESCRIPTIONS[preset]}</p>
{lowTrust ? (
const containment = lowTrust ? (
<div
role={hasScope ? "status" : "alert"}
aria-live="polite"
@@ -249,7 +237,69 @@ export function TrustPresetSection({
</CollapsibleSection>
</div>
</div>
) : null}
) : null;
if (layout === "cards") {
return (
<div className="flex flex-col gap-(--agent-detail-module-gap)">
<div className="grid gap-(--agent-detail-module-gap) sm:grid-cols-3">
<div role="radiogroup" aria-label="Trust preset" className="contents">
{(["low_trust_review", "standard"] as const).map((value) => {
const selected = preset === value;
return (
<button
key={value}
type="button"
role="radio"
aria-checked={selected}
data-agent-tile=""
disabled={disabled}
onClick={() => (selected ? undefined : handlePresetChange(value))}
className={cn(
"flex min-h-(--agent-detail-tile-min-h) flex-col gap-2 rounded-lg border bg-(--agent-detail-tile) p-(--agent-detail-module-inset) text-left transition-colors outline-none hover:bg-(--agent-detail-tile-hover) focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60",
selected ? "border-foreground" : "border-transparent",
)}
>
<span className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold text-foreground">{TRUST_PRESET_LABELS[value]}</span>
{selected ? <Check className="size-4 text-foreground" aria-hidden /> : null}
</span>
<span className="text-sm leading-5 text-muted-foreground">{TRUST_PRESET_DESCRIPTIONS[value]}</span>
</button>
);
})}
</div>
{aside}
</div>
{containment}
{lowTrust ? projectAccessEditor : null}
{managedPermissions.authorizationPolicy?.reviewPreset ? null : (
<p className="text-xs text-muted-foreground">
Advanced permissions remain editable through the EE permissions extension when installed.
</p>
)}
</div>
);
}
return (
<div>
<h3 className="mb-3 text-sm font-medium">Trust</h3>
<div className="rounded-lg border border-border p-4 space-y-3">
<Field label="Trust preset" hint="Choose how broadly this agent can read and act on Paperclip work objects.">
<select
className={inputClass}
value={preset}
onChange={(event) => handlePresetChange(event.target.value)}
disabled={disabled}
>
<option value="standard">{TRUST_PRESET_LABELS.standard}</option>
<option value="low_trust_review">{TRUST_PRESET_LABELS.low_trust_review}</option>
</select>
</Field>
<p className="text-xs text-muted-foreground">{TRUST_PRESET_DESCRIPTIONS[preset]}</p>
{containment}
{lowTrust && projectAccessEditor}
@@ -0,0 +1,104 @@
import type { HTMLAttributes, ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { Link } from "@/lib/router";
import { cn } from "@/lib/utils";
export const agentTableHeadClass =
"grid h-11 items-center gap-3 border-b border-border bg-(--agent-detail-table-head) px-4 text-sm text-muted-foreground";
export const agentTableRowClass = "grid min-h-11 items-center gap-3 border-b border-border px-4 py-2 text-sm last:border-b-0";
export function AgentPanel({
title,
meta,
action,
className,
headerClassName,
children,
...rest
}: {
title?: ReactNode;
meta?: ReactNode;
action?: ReactNode;
className?: string;
headerClassName?: string;
children: ReactNode;
} & Omit<HTMLAttributes<HTMLElement>, "title">) {
return (
<section data-agent-tile="" className={cn("flex min-w-0 flex-col rounded-lg bg-(--agent-detail-tile)", className)} {...rest}>
{title ? (
<header className={cn("flex items-center justify-between gap-3 px-(--agent-detail-module-inset) pt-(--agent-detail-module-inset) pb-3", headerClassName)}>
<h3 className="flex min-w-0 items-center gap-2 text-sm font-semibold text-foreground">{title}</h3>
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{meta}
{action}
</div>
</header>
) : null}
{children}
</section>
);
}
export function AgentSectionHeading({ title, action }: { title: ReactNode; action?: ReactNode }) {
return (
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{action}
</div>
);
}
export function AgentTextLink({ children, onClick }: { children: ReactNode; onClick?: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="rounded-sm text-xs text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{children}
</button>
);
}
export function AgentMetricTile({
icon: Icon,
value,
label,
description,
to,
}: {
icon: LucideIcon;
value: ReactNode;
label: string;
description: ReactNode;
to: string;
}) {
return (
<Link
to={to}
className="flex min-h-(--agent-detail-tile-min-h) min-w-0 flex-col rounded-lg bg-(--agent-detail-tile) p-(--agent-detail-module-inset) text-left no-underline transition-colors outline-none hover:bg-(--agent-detail-tile-hover) focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="flex items-center justify-between gap-3">
<span className="text-3xl font-semibold tracking-tight tabular-nums text-foreground">{value}</span>
<Icon className="size-5 shrink-0 text-muted-foreground" aria-hidden />
</span>
<span className="mt-1 text-sm text-muted-foreground">{label}</span>
<span className="mt-1.5 text-xs text-muted-foreground/70">{description}</span>
</Link>
);
}
export function AgentChip({ tone = "neutral", mono, children }: { tone?: "neutral" | "danger"; mono?: boolean; children: ReactNode }) {
return (
<span
className={cn(
"inline-flex h-5 items-center gap-1 rounded-md px-1.5 text-xs leading-4",
mono && "font-mono",
tone === "danger" ? "bg-destructive/10 text-destructive" : "bg-(--agent-detail-tile-chip) text-muted-foreground",
)}
>
{children}
</span>
);
}
@@ -0,0 +1,135 @@
// @vitest-environment jsdom
import { act, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { Agent } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { PrimaryAgentCrownButton, PrimaryAgentPresentationProvider } from "./PrimaryAgentPresentation";
const agent = (id: string, name: string) => ({ id, name, companyId: "company-a", status: "idle" }) as Agent;
const ceo = agent("ceo", "CEO");
const cto = agent("cto", "CTO");
let root: Root;
let container: HTMLDivElement;
function Harness({ initial, onChange, error, onRetry }: {
initial: Agent | null;
onChange: (agentId: string) => void;
error?: string;
onRetry?: () => void;
}) {
const [primary, setPrimary] = useState<Agent | null>(initial);
return (
<TooltipProvider>
<PrimaryAgentPresentationProvider value={{
companyId: "company-a",
primaryAgentId: primary?.id ?? null,
primaryAgent: primary,
error,
onRetry,
onChange: (agentId) => {
onChange(agentId);
setPrimary([ceo, cto].find((candidate) => candidate.id === agentId) ?? null);
},
}}>
<PrimaryAgentCrownButton agent={ceo} />
</PrimaryAgentPresentationProvider>
</TooltipProvider>
);
}
async function render(props: Parameters<typeof Harness>[0]) {
await act(async () => root.render(<Harness {...props} />));
}
const crown = () => container.querySelector<HTMLElement>(".primary-crown");
const nextTick = () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
const dialog = () => document.body.querySelector<HTMLElement>("[role=alertdialog]");
beforeEach(() => {
vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} });
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
describe("PrimaryAgentCrownButton", () => {
it("fills the crown at once when there is no primary agent yet", async () => {
const onChange = vi.fn();
await render({ initial: null, onChange });
expect(crown()?.getAttribute("aria-label")).toBe("Set as my primary");
await act(async () => crown()?.click());
expect(onChange).toHaveBeenCalledWith("ceo");
expect(dialog()).toBeNull();
expect(crown()?.getAttribute("role")).toBe("img");
expect(crown()?.getAttribute("aria-label")).toBe("My primary agent");
expect(crown()?.dataset.pop).toBe("now");
const bubbles = Array.from(crown()?.querySelectorAll<HTMLElement>(".primary-crown-bubble") ?? []);
expect(bubbles).toHaveLength(3);
for (const bubble of bubbles) {
expect(Number(bubble.style.getPropertyValue("--bubble-size"))).toBeGreaterThan(0);
expect(Number(bubble.style.getPropertyValue("--bubble-travel"))).toBeGreaterThan(0);
}
});
it("asks before replacing the current primary agent, then fills after the dialog", async () => {
const onChange = vi.fn();
await render({ initial: cto, onChange });
await act(async () => crown()?.click());
expect(dialog()?.textContent).toContain("Change your primary agent?");
expect(onChange).not.toHaveBeenCalled();
const confirm = Array.from(dialog()?.querySelectorAll("button") ?? []).find((button) => button.textContent === "Set as my primary");
await act(async () => confirm?.click());
expect(onChange).toHaveBeenCalledWith("ceo");
expect(crown()?.getAttribute("aria-label")).toBe("My primary agent");
expect(crown()?.dataset.pop).toBe("after-dialog");
await nextTick();
expect(document.activeElement).toBe(crown());
expect(document.body.querySelector("[data-slot=tooltip-content]")).toBeNull();
});
it("keeps the current primary agent when the dialog is cancelled", async () => {
const onChange = vi.fn();
await render({ initial: cto, onChange });
await act(async () => crown()?.click());
const cancel = Array.from(dialog()?.querySelectorAll("button") ?? []).find((button) => button.textContent === "Cancel");
await act(async () => cancel?.click());
expect(onChange).not.toHaveBeenCalled();
expect(crown()?.getAttribute("aria-label")).toBe("Set as my primary");
await nextTick();
expect(document.activeElement).toBe(crown());
expect(document.body.querySelector("[data-slot=tooltip-content]")).toBeNull();
});
it("shows a still, filled crown on the primary agent", async () => {
await render({ initial: ceo, onChange: vi.fn() });
expect(crown()?.getAttribute("role")).toBe("img");
expect(crown()?.dataset.pop).toBeUndefined();
expect(crown()?.querySelector(".primary-crown-bubble")).toBeNull();
expect(container.querySelector("button")).toBeNull();
});
it("retries loading when the primary agent could not be loaded", async () => {
const onRetry = vi.fn();
await render({ initial: null, onChange: vi.fn(), error: "offline", onRetry });
await act(async () => crown()?.click());
expect(crown()?.getAttribute("aria-label")).toBe("Retry loading primary agent");
expect(onRetry).toHaveBeenCalledTimes(1);
});
});
@@ -1,10 +1,11 @@
import { createContext, useContext } from "react";
import { ArrowRight, Crown, Loader2 } from "lucide-react";
import { createContext, useContext, useRef, useState, type CSSProperties } from "react";
import { ArrowRight, Crown } from "lucide-react";
import type { Agent } from "@paperclipai/shared";
import { AgentAvatar } from "@/components/AgentAvatar";
import { Button } from "@/components/ui/button";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
/** Shared presentation for the application and the approved review stories. */
export interface PrimaryAgentPresentation {
@@ -39,56 +40,127 @@ export function PrimaryAgentIndicator({ agentId, companyId }: { agentId: string;
</Tooltip>;
}
export function SetPrimaryAgentButton({ agent }: {
const crownClass = "primary-crown relative";
type CrownPop = { timing: "now" | "after-dialog"; bubbles: { angle: number; size: number; travel: number }[] };
function crownBubbles(): CrownPop["bubbles"] {
const start = Math.random() * 360;
return [0, 1, 2].map((index) => ({
angle: Math.round(start + index * 120 + (Math.random() - 0.5) * 60),
size: Number((0.45 + Math.random() * 0.55).toFixed(2)),
travel: Number((0.8 + Math.random() * 0.4).toFixed(2)),
}));
}
export function PrimaryAgentCrownButton({ agent }: {
agent: Agent;
}) {
const primary = usePrimaryAgentPresentation(agent.companyId);
const [confirming, setConfirming] = useState(false);
const [replacing, setReplacing] = useState<Agent | null>(null);
const [pop, setPop] = useState<CrownPop | null>(null);
const filledRef = useRef<HTMLSpanElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const [tooltipOpen, setTooltipOpen] = useState(false);
const quietTooltip = useRef(false);
if (!primary || agent.status === "terminated" || agent.status === "pending_approval") return null;
if (primary.error) return <Button type="button" variant="link" size="sm" onClick={primary.onRetry}
className="h-auto justify-start p-0 text-xs text-destructive">Retry loading primary agent</Button>;
const selected = primary.primaryAgentId === agent.id;
const pending = primary.pendingAgentId === agent.id;
if (selected && !pending && !primary.loading) return null;
const previous = primary.primaryAgent?.id === primary.primaryAgentId ? primary.primaryAgent : null;
const loading = primary.loading || Boolean(primary.primaryAgentId && !previous);
const disabled = loading || Boolean(primary.pendingAgentId);
const button = <Button type="button" variant="link" size="sm" disabled={disabled}
onClick={primary.primaryAgentId ? undefined : () => primary.onChange(agent.id)}
className="h-auto justify-start p-0 text-xs text-muted-foreground hover:text-foreground">
{pending || loading ? <Loader2 aria-hidden="true" className="size-3 motion-safe:animate-spin" /> : null}
{loading ? "Loading primary…" : pending ? "Setting primary…" : "Set as my primary"}
</Button>;
if (!previous) return button;
return <AlertDialog key={previous.id}>
<AlertDialogTrigger asChild>{button}</AlertDialogTrigger>
<AlertDialogContent>
<div className="flex items-start justify-center gap-5 pt-2">
<div className="flex min-w-0 flex-1 flex-col items-center gap-2 text-center">
<AgentAvatar agent={previous} size={64} label={`${previous.name} avatar`} />
<span className="max-w-full break-words text-sm font-medium">{previous.name}</span>
</div>
<ArrowRight aria-hidden="true" className="mt-5 size-6 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col items-center gap-2 text-center">
<AgentAvatar agent={agent} size={64} label={`${agent.name} avatar`}
className="rounded-xl ring-1 ring-primary ring-offset-2 ring-offset-background" />
<span className="flex max-w-full items-center justify-center gap-1.5 text-sm font-medium">
<span className="min-w-0 break-words">{agent.name}</span>
<span role="img" aria-label="New primary agent" className="inline-flex shrink-0 text-primary">
<Crown aria-hidden="true" className="size-3.5" />
const busy = loading || Boolean(primary.pendingAgentId);
const choose = (timing: CrownPop["timing"]) => {
setPop({ timing, bubbles: crownBubbles() });
primary.onChange(agent.id);
};
const request = () => {
if (!previous) return choose("now");
setReplacing(previous);
setConfirming(true);
};
const tooltip = {
open: tooltipOpen,
onOpenChange: (open: boolean) => setTooltipOpen(open && !quietTooltip.current),
};
const wakeTooltip = () => { quietTooltip.current = false; };
const control = primary.error ? (
<Tooltip {...tooltip}>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label="Retry loading primary agent" onClick={primary.onRetry}
onPointerMove={wakeTooltip} onBlur={wakeTooltip}
className={cn(crownClass, "text-destructive")}>
<Crown aria-hidden="true" className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent>Couldn't load your primary agent. Select to retry.</TooltipContent>
</Tooltip>
) : selected && !loading ? (
<Tooltip {...tooltip}>
<TooltipTrigger asChild>
<span ref={filledRef} role="img" aria-label="My primary agent" tabIndex={-1} data-pop={pop?.timing}
onPointerMove={wakeTooltip} onBlur={wakeTooltip}
onAnimationEnd={(event) => { if (event.animationName === "primary-crown-pop") setPop(null); }}
className={cn(crownClass, "inline-flex size-9 shrink-0 items-center justify-center rounded-md text-primary outline-none focus-visible:ring-ring/50 focus-visible:ring-(length:--rad-3)")}>
<Crown aria-hidden="true" className="size-5 fill-current" />
{pop?.bubbles.map((bubble, index) => (
<span key={index} aria-hidden="true" className="primary-crown-bubble"
style={{ "--bubble-angle": bubble.angle, "--bubble-size": bubble.size, "--bubble-travel": bubble.travel } as CSSProperties} />
))}
</span>
</TooltipTrigger>
<TooltipContent>My primary agent</TooltipContent>
</Tooltip>
) : (
<Tooltip {...tooltip}>
<TooltipTrigger asChild>
<Button ref={buttonRef} type="button" variant="ghost" size="icon-sm" aria-label="Set as my primary" disabled={busy} onClick={request}
onPointerMove={wakeTooltip} onBlur={wakeTooltip}
className={cn(crownClass, "text-muted-foreground/50 hover:text-foreground")}>
<Crown aria-hidden="true" className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent>{loading ? "Loading your primary agent…" : "Set as my primary"}</TooltipContent>
</Tooltip>
);
return <>
{control}
{replacing ? <AlertDialog open={confirming} onOpenChange={setConfirming}>
<AlertDialogContent onCloseAutoFocus={(event) => {
event.preventDefault();
quietTooltip.current = true;
setTooltipOpen(false);
(filledRef.current ?? buttonRef.current)?.focus();
}}>
<div className="flex items-start justify-center gap-5 pt-2">
<div className="flex min-w-0 flex-1 flex-col items-center gap-2 text-center">
<AgentAvatar agent={replacing} size={64} label={`${replacing.name} avatar`} />
<span className="max-w-full break-words text-sm font-medium">{replacing.name}</span>
</div>
<ArrowRight aria-hidden="true" className="mt-5 size-6 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col items-center gap-2 text-center">
<AgentAvatar agent={agent} size={64} label={`${agent.name} avatar`}
className="rounded-xl ring-1 ring-primary ring-offset-2 ring-offset-background" />
<span className="flex max-w-full items-center justify-center gap-1.5 text-sm font-medium">
<span className="min-w-0 break-words">{agent.name}</span>
<span role="img" aria-label="New primary agent" className="inline-flex shrink-0 text-primary">
<Crown aria-hidden="true" className="size-3.5" />
</span>
</span>
</span>
</div>
</div>
</div>
<AlertDialogHeader>
<AlertDialogTitle>Change your primary agent?</AlertDialogTitle>
<AlertDialogDescription className="break-words">
This will make {agent.name} your primary agent, do you want to do this?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-row justify-between sm:justify-between">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction disabled={disabled} onClick={() => primary.onChange(agent.id)}>Set as my primary</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>;
<AlertDialogHeader>
<AlertDialogTitle>Change your primary agent?</AlertDialogTitle>
<AlertDialogDescription className="break-words">
This will make {agent.name} your primary agent, do you want to do this?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-row justify-between sm:justify-between">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction disabled={busy} onClick={() => choose("after-dialog")}>Set as my primary</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog> : null}
</>;
}
@@ -32,6 +32,7 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
{ name: "--motion-ease-in", group: "Easing", kind: "easing" },
{ name: "--motion-ease-pane-width-reset", group: "Easing", kind: "easing" },
{ name: "--motion-ease-pane-reveal", group: "Easing", kind: "easing" },
{ name: "--motion-ease-primary-crown-burst", group: "Easing", kind: "easing" },
// Duration primitives
{ name: "--motion-duration-instant", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
@@ -80,6 +81,8 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
{ name: "--motion-pane-reveal", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-pane-width-reset", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-side-panel-tab", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-primary-crown-pop", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-primary-crown-pop-delay", group: "States", kind: "time", min: 0, max: 1000, step: 10 },
];
/** Common easing presets offered by the tweak panel's easing picker. */
+141 -7
View File
@@ -591,6 +591,33 @@
--tc-composer-visible-nav-offset: calc(4rem + var(--spacing) * 1.5 + var(--sz-safe-bottom));
/* With the dock's pb-1, leave 12px below the card when the nav hides. */
--tc-composer-hidden-nav-offset: calc(var(--sz-safe-bottom) + var(--spacing) * 2);
/* Agent detail page (Refine-v2). Surfaces mix the theme's own foreground
into its background, so the same tokens read in light and dark. */
--agent-detail-module-gap: 12px;
--agent-detail-band-gap: 48px;
--agent-detail-module-inset: 20px;
--agent-detail-portrait-zoom: 1.21875;
--agent-detail-tile-min-h: 122px;
--agent-detail-control-h: 40px;
--agent-detail-logo-rows-h: 60px;
--agent-detail-dialog-max-h: 85vh;
--agent-detail-history-cols: minmax(0, 1.5fr) minmax(0, 1fr) 140px 104px;
--agent-detail-tile: color-mix(in oklab, var(--foreground) 4%, var(--background));
--agent-detail-tile-hover: color-mix(in oklab, var(--foreground) 7%, var(--background));
--agent-detail-tile-chip: color-mix(in oklab, var(--foreground) 8%, transparent);
--agent-detail-tile-input: color-mix(in oklab, var(--foreground) 5%, var(--agent-detail-tile));
--agent-detail-table-head: color-mix(in oklab, var(--foreground) 3%, transparent);
--agent-detail-control-border: color-mix(in oklab, var(--foreground) 20%, transparent);
--agent-detail-control-bg: color-mix(in oklab, var(--foreground) 5%, var(--background));
--agent-detail-switch-off: color-mix(in oklab, var(--foreground) 15%, transparent);
--agent-detail-dot-unsaved: var(--status-task-icon-todo);
--agent-detail-dot-saved: var(--status-task-icon-done);
--motion-primary-crown-pop: 598ms; /* --motion-duration-deliberate slowed by 15% */
--motion-primary-crown-pop-delay: var(--motion-duration-fast);
--primary-crown-bubble-size: 7px;
--primary-crown-bubble-travel: 30px;
--motion-ease-primary-crown-burst: cubic-bezier(0.2, 0.8, 0.3, 1);
}
.dark {
@@ -3303,18 +3330,18 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
.agent-yaml-editor .cm-editor {
height: var(--sz-480px);
font-family: var(--font-mono);
font-size: var(--text-sm);
background: var(--background);
font-size: var(--text-xs);
background: var(--agent-detail-tile);
color: var(--foreground);
}
.agent-yaml-editor .cm-scroller { overflow: auto; }
.agent-yaml-editor .cm-gutters {
background: var(--muted);
background: transparent;
color: var(--muted-foreground);
border-color: var(--border);
}
.agent-yaml-editor .cm-activeLine,
.agent-yaml-editor .cm-activeLineGutter { background: var(--accent); }
.agent-yaml-editor .cm-activeLineGutter { background: var(--agent-detail-tile-chip); }
.agent-yaml-editor .cm-cursor { border-left-color: var(--foreground); }
.agent-yaml-editor .cm-selectionBackground,
.agent-yaml-editor .cm-focused .cm-selectionBackground { background: var(--accent); }
@@ -3326,7 +3353,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
.agent-settings-form [data-config-section] {
@apply my-0;
}
.agent-settings-form [data-config-section] > .rounded-lg {
.agent-settings-form [data-config-section] > .rounded-lg:not([data-agent-tile]) {
@apply bg-card p-5;
}
.agent-settings-form [data-config-section] h3 {
@@ -3344,8 +3371,8 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
> :nth-child(n + 3) {
@apply sm:col-span-2;
}
.agent-settings-overview section,
.agent-settings-permissions .rounded-lg,
.agent-settings-overview section:not([data-agent-tile]),
.agent-settings-permissions .rounded-lg:not([data-agent-tile]),
.agent-settings-api-keys .rounded-lg,
.agent-settings-revisions .rounded-md {
@apply bg-card;
@@ -5087,3 +5114,110 @@ html:not(.dark) .coordinator-sidebar {
.coordinator-task-commitment > .coordinator-task-light { display: none; }
.coordinator-created-task, .coordinator-decision-receipt { transition: none; }
}
/* Agent detail page (Refine-v2): inside a filled panel, controls step up from
the panel fill and an off switch keeps a visible track. */
[data-agent-tile] {
--agent-detail-control-bg: var(--agent-detail-tile-input);
}
[data-agent-tile] [data-slot="toggle"][aria-checked="false"] {
background: var(--agent-detail-switch-off);
}
/* Agent detail header: the primary-agent crown fills with a short pop while three
bubbles burst out of its centre. After the replace-primary dialog it waits for
the dialog to close first. Each bubble gets its own angle, size and travel. */
.primary-crown[data-pop] svg {
transform-origin: center;
animation: primary-crown-pop var(--motion-primary-crown-pop) var(--motion-ease-standard) both;
}
.primary-crown-bubble {
position: absolute;
top: 50%;
left: 50%;
width: calc(var(--primary-crown-bubble-size) * var(--bubble-size));
aspect-ratio: 1;
pointer-events: none;
translate: -50% -50%;
rotate: calc(var(--bubble-angle) * 1deg);
animation: primary-crown-bubble-travel var(--motion-primary-crown-pop) var(--motion-ease-primary-crown-burst) both;
}
.primary-crown-bubble::before {
content: "";
position: absolute;
inset: 0;
border-radius: 9999px;
background: currentColor;
animation: primary-crown-bubble-scale var(--motion-primary-crown-pop) both;
}
.primary-crown[data-pop="after-dialog"] svg,
.primary-crown[data-pop="after-dialog"] .primary-crown-bubble,
.primary-crown[data-pop="after-dialog"] .primary-crown-bubble::before {
animation-delay: var(--motion-primary-crown-pop-delay);
}
@keyframes primary-crown-pop {
0% { transform: scale(1); fill-opacity: 0; }
40% { transform: scale(1.3); fill-opacity: 1; }
70% { transform: scale(0.92); }
100% { transform: scale(1); fill-opacity: 1; }
}
@keyframes primary-crown-bubble-travel {
from { transform: translateX(0); }
to { transform: translateX(calc(var(--primary-crown-bubble-travel) * var(--bubble-travel))); }
}
@keyframes primary-crown-bubble-scale {
0% { transform: scale(0); animation-timing-function: var(--motion-ease-out-expo); }
25% { transform: scale(1); animation-timing-function: var(--motion-ease-standard); }
100% { transform: scale(0); }
}
@media (prefers-reduced-motion: reduce) {
.primary-crown[data-pop] svg { animation: none; }
.primary-crown-bubble { display: none; }
}
/* Agent detail Harness / Secrets: AgentConfigForm's "panels" layout. */
.agent-config-panels {
display: flex;
flex-direction: column;
gap: var(--agent-detail-module-gap);
}
.agent-config-panels > [data-config-section] {
display: flex;
flex-direction: column;
}
.agent-config-panels > [data-config-section] > [data-agent-tile] {
flex: 1;
}
.agent-config-panels > section[aria-label="Runtime test"] {
order: 1;
}
.agent-config-panels > [data-config-section="environment-variables"] {
order: 2;
margin-top: calc(var(--agent-detail-band-gap) - var(--agent-detail-module-gap));
}
.agent-config-panels > :not([data-config-section]):not(section[aria-label="Runtime test"]) {
order: 3;
}
@media (min-width: 768px) {
.agent-config-panels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.agent-config-panels > * {
grid-column: 1 / -1;
}
.agent-config-panels > [data-config-section="adapter"] {
grid-column: 1;
}
.agent-config-panels:has(> [data-config-section="environment"]) > [data-config-section="adapter"] {
grid-row: span 2;
}
.agent-config-panels > [data-config-section="configuration"],
.agent-config-panels > [data-config-section="environment"] {
grid-column: 2;
}
}
.agent-config-panels h3,
.agent-config-panels + h3 {
font-weight: 600;
}
+5 -2
View File
@@ -145,7 +145,10 @@ function ConfigurationEditor({ agent, initial, onDirtyChange, onSaved, onSavingC
<footer className="sticky bottom-0 z-10 flex items-center justify-between gap-3 border-t border-border bg-background py-4">
<Button variant="ghost" disabled={busy} onClick={() => { if (!operationPending.current) { operationPending.current = true; reload.mutate(); } }}>Reload saved</Button>
<div className="flex items-center gap-3">
<span role="status" className="text-xs text-muted-foreground">{save.isPending ? `Saving: ${progress || "configuration"}…` : reload.isPending ? "Loading configuration…" : dirty ? validation.document && !validation.changes.length ? "No configuration changes" : "Unsaved changes" : ""}</span>
<span role="status" className="flex items-center gap-2 text-xs text-muted-foreground">
{!save.isPending && !reload.isPending && dirty ? <span aria-hidden className="size-1.5 shrink-0 rounded-full bg-(--agent-detail-dot-unsaved)" /> : null}
{save.isPending ? `Saving: ${progress || "configuration"}…` : reload.isPending ? "Loading configuration…" : dirty ? validation.document && !validation.changes.length ? "No configuration changes" : "Unsaved changes" : "No unsaved changes"}
</span>
<Button disabled={!dirty || busy || reloadRequired || !validation.document || !validation.changes.length} onClick={() => { if (!operationPending.current) { operationPending.current = true; save.mutate(); } }}>{save.isPending ? "Saving…" : "Save changes"}</Button>
</div>
</footer>
@@ -184,5 +187,5 @@ function AgentYamlEditor({ value, onChange, readOnly }: { value: string; onChang
const view = editor.current;
if (view && view.state.doc.toString() !== value) view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
}, [value]);
return <div ref={container} className="agent-yaml-editor overflow-hidden rounded-md border border-border" />;
return <div ref={container} className="agent-yaml-editor overflow-hidden rounded-lg" />;
}
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import type { BudgetOverview, CostByAgent, CostByAgentModel } from "@paperclipai/shared";
import { agentBudgetSummary } from "./AgentBudgetsPanel";
import { agentCostRow, agentModelCostRows } from "./AgentCostsPanel";
const agent = {
id: "agent-1",
name: "Atlas",
status: "active",
pauseReason: null,
budgetMonthlyCents: 10_000,
spentMonthlyCents: 2_500,
} as const;
function costRow(agentId: string | null, costCents: number): CostByAgent {
return {
agentId,
agentName: agentId,
agentStatus: "active",
eventCount: 1,
estimatedEventCount: 0,
costCents,
inputTokens: 10,
cachedInputTokens: 0,
outputTokens: 5,
apiRunCount: 1,
subscriptionRunCount: 0,
subscriptionCachedInputTokens: 0,
subscriptionInputTokens: 0,
subscriptionOutputTokens: 0,
};
}
function modelRow(agentId: string, model: string, costCents: number): CostByAgentModel {
return {
agentId,
agentName: agentId,
provider: "anthropic",
biller: "anthropic",
billingType: "metered_api",
model,
eventCount: 1,
estimatedEventCount: 0,
costCents,
inputTokens: 1,
cachedInputTokens: 0,
outputTokens: 1,
};
}
describe("agent-scoped audit data", () => {
it("keeps only the agent's cost row and orders its models by spend", () => {
expect(agentCostRow([costRow("agent-2", 900), costRow("agent-1", 300), costRow(null, 50)], "agent-1")?.costCents).toBe(300);
expect(agentCostRow([costRow("agent-2", 900)], "agent-1")).toBeNull();
expect(
agentModelCostRows([modelRow("agent-1", "small", 20), modelRow("agent-2", "other", 90), modelRow("agent-1", "large", 80)], "agent-1").map((row) => row.model),
).toEqual(["large", "small"]);
});
it("uses the agent's own budget policy and falls back to its monthly budget", () => {
const policy = { ...agentBudgetSummary(undefined, agent, "company-1"), policyId: "policy-1", amount: 50_000 };
const overview = {
companyId: "company-1",
policies: [{ ...policy, scopeId: "agent-2", policyId: "policy-2" }, policy],
activeIncidents: [],
pausedAgentCount: 0,
pausedProjectCount: 0,
pendingApprovalCount: 0,
} satisfies BudgetOverview;
expect(agentBudgetSummary(overview, agent, "company-1").policyId).toBe("policy-1");
const fallback = agentBudgetSummary(undefined, agent, "company-1");
expect(fallback.scopeType).toBe("agent");
expect(fallback.amount).toBe(10_000);
expect(fallback.remainingAmount).toBe(7_500);
expect(fallback.utilizationPercent).toBe(25);
expect(fallback.status).toBe("ok");
});
});
+21
View File
@@ -0,0 +1,21 @@
import type { AgentDetail as AgentDetailRecord } from "@paperclipai/shared";
import { AuditFeed } from "./audit/AuditFeed";
import { AuditRuns } from "./audit/AuditRuns";
import { AgentBudgetsPanel } from "./AgentBudgetsPanel";
import { AgentCostsPanel } from "./AgentCostsPanel";
import type { AgentAuditView } from "./agent-detail-navigation";
export function AgentAuditTab({
agent,
companyId,
section,
}: {
agent: AgentDetailRecord;
companyId: string;
section: AgentAuditView;
}) {
if (section === "activity") return <AuditFeed companyId={companyId} lockedAgentId={agent.id} hideHeader />;
if (section === "runs") return <AuditRuns companyId={companyId} lockedAgentId={agent.id} hideHeader />;
if (section === "costs") return <AgentCostsPanel agentId={agent.id} agentName={agent.name} companyId={companyId} />;
return <AgentBudgetsPanel agent={agent} companyId={companyId} />;
}
+111
View File
@@ -0,0 +1,111 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AgentDetail as AgentDetailRecord, BudgetOverview, BudgetPolicySummary } from "@paperclipai/shared";
import { budgetsApi } from "../api/budgets";
import { BudgetIncidentCard } from "../components/BudgetIncidentCard";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { PageSkeleton } from "../components/PageSkeleton";
import { queryKeys } from "../lib/queryKeys";
type BudgetAgent = Pick<AgentDetailRecord, "id" | "name" | "status" | "pauseReason" | "budgetMonthlyCents" | "spentMonthlyCents">;
export function agentBudgetSummary(overview: BudgetOverview | undefined, agent: BudgetAgent, companyId: string): BudgetPolicySummary {
const matched = overview?.policies.find((policy) => policy.scopeType === "agent" && policy.scopeId === agent.id);
if (matched) return matched;
const amount = agent.budgetMonthlyCents ?? 0;
const observed = agent.spentMonthlyCents ?? 0;
return {
policyId: "",
companyId,
scopeType: "agent",
scopeId: agent.id,
scopeName: agent.name,
metric: "billed_cents",
windowKind: "calendar_month_utc",
amount,
unpricedEventCount: 0,
pendingRunCount: 0,
unpricedUsagePolicy: "block",
observedAmount: observed,
remainingAmount: Math.max(0, amount - observed),
utilizationPercent: amount > 0 ? Number(((observed / amount) * 100).toFixed(2)) : 0,
warnPercent: 80,
hardStopEnabled: true,
notifyEnabled: true,
isActive: amount > 0,
status: amount > 0 && observed >= amount ? "hard_stop" : "ok",
paused: agent.status === "paused",
pauseReason: agent.pauseReason ?? null,
windowStart: new Date(),
windowEnd: new Date(),
};
}
export function AgentBudgetsPanel({ agent, companyId }: { agent: BudgetAgent; companyId: string }) {
const queryClient = useQueryClient();
const overview = useQuery({
queryKey: queryKeys.budgets.overview(companyId),
queryFn: () => budgetsApi.overview(companyId),
refetchInterval: 30_000,
staleTime: 5_000,
});
const summary = agentBudgetSummary(overview.data, agent, companyId);
const incidents = (overview.data?.activeIncidents ?? []).filter(
(incident) => incident.scopeType === "agent" && incident.scopeId === agent.id,
);
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.budgets.overview(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
};
const policy = useMutation({
mutationFn: (changes: { amount?: number; reservationCents?: string; unpricedUsagePolicy?: "block" | "allow" }) =>
budgetsApi.upsertPolicy(companyId, {
scopeType: "agent",
scopeId: agent.id,
metric: summary.metric,
windowKind: summary.windowKind,
...changes,
}),
onSuccess: invalidate,
});
const incident = useMutation({
mutationFn: (input: { incidentId: string; action: "keep_paused" | "raise_budget_and_resume"; amount?: number }) =>
budgetsApi.resolveIncident(companyId, input.incidentId, input),
onSuccess: invalidate,
});
if (overview.isLoading) return <PageSkeleton variant="costs" />;
return (
<div className="space-y-4">
{overview.error && !overview.data ? (
<p role="alert" className="text-sm text-destructive">Budget data could not be loaded. Showing the agent's saved monthly budget.</p>
) : null}
{policy.error ? (
<p role="alert" className="text-sm text-destructive">Could not update {agent.name}'s budget. Review the amount and try again.</p>
) : null}
{incidents.map((entry) => (
<div key={entry.id} className="space-y-2">
<BudgetIncidentCard
incident={entry}
isMutating={incident.isPending}
onKeepPaused={() => incident.mutate({ incidentId: entry.id, action: "keep_paused" })}
onRaiseAndResume={(amount) => incident.mutate({ incidentId: entry.id, action: "raise_budget_and_resume", amount })}
/>
{incident.error && incident.variables?.incidentId === entry.id ? (
<p role="alert" className="text-sm text-destructive">
{incident.variables.action === "keep_paused" ? "Could not keep this agent paused." : "Could not raise the budget and resume."}{" "}
{incident.error instanceof Error && incident.error.message ? incident.error.message : "Try again."}
</p>
) : null}
</div>
))}
<BudgetPolicyCard
summary={summary}
isSaving={policy.isPending}
onSave={(amount) => policy.mutate({ amount })}
onReservationChange={(reservationCents) => policy.mutate({ reservationCents })}
onUnpricedUsagePolicyChange={(unpricedUsagePolicy) => policy.mutate({ unpricedUsagePolicy })}
/>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Coins, DollarSign, PlayCircle, ReceiptText } from "lucide-react";
import type { CostByAgent, CostByAgentModel } from "@paperclipai/shared";
import { costsApi } from "../api/costs";
import { CostAmount } from "../components/CostAmount";
import { EmptyState } from "../components/EmptyState";
import { MetricCard } from "../components/MetricCard";
import { PageSkeleton } from "../components/PageSkeleton";
import { Button } from "@/components/ui/button";
import { useDateRange, PRESET_KEYS, PRESET_LABELS } from "../hooks/useDateRange";
import { queryKeys } from "../lib/queryKeys";
import { billingTypeDisplayName, formatCents, formatTokens, providerDisplayName } from "../lib/utils";
export function agentCostRow(rows: readonly CostByAgent[] | undefined, agentId: string): CostByAgent | null {
return rows?.find((row) => row.agentId === agentId) ?? null;
}
export function agentModelCostRows(rows: readonly CostByAgentModel[] | undefined, agentId: string): CostByAgentModel[] {
return (rows ?? [])
.filter((row) => row.agentId === agentId)
.sort((a, b) => b.costCents - a.costCents);
}
export function AgentCostsPanel({ agentId, agentName, companyId }: { agentId: string; agentName: string; companyId: string }) {
const { preset, setPreset, customFrom, setCustomFrom, customTo, setCustomTo, from, to, customReady } = useDateRange();
const reportFrom = ["custom", "mtd", "ytd"].includes(preset) ? from || undefined : undefined;
const reportTo = preset === "custom" ? to || undefined : preset;
const spend = useQuery({
queryKey: [...queryKeys.costs(companyId, reportFrom, reportTo), "agent", agentId],
queryFn: async () => {
const [byAgent, byAgentModel] = await Promise.all([
costsApi.byAgent(companyId, from || undefined, to || undefined),
costsApi.byAgentModel(companyId, from || undefined, to || undefined),
]);
return { row: agentCostRow(byAgent, agentId), models: agentModelCostRows(byAgentModel, agentId) };
},
enabled: customReady,
refetchInterval: 30_000,
});
const row = spend.data?.row ?? null;
const models = spend.data?.models ?? [];
const totalTokens = useMemo(
() => (row ? row.inputTokens + row.cachedInputTokens + row.outputTokens : 0),
[row],
);
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Cost period">
{PRESET_KEYS.map((key) => (
<Button key={key} variant={preset === key ? "secondary" : "ghost"} size="sm" onClick={() => setPreset(key)} aria-pressed={preset === key}>
{PRESET_LABELS[key]}
</Button>
))}
</div>
{preset === "custom" ? (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border p-3">
<input type="date" aria-label="From" value={customFrom} onChange={(event) => setCustomFrom(event.target.value)} className="h-9 rounded-md border border-input bg-background px-3 text-sm text-foreground" />
<span className="text-sm text-muted-foreground">to</span>
<input type="date" aria-label="To" value={customTo} onChange={(event) => setCustomTo(event.target.value)} className="h-9 rounded-md border border-input bg-background px-3 text-sm text-foreground" />
</div>
) : null}
{!customReady ? (
<p className="text-sm text-muted-foreground">Select a start and end date to load data.</p>
) : spend.isLoading ? (
<PageSkeleton variant="costs" />
) : spend.error && !spend.data ? (
<p role="alert" className="text-sm text-destructive">{agentName}'s costs could not be loaded. Please try again shortly.</p>
) : !row ? (
<EmptyState icon={DollarSign} message={`No cost events for ${agentName} in this period.`} />
) : (
<>
<div className="grid gap-3 sm:grid-cols-3">
<MetricCard appearance="card" icon={ReceiptText} value={formatCents(row.costCents)} label="Spend" description={`${row.eventCount} ledger events`} />
<MetricCard appearance="card" icon={Coins} value={formatTokens(totalTokens)} label="Tokens" description={`in ${formatTokens(row.inputTokens + row.cachedInputTokens)} (${formatTokens(row.cachedInputTokens)} cached) · out ${formatTokens(row.outputTokens)}`} />
<MetricCard appearance="card" icon={PlayCircle} value={row.apiRunCount + row.subscriptionRunCount} label="Runs" description={`${row.apiRunCount} api · ${row.subscriptionRunCount} subscription`} />
</div>
<section className="space-y-3" aria-labelledby="agent-cost-models-heading">
<h3 id="agent-cost-models-heading" className="text-sm font-medium text-foreground">By model</h3>
{models.length === 0 ? (
<p className="text-sm text-muted-foreground">No model breakdown for this period.</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{models.map((model) => {
const share = row.costCents > 0 ? Math.round((model.costCents / row.costCents) * 100) : 0;
return (
<li key={`${model.provider}:${model.model}:${model.billingType}`} className="flex flex-col gap-2 px-4 py-3 text-sm sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="truncate text-foreground">
{providerDisplayName(model.provider)}
<span className="mx-1 text-muted-foreground">/</span>
<span className="font-mono">{model.model}</span>
</div>
<div className="truncate text-xs text-muted-foreground">
{providerDisplayName(model.biller)} · {billingTypeDisplayName(model.billingType)}
</div>
</div>
<div className="text-right tabular-nums">
<CostAmount
costCents={model.costCents}
eventCount={model.eventCount}
estimatedEventCount={model.estimatedEventCount}
suffix={<span className="ml-1 font-normal text-muted-foreground">({share}%)</span>}
/>
<div className="text-xs text-muted-foreground">
{formatTokens(model.inputTokens + model.cachedInputTokens + model.outputTokens)} tok
</div>
</div>
</li>
);
})}
</ul>
)}
</section>
</>
)}
</div>
);
}
+60 -55
View File
@@ -293,14 +293,17 @@ describe("PromptsTab instruction editor", () => {
await flushReact();
}
async function selectInstructionMode(mode: "Read" | "Edit" | "Raw") {
await act(async () => {
buttonByText(container, mode.toLowerCase()).click();
});
async function selectInstructionMode(mode: "Edit" | "Raw") {
const editing = Boolean(container.querySelector('button[aria-pressed="true"][aria-label^="Stop editing"]'));
if ((mode === "Edit") !== editing) {
await act(async () => {
container.querySelector<HTMLButtonElement>(mode === "Edit" ? 'button[aria-label^="Edit "]' : 'button[aria-label^="Stop editing"]')!.click();
});
}
await flushReact();
}
it("uses server markdown metadata for extensionless files and saves MarkdownEditor drafts", async () => {
it("shows extensionless Markdown files as raw text and saves the edited draft", async () => {
const summary = makeSummary("AGENTS", "AGENTS", {
language: "markdown",
markdown: true,
@@ -311,22 +314,18 @@ describe("PromptsTab instruction editor", () => {
);
await waitFor(() => {
expect(container.querySelector('[data-testid="markdown-body"]')?.textContent).toBe("# Current");
expect(container.querySelector('[data-testid="instructions-raw-source"]')?.textContent?.trim()).toBe("# Current");
});
await selectInstructionMode("Raw");
expect(container.querySelector('[data-testid="instructions-raw-source"]')?.textContent?.trim()).toBe("# Current");
expect(container.querySelector('[data-testid="markdown-body"]')).toBeNull();
await selectInstructionMode("Edit");
const editor = await waitFor(() => {
const candidate = container.querySelector<HTMLTextAreaElement>('[data-testid="markdown-editor"]');
const candidate = container.querySelector<HTMLTextAreaElement>('textarea[placeholder="File contents"]');
expect(candidate).not.toBeNull();
return candidate!;
});
expect(markdownEditorRenderMock).toHaveBeenLastCalledWith(expect.objectContaining({
contentClassName: expect.not.stringContaining("font-mono"),
hasImageUploadHandler: true,
value: "# Current",
}));
expect(editor.value).toBe("# Current");
expect(markdownEditorRenderMock).not.toHaveBeenCalled();
await act(async () => {
editor.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
@@ -359,7 +358,7 @@ describe("PromptsTab instruction editor", () => {
await waitFor(() => expect(buttonByText(container, "Review preserved edits")).toBeDefined());
await act(async () => { buttonByText(container, "Review preserved edits").click(); });
const editor = await waitFor(() => {
const element = container.querySelector<HTMLTextAreaElement>('[data-testid="markdown-editor"]');
const element = container.querySelector<HTMLTextAreaElement>('textarea[placeholder="File contents"]');
expect(element?.value).toBe("preserved edit"); return element!;
});
mockAgentsApi.resolveInstructionCandidate.mockRejectedValue(new Error("Instructions changed since the base revision"));
@@ -456,7 +455,7 @@ describe("PromptsTab instruction editor", () => {
// Choosing to edit and paste into the current entry is a separate ordinary save.
await selectInstructionMode("Edit");
const editor = container.querySelector<HTMLTextAreaElement>('[data-testid="markdown-editor"]')!;
const editor = container.querySelector<HTMLTextAreaElement>('textarea[placeholder="File contents"]')!;
expect(editor.value).toBe("Current instructions");
await act(async () => { editor.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); setNativeValue(editor, preserved); });
await waitFor(() => expect(saveAction).toEqual(expect.any(Function)));
@@ -471,7 +470,7 @@ describe("PromptsTab instruction editor", () => {
await renderPromptsTab(makeBundle("AGENTS.md", [summary]), { "AGENTS.md": makeDetail(summary, "original", { revision }) });
mockAgentsApi.saveInstructionsFile.mockRejectedValue(new Error("Instructions changed since the base revision"));
await selectInstructionMode("Edit");
const editor = container.querySelector<HTMLTextAreaElement>('[data-testid="markdown-editor"]')!;
const editor = container.querySelector<HTMLTextAreaElement>('textarea[placeholder="File contents"]')!;
await act(async () => { editor.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); setNativeValue(editor, "my unsaved edit"); });
await waitFor(() => expect(saveAction).toEqual(expect.any(Function)));
await act(async () => { saveAction?.(); });
@@ -496,34 +495,6 @@ describe("PromptsTab instruction editor", () => {
await waitFor(() => expect(mockAgentsApi.restoreInstructions).toHaveBeenCalledWith("agent-1", { path: "AGENTS.md", revisionId: "old-revision", baseRevisionId: "current-1" }, "company-1"));
});
it("ignores rich-editor mount normalization until the user interacts", async () => {
const summary = makeSummary("AGENTS.md", "AGENTS.md");
const onDirtyChange = vi.fn();
await renderPromptsTab(
makeBundle("AGENTS.md", [summary]),
{ "AGENTS.md": makeDetail(summary, "# Current") },
{ onDirtyChange },
);
await selectInstructionMode("Edit");
const editorProps = await waitFor(() => {
const latest = markdownEditorRenderMock.mock.calls.at(-1)?.[0] as
| { onChange?: (value: string) => void }
| undefined;
expect(latest?.onChange).toEqual(expect.any(Function));
return latest!;
});
await act(async () => {
editorProps.onChange?.("# Current\n");
});
await flushReact();
expect(onDirtyChange).not.toHaveBeenCalledWith(true);
expect(saveAction).toBeNull();
expect(mockAgentsApi.saveInstructionsFile).not.toHaveBeenCalled();
});
it("releases dirty state and save controls when the instructions tab unmounts", async () => {
const summary = makeSummary("AGENTS.md", "AGENTS.md");
const onDirtyChange = vi.fn();
@@ -542,7 +513,7 @@ describe("PromptsTab instruction editor", () => {
await selectInstructionMode("Edit");
const editor = await waitFor(() => {
const candidate = container.querySelector<HTMLTextAreaElement>('[data-testid="markdown-editor"]');
const candidate = container.querySelector<HTMLTextAreaElement>('textarea[placeholder="File contents"]');
expect(candidate).not.toBeNull();
return candidate!;
});
@@ -567,7 +538,44 @@ describe("PromptsTab instruction editor", () => {
expect(cancelAction).toBeNull();
});
it("uses the Markdown editor for pending new .md files before server metadata exists", async () => {
it("adopts a loaded external profile instead of keeping the old bundle as unsaved changes", async () => {
const managed = makeSummary("AGENTS.md", "AGENTS.md");
const loaded = makeSummary("SOUL.md", "SOUL.md");
const onDirtyChange = vi.fn();
await renderPromptsTab(
makeBundle("AGENTS.md", [managed]),
{
"AGENTS.md": makeDetail(managed, "# Managed"),
"SOUL.md": makeDetail(loaded, "# Loaded profile"),
},
{ onDirtyChange },
);
mockAgentsApi.updateInstructionsBundle.mockImplementation(async () => {
mockAgentsApi.instructionsBundle.mockResolvedValue(makeBundle("SOUL.md", [loaded], {
mode: "external",
rootPath: "/srv/prompts/atlas",
resolvedEntryPath: "/srv/prompts/atlas/SOUL.md",
}));
return {};
});
await act(async () => buttonByText(container, "Advanced settings").click());
const panel = container.querySelector<HTMLElement>('[aria-label="External management"]')!;
const [rootInput, entryInput] = Array.from(panel.querySelectorAll("input"));
await act(async () => setNativeValue(rootInput, "/srv/prompts/atlas"));
await act(async () => setNativeValue(entryInput, "SOUL.md"));
await act(async () => buttonByText(container, "Load profile").click());
await waitFor(() => {
expect(panel.querySelector('[role="status"]')?.textContent).toContain("Loaded SOUL.md from /srv/prompts/atlas");
expect(container.querySelector('[data-testid="instructions-raw-source"]')?.textContent?.trim()).toBe("# Loaded profile");
});
expect(mockAgentsApi.updateInstructionsBundle).toHaveBeenCalledTimes(1);
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
expect(saveAction).toBeNull();
});
it("edits pending new files as raw text before server metadata exists", async () => {
const summary = makeSummary("settings.json", "settings.json", {
language: "json",
markdown: false,
@@ -583,7 +591,7 @@ describe("PromptsTab instruction editor", () => {
});
await act(async () => {
buttonByText(container, "+").click();
container.querySelector<HTMLButtonElement>('button[aria-label="Add agent file"]')!.click();
});
await flushReact();
const input = container.querySelector<HTMLInputElement>('input[placeholder="TOOLS.md"]');
@@ -596,12 +604,13 @@ describe("PromptsTab instruction editor", () => {
await selectInstructionMode("Edit");
await waitFor(() => {
expect(container.querySelector('[data-testid="markdown-editor"]')).not.toBeNull();
expect(container.querySelector<HTMLTextAreaElement>('textarea[placeholder="File contents"]')?.value).toBe("");
});
expect(markdownEditorRenderMock).not.toHaveBeenCalled();
expect(mockAgentsApi.instructionsFile).not.toHaveBeenCalledWith("agent-1", "notes.md", "company-1");
});
it("falls back to extension detection for existing .md files when metadata is missing", async () => {
it("edits .md files without Markdown metadata as raw text", async () => {
const summary = makeSummary("FALLBACK.md", "FALLBACK.md", {
language: "text",
markdown: undefined,
@@ -613,10 +622,7 @@ describe("PromptsTab instruction editor", () => {
await selectInstructionMode("Edit");
await waitFor(() => {
expect(container.querySelector("[data-testid=\"markdown-editor\"]")).not.toBeNull();
expect(markdownEditorRenderMock).toHaveBeenLastCalledWith(expect.objectContaining({
value: "# Fallback",
}));
expect(container.querySelector<HTMLTextAreaElement>('textarea[placeholder="File contents"]')?.value).toBe("# Fallback");
});
});
@@ -632,7 +638,6 @@ describe("PromptsTab instruction editor", () => {
await selectInstructionMode("Edit");
await waitFor(() => {
expect(container.querySelector('[data-testid="markdown-editor"]')).toBeNull();
expect(container.querySelector<HTMLTextAreaElement>('textarea[placeholder="File contents"]')?.value).toBe("raw instructions");
});
});
+1 -1
View File
@@ -18,7 +18,7 @@ import {
describe("agent detail tabs", () => {
it("exposes Secrets as its own route-backed tab", () => {
expect(AGENT_DETAIL_TABS.map((tab) => tab.label)).toContain("Secrets");
expect(AGENT_DETAIL_TABS.map((tab) => tab.label)).toContain("Secrets & variables");
expect(parseAgentDetailView("secrets")).toBe("secrets");
});
File diff suppressed because it is too large. Load diff
+15 -25
View File
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { AgentDetail, AgentRuntimeState, HeartbeatRun, Issue } from "@paperclipai/shared";
import { describe, expect, it, vi } from "vitest";
import { AgentOverview } from "./AgentDetail";
import { TooltipProvider } from "@/components/ui/tooltip";
import { queryKeys } from "../lib/queryKeys";
vi.mock("@/lib/router", async () => {
@@ -22,25 +23,7 @@ vi.mock("../components/MarkdownBody", () => ({
}));
describe("AgentOverview", () => {
it("identifies Muse and shows its provider-managed model even when old model fields remain", () => {
const agent = {
id: "muse-agent", companyId: "company-1", name: "Muse Researcher", role: "researcher", status: "active",
adapterType: "paperclip_runner", adapterConfig: { provider: "muse", model: "old-model" },
runtimeConfig: { model: "old-runtime-model" },
} as unknown as AgentDetail;
const markup = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<AgentOverview agent={agent} runs={[]} assignedIssues={[]} directReportCount={0} skillNames={[]} agentRouteId="muse-agent" />
</QueryClientProvider>,
);
expect(markup).toContain("Muse — Personal agent");
expect(markup).toContain("Managed by Muse");
expect(markup).not.toContain("Adapter default");
expect(markup).not.toContain("old-model");
expect(markup).not.toContain("old-runtime-model");
});
it("prioritizes identity, capability, runtime, skills, and tasks without duplicating sidebar Audit links", () => {
it("leads with audit cards, then Profile, Skills and Recent tasks", () => {
const agent = {
id: "agent-1",
companyId: "company-1",
@@ -72,6 +55,7 @@ describe("AgentOverview", () => {
const markup = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<TooltipProvider>
<AgentOverview
agent={agent}
runs={[] as HeartbeatRun[]}
@@ -81,19 +65,25 @@ describe("AgentOverview", () => {
skillNames={["Design Guide", "Check PR"]}
agentRouteId="codexcoder"
/>
</TooltipProvider>
</QueryClientProvider>,
);
expect(markup).toContain("Identity");
expect(markup).toContain("Capabilities");
expect(markup).toContain("Harness / Runtime");
for (const label of ["Activity", "Runs", "Costs", "Budget used", "Profile", "Skills", "Recent tasks"]) {
expect(markup).toContain(label);
}
for (const page of ["activity", "runs", "costs", "budgets"]) {
expect(markup).toContain(`href="/agents/codexcoder/${page}"`);
}
expect(markup).toContain("2 enabled");
expect(markup).toContain("Design Guide");
expect(markup).toContain("Simplify agent information architecture");
expect(markup).toContain("PAP-42");
expect(markup).not.toContain("agent-audit-links-heading");
expect(markup).toContain("See all tasks");
expect(markup).not.toContain("Latest Run");
expect(markup).not.toContain("Harness / Runtime");
expect(markup).not.toContain("Capabilities");
expect(markup).not.toContain("agentId=agent-1");
expect(markup).not.toContain("Run Activity");
expect(markup).not.toContain("Tasks by Status");
expect(markup).not.toContain("Created by");
});
+28 -5
View File
@@ -2,26 +2,48 @@ import { describe, expect, it } from "vitest";
import {
AGENT_DETAIL_NAVIGATION,
agentDetailHref,
agentDetailViewLabel,
agentLegacyAuditSection,
isAgentAuditView,
agentScopedAuditHref,
parseAgentDetailView,
} from "./agent-detail-navigation";
describe("agent detail navigation", () => {
it("exposes the complete local information architecture", () => {
expect(AGENT_DETAIL_NAVIGATION.map((section) => section.label)).toEqual(["Agent", "Audit"]);
expect(AGENT_DETAIL_NAVIGATION.flatMap((section) => section.items.map((item) => item.value))).toEqual([
"overview",
"instructions",
"skills",
"runtime",
"secrets",
"tools",
"channels",
"permissions",
"api-keys",
"revisions",
"advanced",
"activity",
"runs",
"costs",
"budgets",
]);
expect(agentDetailViewLabel("instructions")).toBe("Identity");
});
it("keeps views that left the nav reachable by URL", () => {
expect(parseAgentDetailView("skills")).toBe("skills");
expect(parseAgentDetailView("tools")).toBe("tools");
expect(parseAgentDetailView("channels")).toBe("channels");
expect(parseAgentDetailView("api-keys")).toBe("api-keys");
expect(parseAgentDetailView("revisions")).toBe("revisions");
expect(agentDetailViewLabel("revisions")).toBe("Agent history");
});
it("serves Identity at /identity and the audit pages inside the agent", () => {
expect(parseAgentDetailView("identity")).toBe("instructions");
expect(agentDetailHref("codie", "instructions")).toBe("/agents/codie/identity");
expect(parseAgentDetailView("runs")).toBe("runs");
expect(parseAgentDetailView("budget")).toBe("budgets");
expect(isAgentAuditView("costs")).toBe(true);
expect(isAgentAuditView("runtime")).toBe(false);
expect(agentDetailHref("codie", "activity")).toBe("/agents/codie/activity");
});
it("canonicalizes legacy local paths", () => {
@@ -30,6 +52,7 @@ describe("agent detail navigation", () => {
expect(parseAgentDetailView("dashboard")).toBe("overview");
expect(parseAgentDetailView("configuration")).toBe("runtime");
expect(parseAgentDetailView("prompts")).toBe("instructions");
expect(agentDetailHref("codie", "instructions")).toBe("/agents/codie/identity");
expect(agentDetailHref("codexcoder", "permissions")).toBe("/agents/codexcoder/permissions");
});
+41 -19
View File
@@ -1,5 +1,7 @@
import { auditSectionHref, type AuditSection } from "./audit/audit-navigation";
export type AgentAuditView = Extract<AuditSection, "activity" | "runs" | "costs" | "budgets">;
export type AgentDetailView =
| "overview"
| "instructions"
@@ -12,6 +14,7 @@ export type AgentDetailView =
| "api-keys"
| "revisions"
| "advanced"
| AgentAuditView
| "run-detail";
export type AgentLocalDetailView = Exclude<AgentDetailView, "run-detail">;
@@ -24,32 +27,45 @@ export const AGENT_DETAIL_NAVIGATION: ReadonlyArray<{
label: "Agent",
items: [
{ value: "overview", label: "Overview" },
{ value: "instructions", label: "Instructions" },
{ value: "skills", label: "Skills" },
],
},
{
label: "Runtime",
items: [
{ value: "instructions", label: "Identity" },
{ value: "runtime", label: "Harness / Runtime" },
{ value: "secrets", label: "Secrets" },
{ value: "tools", label: "Tools" },
{ value: "channels", label: "Channels" },
{ value: "secrets", label: "Secrets & variables" },
{ value: "permissions", label: "Permissions / Trust" },
{ value: "advanced", label: "Advanced" },
],
},
{
label: "Governance",
label: "Audit",
items: [
{ value: "permissions", label: "Permissions / Trust" },
{ value: "api-keys", label: "API Keys" },
{ value: "revisions", label: "Revisions" },
{ value: "advanced", label: "Advanced" },
{ value: "activity", label: "Activity" },
{ value: "runs", label: "Runs" },
{ value: "costs", label: "Costs" },
{ value: "budgets", label: "Budgets" },
],
},
] as const;
const HIDDEN_VIEW_LABELS: Partial<Record<AgentLocalDetailView, string>> = {
skills: "Skills",
tools: "Tools",
channels: "Channels",
"api-keys": "API Keys",
revisions: "Agent history",
};
export const AGENT_AUDIT_VIEWS: ReadonlySet<AgentLocalDetailView> = new Set(["activity", "runs", "costs", "budgets"]);
export function isAgentAuditView(view: AgentDetailView): view is AgentAuditView {
return AGENT_AUDIT_VIEWS.has(view as AgentLocalDetailView);
}
export function agentDetailViewLabel(view: AgentLocalDetailView): string {
const item = AGENT_DETAIL_NAVIGATION.flatMap((section) => section.items).find((entry) => entry.value === view);
return item?.label ?? HIDDEN_VIEW_LABELS[view] ?? "Overview";
}
export function parseAgentDetailView(value: string | null): AgentLocalDetailView {
if (value === "instructions" || value === "prompts") return "instructions";
if (value === "identity" || value === "instructions" || value === "prompts") return "instructions";
if (value === "skills") return "skills";
if (value === "runtime" || value === "configure" || value === "configuration") return "runtime";
if (value === "secrets") return "secrets";
@@ -59,14 +75,20 @@ export function parseAgentDetailView(value: string | null): AgentLocalDetailView
if (value === "api-keys" || value === "keys") return "api-keys";
if (value === "revisions" || value === "history") return "revisions";
if (value === "advanced") return "advanced";
const audit = agentLegacyAuditSection(value);
if (audit) return audit;
return "overview";
}
export function agentDetailHref(agentRef: string, view: AgentLocalDetailView = "overview") {
return `/agents/${agentRef}/${view}`;
export function agentDetailSegment(view: AgentLocalDetailView): string {
return view === "instructions" ? "identity" : view;
}
export function agentLegacyAuditSection(value: string | null): AuditSection | null {
export function agentDetailHref(agentRef: string, view: AgentLocalDetailView = "overview") {
return `/agents/${agentRef}/${agentDetailSegment(view)}`;
}
export function agentLegacyAuditSection(value: string | null): AgentAuditView | null {
if (value === "runs") return "runs";
if (value === "audit" || value === "activity") return "activity";
if (value === "cost" || value === "costs") return "costs";
@@ -0,0 +1,74 @@
import { useMemo } from "react";
import { ArrowUpRight } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { toolsApi } from "@/api/tools";
import { AgentPanel } from "@/components/agent-detail/AgentPanels";
import { Button } from "@/components/ui/button";
import { Link } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
import { AppLogo } from "../apps/AppLogo";
const SHOWN_LOGOS = 3;
export function AgentAllowedConnectors({ agentId, companyId }: { agentId: string; companyId: string }) {
const effective = useQuery({
queryKey: queryKeys.tools.effectiveProfilesForAgent(companyId, agentId),
queryFn: () => toolsApi.getEffectiveProfilesForAgent(companyId, agentId),
});
const connections = useQuery({
queryKey: queryKeys.tools.connections(companyId),
queryFn: () => toolsApi.listConnections(companyId),
});
const applications = useQuery({
queryKey: queryKeys.tools.applications(companyId),
queryFn: () => toolsApi.listApplications(companyId),
});
const apps = useMemo(() => {
const connectionApp = new Map((connections.data?.connections ?? []).map((connection) => [connection.id, connection.applicationId]));
const appById = new Map((applications.data?.applications ?? []).map((application) => [application.id, application]));
const seen = new Map<string, { id: string; name: string; key?: string }>();
for (const tool of effective.data?.allowedTools ?? []) {
const applicationId = connectionApp.get(tool.connectionId);
const application = applicationId ? appById.get(applicationId) : undefined;
if (!application || seen.has(application.id)) continue;
seen.set(application.id, { id: application.id, name: application.name, key: application.applicationKey });
}
return [...seen.values()];
}, [applications.data?.applications, connections.data?.connections, effective.data?.allowedTools]);
const loading = effective.isLoading || connections.isLoading || applications.isLoading;
const failed = effective.error || connections.error || applications.error;
return (
<AgentPanel
title="Allowed connectors"
aria-label="Allowed connectors"
action={
<Button variant="ghost" size="icon-xs" asChild>
<Link to="/apps?source=installed" aria-label="Manage connectors" title="Manage connectors">
<ArrowUpRight className="size-4" />
</Link>
</Button>
}
>
<div className="flex min-w-0 flex-1 flex-col justify-center px-(--agent-detail-module-inset) pb-(--agent-detail-module-inset)">
{loading ? (
<p role="status" className="text-xs text-muted-foreground">Resolving access…</p>
) : failed ? (
<p role="alert" className="text-xs text-destructive">Couldn’t load this agent’s connectors.</p>
) : apps.length === 0 ? (
<p className="text-xs text-muted-foreground">No connectors are allowed yet.</p>
) : (
<ul aria-label="Connectors" className="flex max-h-(--agent-detail-logo-rows-h) flex-wrap gap-3 overflow-hidden">
{apps.slice(0, SHOWN_LOGOS).map((app) => (
<li key={app.id} className="flex shrink-0" title={app.name}>
<AppLogo name={app.name} brandKey={app.key} size={24} compact />
</li>
))}
</ul>
)}
</div>
</AgentPanel>
);
}
@@ -0,0 +1,99 @@
import { useLayoutEffect, useRef, useState } from "react";
import type { Agent } from "@paperclipai/shared";
import { AgentAvatar } from "@/components/AgentAvatar";
import { Link } from "@/lib/router";
import { agentRouteRef } from "@/lib/utils";
import { agentDetailHref } from "../agent-detail-navigation";
const MAX_LINES = 2;
const itemClass = "inline-flex min-w-0 max-w-full items-center gap-1.5";
const toggleClass = "text-muted-foreground transition-colors hover:text-foreground";
function lineCount(widths: number[], available: number, gap: number) {
let lines = 0;
let used = 0;
for (const width of widths) {
if (lines > 0 && used + gap + width <= available) {
used += gap + width;
} else {
lines += 1;
used = width;
}
}
return lines;
}
export function fitReports(widths: number[], available: number, gap: number, moreWidth: number, maxLines = MAX_LINES) {
if (available <= 0 || lineCount(widths, available, gap) <= maxLines) return widths.length;
for (let shown = widths.length - 1; shown > 0; shown -= 1) {
if (lineCount([...widths.slice(0, shown), moreWidth], available, gap) <= maxLines) return shown;
}
return 0;
}
export function AgentDirectReports({ reports }: { reports: Agent[] }) {
const listRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const [shown, setShown] = useState(reports.length);
const [expanded, setExpanded] = useState(false);
const reportsKey = reports.map((report) => `${report.id}:${report.name}`).join("|");
useLayoutEffect(() => {
const list = listRef.current;
const measure = measureRef.current;
if (!list || !measure) return;
const fit = () => {
const widths = Array.from(measure.children, (child) => Math.ceil(child.getBoundingClientRect().width));
const moreWidth = widths.pop() ?? 0;
const gap = Number.parseFloat(getComputedStyle(list).columnGap) || 0;
setShown(fitReports(widths, Math.floor(list.clientWidth), gap, moreWidth));
};
fit();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(fit);
observer.observe(list);
observer.observe(measure);
return () => observer.disconnect();
}, [reportsKey]);
const hidden = expanded ? 0 : Math.max(0, reports.length - shown);
const visible = hidden > 0 ? reports.slice(0, shown) : reports;
return (
<div className="relative">
<div ref={listRef} className="flex flex-wrap items-center gap-x-4 gap-y-2">
{visible.map((report) => (
<Link
key={report.id}
to={agentDetailHref(agentRouteRef(report))}
className={`${itemClass} text-foreground no-underline hover:underline`}
>
<AgentAvatar agent={report} size={20} />
<span className="truncate">{report.name}</span>
</Link>
))}
{hidden > 0 ? (
<button type="button" className={toggleClass} onClick={() => setExpanded(true)}>
+{hidden} more
</button>
) : null}
{expanded && shown < reports.length ? (
<button type="button" className={toggleClass} onClick={() => setExpanded(false)}>
Show less
</button>
) : null}
</div>
<div aria-hidden className="pointer-events-none invisible absolute inset-x-0 top-0 h-0 overflow-hidden">
<div ref={measureRef} className="flex w-max">
{reports.map((report) => (
<span key={report.id} className="inline-flex items-center gap-1.5">
<AgentAvatar agent={report} size={20} />
<span>{report.name}</span>
</span>
))}
<span>+{reports.length} more</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,210 @@
import { useState } from "react";
import { RotateCcw } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AgentConfigRevision, AgentDetail as AgentDetailRecord } from "@paperclipai/shared";
import { agentsApi } from "@/api/agents";
import { accessApi } from "@/api/access";
import { AgentChip, agentTableHeadClass, agentTableRowClass } from "@/components/agent-detail/AgentPanels";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useNavigate } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
import { formatDate } from "@/lib/utils";
import { syncAgentRouteAfterRename } from "./agent-route";
const KEY_LABELS: Record<string, string> = {
name: "name",
title: "title",
role: "role",
reportsTo: "reporting line",
capabilities: "capabilities",
adapterType: "harness",
adapterConfig: "harness settings",
runtimeConfig: "runtime settings",
budgetMonthlyCents: "budget",
permissions: "permissions",
metadata: "metadata",
};
export function revisionSummary(revision: Pick<AgentConfigRevision, "changedKeys" | "rolledBackFromRevisionId">): string {
if (revision.rolledBackFromRevisionId) return "Restored an earlier version";
if (revision.changedKeys.length === 0) return "Saved with no tracked changes";
const labels = revision.changedKeys.map((key) => KEY_LABELS[key] ?? key);
const sentence = labels.length === 1 ? labels[0] : `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}`;
return `Changed ${sentence}`;
}
function useRevisionAuthor(agent: AgentDetailRecord, companyId: string | undefined) {
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(companyId ?? agent.companyId),
queryFn: () => agentsApi.list(companyId ?? agent.companyId),
});
const { data: directory } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(agent.companyId),
queryFn: () => accessApi.listUserDirectory(agent.companyId),
});
return (revision: AgentConfigRevision) => {
if (revision.createdByAgentId) {
return agents?.find((candidate) => candidate.id === revision.createdByAgentId)?.name ?? "An agent";
}
if (revision.createdByUserId && revision.createdByUserId !== "local-board") {
const entry = directory?.users.find((candidate) => candidate.principalId === revision.createdByUserId);
return entry?.user?.name?.trim() || entry?.user?.email || "Former member";
}
return "Board";
};
}
export function AgentHistoryDialog({
agent,
companyId,
open,
onOpenChange,
}: {
agent: AgentDetailRecord;
companyId?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [pending, setPending] = useState<AgentConfigRevision | null>(null);
const authorOf = useRevisionAuthor(agent, companyId);
const { data: revisions, isLoading, error } = useQuery({
queryKey: queryKeys.agents.configRevisions(agent.id),
queryFn: () => agentsApi.listConfigRevisions(agent.id, companyId),
enabled: open,
});
const rollback = useMutation({
mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId, companyId),
onSuccess: (updated) => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
setPending(null);
if (!syncAgentRouteAfterRename(queryClient, navigate, agent, updated, "overview")) {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
}
},
});
const restoreError = rollback.error
? rollback.error instanceof Error && rollback.error.message
? rollback.error.message
: "Could not restore that version."
: null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-(--agent-detail-dialog-max-h) flex-col gap-5 sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="text-base">Agent history</DialogTitle>
<DialogDescription>Every saved change to {agent.name}&rsquo;s identity and configuration. Restore any version.</DialogDescription>
</DialogHeader>
{error ? (
<p role="alert" className="text-sm text-destructive">{error instanceof Error ? error.message : "Could not load agent history."}</p>
) : isLoading ? (
<p role="status" className="text-sm text-muted-foreground">Loading agent history…</p>
) : (revisions ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">No saved changes yet.</p>
) : (
<div className="min-h-0 overflow-y-auto rounded-lg border border-border">
<div className={`${agentTableHeadClass} grid-cols-(--agent-detail-history-cols)`}>
<span>Configuration</span>
<span>Team member</span>
<span>Date configured</span>
<span aria-hidden />
</div>
{(revisions ?? []).map((revision) => (
<div key={revision.id} className={`${agentTableRowClass} grid-cols-(--agent-detail-history-cols)`}>
<span className="min-w-0">
<span className="block text-sm text-foreground">{revisionSummary(revision)}</span>
{revision.changedKeys.length > 0 ? (
<span className="mt-1 flex flex-wrap gap-1">
{revision.changedKeys.map((key) => (
<AgentChip key={key} mono>
{key}
</AgentChip>
))}
</span>
) : null}
</span>
<span className="truncate text-sm text-foreground">{authorOf(revision)}</span>
<span className="truncate text-sm tabular-nums text-muted-foreground">{formatDate(revision.createdAt)}</span>
<Button
variant="ghost"
size="sm"
className="-mr-2.5 justify-self-end"
onClick={() => {
rollback.reset();
setPending(revision);
}}
disabled={rollback.isPending}
>
<RotateCcw />
Restore
</Button>
</div>
))}
</div>
)}
{restoreError && pending === null ? (
<p role="alert" className="text-sm text-destructive">{restoreError}</p>
) : null}
</DialogContent>
<AlertDialog open={pending !== null} onOpenChange={(next) => (next ? undefined : setPending(null))}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Restore this version?</AlertDialogTitle>
<AlertDialogDescription>
{pending
? `Continuing will restore ${agent.name}’s identity and configuration to the version saved on ${formatDate(pending.createdAt)}. Anything changed since then will be replaced. The current version stays in Agent history.`
: null}
</AlertDialogDescription>
</AlertDialogHeader>
{pending ? (
<div className="flex flex-col gap-2 rounded-lg bg-(--agent-detail-tile) p-4">
<span className="text-xs text-muted-foreground">
{formatDate(pending.createdAt)} · By: {authorOf(pending)}
</span>
<span className="text-sm text-foreground">{revisionSummary(pending)}</span>
{pending.changedKeys.length > 0 ? (
<span className="flex flex-wrap gap-1">
{pending.changedKeys.map((key) => (
<AgentChip key={key} mono>
{key}
</AgentChip>
))}
</span>
) : null}
</div>
) : null}
{restoreError ? (
<p role="alert" className="text-sm text-destructive">{restoreError}</p>
) : null}
<AlertDialogFooter className="sm:justify-between">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={rollback.isPending}
onClick={(event) => {
event.preventDefault();
if (pending) rollback.mutate(pending.id);
}}
>
<RotateCcw />
{rollback.isPending ? "Restoring…" : "Restore version"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}
@@ -0,0 +1,104 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { InstructionsBundleSettings } from "./InstructionsBundleSettings";
const mockAgentsApi = vi.hoisted(() => ({
updateInstructionsBundle: vi.fn(),
}));
const onSwitched = vi.fn();
vi.mock("@/api/agents", () => ({ agentsApi: mockAgentsApi }));
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
function setNativeValue(element: HTMLInputElement, value: string) {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(element, value);
element.dispatchEvent(new Event("input", { bubbles: true }));
}
function button(container: HTMLElement, text: string) {
const match = Array.from(container.querySelectorAll("button")).find((candidate) => candidate.textContent?.trim() === text);
if (!match) throw new Error(`Button not found: ${text}`);
return match as HTMLButtonElement;
}
describe("InstructionsBundleSettings", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
mockAgentsApi.updateInstructionsBundle.mockReset();
onSwitched.mockReset();
});
afterEach(async () => {
await act(() => root.unmount());
container.remove();
});
async function renderAndLoad(rootPath: string, entryFile: string) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(() =>
root.render(
<QueryClientProvider client={queryClient}>
<InstructionsBundleSettings agentId="agent-1" companyId="company-1" mode="managed" rootPath="" entryFile="AGENTS.md" blocked={false} onSwitched={onSwitched} />
</QueryClientProvider>,
),
);
await act(() => button(container, "Advanced settings").click());
const [rootInput, entryInput] = Array.from(container.querySelectorAll("input"));
await act(() => setNativeValue(rootInput, rootPath));
await act(() => setNativeValue(entryInput, entryFile));
await act(() => button(container, "Load profile").click());
await flushReact();
}
it("asks the server to switch only when the entry file already exists", async () => {
mockAgentsApi.updateInstructionsBundle.mockResolvedValue({});
await renderAndLoad("/srv/prompts/atlas", "AGENTS");
expect(mockAgentsApi.updateInstructionsBundle).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.updateInstructionsBundle).toHaveBeenCalledWith(
"agent-1",
{ mode: "external", rootPath: "/srv/prompts/atlas", entryFile: "AGENTS", requireExistingEntry: true },
"company-1",
);
expect(container.querySelector('[role="status"]')?.textContent).toContain("Loaded AGENTS from /srv/prompts/atlas");
expect(onSwitched).toHaveBeenCalledTimes(1);
});
it("shows the server's reason and leaves the loading state when the entry file is missing", async () => {
mockAgentsApi.updateInstructionsBundle.mockRejectedValue(new Error("AGENTS.md was not found in /srv/prompts/atlas"));
await renderAndLoad("/srv/prompts/atlas", "AGENTS.md");
expect(mockAgentsApi.updateInstructionsBundle).toHaveBeenCalledTimes(1);
expect(container.querySelector('[role="status"]')?.textContent).toContain("AGENTS.md was not found in /srv/prompts/atlas");
expect(button(container, "Load profile").disabled).toBe(false);
expect(onSwitched).not.toHaveBeenCalled();
expect(Array.from(container.querySelectorAll("input")).every((input) => !input.disabled)).toBe(true);
});
});
@@ -0,0 +1,162 @@
import { useState } from "react";
import { ChevronDown, Loader2, Settings2 } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { agentsApi } from "@/api/agents";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { AgentPanel } from "@/components/agent-detail/AgentPanels";
import { queryKeys } from "@/lib/queryKeys";
type Field = "root" | "entry";
type LoadState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "loaded"; root: string; entry: string }
| { kind: "error"; field: Field | null; message: string };
export function checkExternalProfile(root: string, entry: string): { field: Field; message: string } | null {
if (!root.startsWith("/") && !root.startsWith("~")) return { field: "root", message: "Use an absolute path that starts with / or ~." };
if (entry.startsWith("/") || entry.split(/[\\/]/).includes("..")) {
return { field: "entry", message: "The entry file has to sit inside the root path." };
}
return null;
}
const errorMessage = (error: unknown, fallback: string) => (error instanceof Error && error.message ? error.message : fallback);
export function InstructionsBundleSettings({
agentId,
agentUrlKey,
companyId,
mode,
rootPath,
entryFile,
blocked,
onSwitched,
}: {
agentId: string;
agentUrlKey?: string | null;
companyId?: string;
mode: "managed" | "external";
rootPath: string;
entryFile: string;
blocked: boolean;
onSwitched: () => void;
}) {
const queryClient = useQueryClient();
const [root, setRoot] = useState(mode === "external" ? rootPath : "");
const [entry, setEntry] = useState(mode === "external" ? entryFile : "");
const [load, setLoad] = useState<LoadState>({ kind: "idle" });
const busy = load.kind === "loading";
const trimmedRoot = root.trim();
const trimmedEntry = entry.trim();
const unchanged = mode === "external" && trimmedRoot === rootPath && trimmedEntry === entryFile;
const refresh = async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agentId) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
if (agentUrlKey) queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentUrlKey) });
};
const edit = (setter: (value: string) => void) => (value: string) => {
setter(value);
if (!busy) setLoad({ kind: "idle" });
};
const loadProfile = async () => {
const problem = checkExternalProfile(trimmedRoot, trimmedEntry);
if (problem) return setLoad({ kind: "error", ...problem });
setLoad({ kind: "loading" });
try {
await agentsApi.updateInstructionsBundle(
agentId,
{ mode: "external", rootPath: trimmedRoot, entryFile: trimmedEntry, requireExistingEntry: true },
companyId,
);
} catch (error) {
setLoad({ kind: "error", field: null, message: errorMessage(error, `Couldn’t read ${trimmedEntry} in ${trimmedRoot}.`) });
return;
}
await refresh();
onSwitched();
setLoad({ kind: "loaded", root: trimmedRoot, entry: trimmedEntry });
};
const switchToManaged = async () => {
setLoad({ kind: "loading" });
try {
await agentsApi.updateInstructionsBundle(agentId, { mode: "managed", rootPath: null, entryFile }, companyId);
await refresh();
onSwitched();
setLoad({ kind: "idle" });
} catch (error) {
setLoad({ kind: "error", field: null, message: errorMessage(error, "Couldn’t switch back to the managed bundle.") });
}
};
const invalid = (field: Field) => load.kind === "error" && load.field === field;
return (
<Collapsible defaultOpen={mode === "external"} className="flex flex-col gap-(--agent-detail-module-gap)">
<CollapsibleTrigger className="group flex w-fit items-center gap-1.5 rounded-sm text-sm text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:text-foreground">
<Settings2 className="size-4" aria-hidden />
Advanced settings
<ChevronDown className="size-4 transition-transform group-data-[state=open]:rotate-180" aria-hidden />
</CollapsibleTrigger>
<CollapsibleContent>
<AgentPanel className="gap-4 p-(--agent-detail-module-inset)" aria-label="External management">
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">External management</p>
<p className="mt-0.5 text-xs text-muted-foreground">Point this agent at a directory on disk. Paperclip checks that the entry file exists before it switches over.</p>
</div>
<div className="grid gap-(--agent-detail-module-gap) sm:grid-cols-2">
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Root path</span>
<Input
value={root}
onChange={(event) => edit(setRoot)(event.target.value)}
disabled={busy || blocked}
aria-invalid={invalid("root") || undefined}
placeholder="/absolute/path/to/agent/prompts"
className="font-mono text-xs"
/>
</label>
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Entry file</span>
<Input
value={entry}
onChange={(event) => edit(setEntry)(event.target.value)}
disabled={busy || blocked}
aria-invalid={invalid("entry") || undefined}
placeholder="AGENTS.md"
className="font-mono text-xs"
/>
</label>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
{mode === "external" ? (
<Button variant="ghost" size="sm" className="mr-auto -ml-3" onClick={() => void switchToManaged()} disabled={busy || blocked}>
Use managed bundle
</Button>
) : null}
<p role="status" aria-live="polite" className="flex min-w-0 items-center gap-2 text-xs">
{blocked ? <span className="text-muted-foreground">Save or discard your file changes first.</span> : null}
{!blocked && load.kind === "error" ? <span className="text-destructive">{load.message}</span> : null}
{!blocked && load.kind === "loaded" ? (
<span className="flex items-center gap-2 text-muted-foreground">
<span className="size-1.5 shrink-0 rounded-full bg-(--agent-detail-dot-saved)" aria-hidden />
Loaded {load.entry} from {load.root}
</span>
) : null}
</p>
<Button size="sm" onClick={() => void loadProfile()} disabled={!trimmedRoot || !trimmedEntry || busy || blocked || unchanged}>
{busy ? <Loader2 className="animate-spin motion-reduce:animate-none" /> : null}
{busy ? "Loading…" : "Load profile"}
</Button>
</div>
</AgentPanel>
</CollapsibleContent>
</Collapsible>
);
}
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { fitReports } from "./AgentDirectReports";
import { revisionSummary } from "./AgentHistoryDialog";
import { checkExternalProfile } from "./InstructionsBundleSettings";
import { countSince, formatWindowCount } from "./overview-counts";
describe("revisionSummary", () => {
it("names the changed parts of the configuration in plain words", () => {
expect(revisionSummary({ changedKeys: ["adapterConfig"], rolledBackFromRevisionId: null })).toBe("Changed harness settings");
expect(revisionSummary({ changedKeys: ["name", "title"], rolledBackFromRevisionId: null })).toBe("Changed name and title");
expect(revisionSummary({ changedKeys: ["name", "runtimeConfig", "budgetMonthlyCents"], rolledBackFromRevisionId: null })).toBe(
"Changed name, runtime settings and budget",
);
});
it("calls out restores and empty revisions", () => {
expect(revisionSummary({ changedKeys: ["adapterConfig"], rolledBackFromRevisionId: "rev-1" })).toBe("Restored an earlier version");
expect(revisionSummary({ changedKeys: [], rolledBackFromRevisionId: null })).toBe("Saved with no tracked changes");
});
});
describe("checkExternalProfile", () => {
it("accepts an absolute or home root and any entry file inside it", () => {
expect(checkExternalProfile("/Users/avery/prompts/atlas", "AGENTS.md")).toBeNull();
expect(checkExternalProfile("~/prompts/atlas", "AGENTS")).toBeNull();
expect(checkExternalProfile("/Users/avery/prompts", "docs/instructions.txt")).toBeNull();
expect(checkExternalProfile("/Users/avery/prompts", "notes..md")).toBeNull();
});
it("points at the field that needs fixing", () => {
expect(checkExternalProfile("prompts/atlas", "AGENTS.md")).toMatchObject({ field: "root" });
expect(checkExternalProfile("/Users/avery/prompts", "../AGENTS.md")).toMatchObject({ field: "entry" });
expect(checkExternalProfile("/Users/avery/prompts", "docs/../../AGENTS.md")).toMatchObject({ field: "entry" });
expect(checkExternalProfile("/Users/avery/prompts", "/etc/AGENTS.md")).toMatchObject({ field: "entry" });
});
});
describe("countSince", () => {
const day = 24 * 60 * 60 * 1000;
const now = Date.parse("2026-10-30T12:00:00Z");
const rows = (ages: number[]) => ages.map((age, index) => ({ id: index, createdAt: new Date(now - age * day).toISOString() }));
it("counts rows inside the window and reports an exact total when the list reaches past it", () => {
const counted = countSince(rows([0.5, 1, 3, 9]), now - 7 * day, 4);
expect(counted).toEqual({ count: 3, atLeast: false });
expect(formatWindowCount(counted)).toBe("3");
});
it("marks the total as a lower bound when a capped list ends inside the window", () => {
const counted = countSince(rows([0.5, 1, 2, 3]), now - 7 * day, 4);
expect(counted).toEqual({ count: 4, atLeast: true });
expect(formatWindowCount(counted)).toBe("4+");
});
it("applies the row filter", () => {
const failed = rows([0.5, 1, 3]).map((row, index) => ({ ...row, status: index === 1 ? "failed" : "succeeded" }));
expect(countSince(failed, now - 2 * day, 200, (row) => row.status === "failed")).toEqual({ count: 1, atLeast: false });
});
});
describe("fitReports", () => {
const widths = [106, 106, 56, 60, 106, 129, 116, 140];
it("shows every report when they fit in two lines", () => {
expect(fitReports(widths.slice(0, 4), 406, 16, 56)).toBe(4);
expect(fitReports(widths, 900, 16, 56)).toBe(8);
});
it("leaves room for the more button on the second line", () => {
expect(fitReports(widths, 406, 16, 56)).toBe(6);
expect(fitReports(widths, 202, 16, 56)).toBe(2);
});
it("shows every report before the width is known", () => {
expect(fitReports(widths, 0, 16, 56)).toBe(8);
});
});
+27
View File
@@ -0,0 +1,27 @@
import type { QueryClient } from "@tanstack/react-query";
import type { NavigateFunction } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
import { agentRouteRef } from "@/lib/utils";
/**
* Agent detail URLs use a name-derived key, so updates that change the agent's
* name (a rename or a config-revision rollback) can invalidate the reference
* currently in the URL. When that happens, refetching the old reference would
* 404 with "Agent not found". Instead, drop the stale cached queries and
* replace the URL with the new canonical reference. Returns true when a
* redirect happened.
*/
export function syncAgentRouteAfterRename(
queryClient: QueryClient,
navigate: NavigateFunction,
previous: { id: string; urlKey?: string | null; name?: string | null },
updated: { id: string; urlKey?: string | null; name?: string | null },
tab: string,
): boolean {
const previousRef = agentRouteRef(previous);
const nextRef = agentRouteRef(updated);
if (nextRef === previousRef) return false;
queryClient.removeQueries({ queryKey: queryKeys.agents.detail(previousRef) });
navigate(`/agents/${nextRef}/${tab}`, { replace: true });
return true;
}
@@ -0,0 +1,21 @@
export type WindowCount = { count: number; atLeast: boolean };
export function countSince<T extends { createdAt: string | Date }>(
rows: readonly T[],
since: number,
limit: number,
match: (row: T) => boolean = () => true,
): WindowCount {
let count = 0;
let oldest = Infinity;
for (const row of rows) {
const at = new Date(row.createdAt).getTime();
oldest = Math.min(oldest, at);
if (at >= since && match(row)) count += 1;
}
return { count, atLeast: rows.length >= limit && oldest >= since };
}
export function formatWindowCount({ count, atLeast }: WindowCount) {
return atLeast ? `${count}+` : String(count);
}
+1
View File
@@ -391,6 +391,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
useEffect(() => { setQuery(""); setPage(1); setSource("paperclip"); setExpandedSearch(false); setArcadeToConfigure(null); setAggregatorToConnect(null); setAggregatorToManage(null); setInitialAggregatorProvider(undefined); setConnectionToRemove(null); }, [selectedCompanyId]);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("source") === "installed") setSource("installed");
if (params.get("source") === "composio") {
setAggregatorToConnect((params.get("targetToolkit") ? findComposioCatalogApp(params.get("targetToolkit")!) : null) ?? null);
setInitialAggregatorProvider("composio");
+25 -12
View File
@@ -126,9 +126,20 @@ function RoutineScopedRuns({
);
}
export function AuditRuns({ companyId, routineId }: { companyId: string; routineId?: string }) {
export function AuditRuns({
companyId,
routineId,
lockedAgentId,
hideHeader = false,
}: {
companyId: string;
routineId?: string;
/** Pin the list to one agent and hide the agent filter (the agent page's Runs). */
lockedAgentId?: string;
hideHeader?: boolean;
}) {
const [searchParams, setSearchParams] = useSearchParams();
const agentId = searchParams.get("agentId") ?? ALL;
const agentId = lockedAgentId ?? searchParams.get("agentId") ?? ALL;
const status = searchParams.get("runStatus") ?? ALL;
const agents = useQuery({
queryKey: queryKeys.agents.list(companyId),
@@ -200,16 +211,18 @@ export function AuditRuns({ companyId, routineId }: { companyId: string; routine
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-foreground">Runs</h2>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Recent agent executions across the organization. Open a run to inspect its transcript,
output, and task context.
</p>
</div>
{hideHeader ? null : (
<div>
<h2 className="text-lg font-semibold text-foreground">Runs</h2>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Recent agent executions across the organization. Open a run to inspect its transcript,
output, and task context.
</p>
</div>
)}
<div className="flex flex-wrap items-end gap-3 border-y border-border py-3">
<label className="grid gap-1 text-(length:--text-micro) font-medium text-muted-foreground">
{lockedAgentId ? null : <label className="grid gap-1 text-(length:--text-micro) font-medium text-muted-foreground">
<span>Agent</span>
<Select value={agentId} onValueChange={(value) => updateFilter("agentId", value)}>
<SelectTrigger className="w-48">
@@ -224,7 +237,7 @@ export function AuditRuns({ companyId, routineId }: { companyId: string; routine
))}
</SelectContent>
</Select>
</label>
</label>}
<label className="grid gap-1 text-(length:--text-micro) font-medium text-muted-foreground">
<span>Status</span>
<Select value={status} onValueChange={(value) => updateFilter("runStatus", value)}>
@@ -241,7 +254,7 @@ export function AuditRuns({ companyId, routineId }: { companyId: string; routine
</SelectContent>
</Select>
</label>
{agentId !== ALL || status !== ALL ? (
{(!lockedAgentId && agentId !== ALL) || status !== ALL ? (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Clear filters
</Button>
@@ -60,6 +60,10 @@ const descriptions: Record<AgentLocalDetailView, string> = {
"api-keys": "Manage the keys this agent uses to authenticate with Paperclip.",
revisions:
"Review past configuration changes and restore an earlier version.",
activity: "Everything this agent did and everything done to it.",
runs: "Every execution of this agent, newest first.",
costs: "What this agent spent, by model.",
budgets: "This agent’s monthly budget and how much of it is used.",
};
type Feedback = Parameters<
NonNullable<ComponentProps<typeof AgentConfigForm>["onTestFeedbackChange"]>