diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8175d7c..d128a4d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,11 @@ the project uses [semantic versioning](https://semver.org/) once it reaches 1.0;
## [Unreleased]
+### Fixed
+
+- **Link-styled outline buttons had no border.** `buttonVariants()` returned a raw class string, so an outline button built from a link kept both `border-transparent` and the variant's border colour, and the transparent one won. "Back to tasks", **Export CSV**, **Add agent** and similar looked like plain text. They now render as buttons.
+- **Audit trail no longer drops you out of Administration.** It opens inside Administration at `/admin/audit` with the tabs still visible; `/audit` sends administrators there. **Find tasks** no longer shows a redundant "Back to tasks" button, and **Disable** on a team looks like a button.
+
### Changed
- **Console navigation is reorganised by job.** The sidebar groups daily work (**Home**, **Inbox**, **Tasks**, **Chat**), **Agents** (**Agents**, **Find tasks**, **Orchestration**) and **You** (**Notifications**, **Settings**, **Administration** for administrators).
diff --git a/docs/guides/use-the-console.md b/docs/guides/use-the-console.md
index 74a5695..9337cc2 100644
--- a/docs/guides/use-the-console.md
+++ b/docs/guides/use-the-console.md
@@ -295,7 +295,7 @@ The arrows are only a layout. They show references the agent or you set, not an
## Audit
-**Audit** is the organization-wide audit trail. Only administrators see it in the sidebar. A non-admin who opens `/audit` sees "Administrators only".
+**Audit trail** is the organization-wide audit trail. It is the last tab of **Administration** (`/admin/audit`), so the Administration tabs stay in view. An administrator who opens `/audit` is sent there; a non-admin who opens it sees "Administrators only". `/audit?taskId=…` is a single task's trail, reached from **View the full trail** on a task page.
The trail is read-only and newest first, 25 entries at a time with **Show older entries**. Each entry is one sentence ("You approved …", "The agent requested approval: …"), a time, and a link to the task. **Details** expands the exact facts: revision and digest, proposed or sent content, rationale, risk, expiry and delivery result. Note text is never shown, only that a note was added.
@@ -353,7 +353,7 @@ Administrators also see a link to **Administration** at the bottom of the page.
| "Conversation not found" / "This conversation is unavailable in the current organization." | The link is from another organization, or you lost read access | Open the chat from **Recent conversations**, or ask for a grant |
| "Task not found" | The task does not exist or you cannot read it. The server answers 404 rather than 403. | Ask an administrator for a grant |
| Banner ending "Retrying server sync…" | The browser could not load tasks from the server | Check the server is running. The console retries every 5 seconds. |
-| "Administrators only" on **Audit** | You are not an administrator | Open a task and use **View the full trail** |
+| "Administrators only" on **Audit trail** | You are not an administrator | Open a task and use **View the full trail** |
| **Administration** missing from the sidebar, or "Administrators only" | You are not an administrator | Ask an administrator |
| "Too many requests. Try again shortly." | Per-person rate limit | Wait a minute |
diff --git a/src/app/admin/audit/page.tsx b/src/app/admin/audit/page.tsx
new file mode 100644
index 0000000..719132c
--- /dev/null
+++ b/src/app/admin/audit/page.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { AuditView } from "@/components/audit/audit-view";
+
+export default function AdminAuditPage() {
+ return ;
+}
diff --git a/src/app/admin/teams/page.tsx b/src/app/admin/teams/page.tsx
index 3f2007e..7368088 100644
--- a/src/app/admin/teams/page.tsx
+++ b/src/app/admin/teams/page.tsx
@@ -30,7 +30,7 @@ export default function TeamsPage() {
const candidates = view.memberships.filter((member) => !members.some((link) => link.membershipId === member.id));
return (
{ if (window.confirm(`Disable team “${team.name}”? Its grants stop applying.`)) void change({ action: "disableTeam", teamId: team.id }); }}>Disable}>
+ action={}>
{members.length === 0 &&
No members yet.
}
{members.map((link) => {
diff --git a/src/app/audit/page.tsx b/src/app/audit/page.tsx
index 75b4532..c7d6653 100644
--- a/src/app/audit/page.tsx
+++ b/src/app/audit/page.tsx
@@ -1,75 +1,14 @@
"use client";
-import { Suspense, useState } from "react";
-import Link from "next/link";
+import { Suspense } from "react";
import { useSearchParams } from "next/navigation";
-import { Download, ScrollText } from "lucide-react";
-import { AuditTrail } from "@/components/audit/audit-trail";
-import { EmptyState } from "@/components/a2a/primitives";
-import { Page, PageMessage } from "@/components/console/page";
-import { FilterChips } from "@/components/patterns/filter-chips";
-import { Panel } from "@/components/patterns/layout";
-import { buttonVariants } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { auditParams, GROUP_LABEL } from "@/lib/audit";
-import { useServerResource } from "@/lib/use-server-resource";
-import type { AuditGroup } from "@/shared/audit-types";
+import { AuditView } from "@/components/audit/audit-view";
+import { PageMessage } from "@/components/console/page";
-const GROUPS = Object.keys(GROUP_LABEL) as AuditGroup[];
-
-function AuditPage() {
- const taskId = useSearchParams().get("taskId") ?? undefined;
- const [group, setGroup] = useState("all");
- const [since, setSince] = useState("");
- const [until, setUntil] = useState("");
- // The same endpoint answers who may see what: a 403 means this member may only open a single task's trail.
- const probe = useServerResource<{ viewer: { role: string } }>(`/api/audit?${auditParams({ taskId, limit: 1 })}`);
- const admin = probe.data?.viewer.role === "admin";
-
- if (probe.loading) return Loading audit trail…;
- if (!probe.data) {
- return (
- Back to tasks}
- >
- {taskId ? "This task is not available to you." : "The organization-wide audit trail is limited to administrators. Open a task to see its own trail."}
-
- );
- }
- return (
-
- {taskId && Back to task}
- {admin && Export CSV}
- >
- }
- >
-
- admin || name === "all" || name === "approvals" || name === "ownership").map((name) => ({ value: name, label: GROUP_LABEL[name] }))}
- value={[group]}
- onValueChange={(next) => setGroup((next[0] as AuditGroup | undefined) ?? "all")}
- />
-
-
setSince(event.target.value)} />
-
setUntil(event.target.value)} />
-
-
-
-
-
-
- );
+export default function AuditRoute() {
+ return Loading audit trail…}>;
}
-export default function AuditRoute() {
- return Loading audit trail…}>;
+function AuditRouteBody() {
+ return ;
}
diff --git a/src/app/discover/page.tsx b/src/app/discover/page.tsx
index b97f2a5..f8ff875 100644
--- a/src/app/discover/page.tsx
+++ b/src/app/discover/page.tsx
@@ -45,7 +45,6 @@ function Find() {
size="wide"
title="Find tasks"
description="Read the task list straight from an agent to find work another system started. Nothing is stored until you track a task, and then it joins your Tasks and Inbox."
- actions={Back to tasks}
>
{loading ? Loading agents… : reachable.length === 0 ? (
Go to agents}>
diff --git a/src/components/admin/admin-nav.test.tsx b/src/components/admin/admin-nav.test.tsx
index 7111261..5480bff 100644
--- a/src/components/admin/admin-nav.test.tsx
+++ b/src/components/admin/admin-nav.test.tsx
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
import { ADMIN_LINKS } from "./admin-nav";
describe("Admin sub-navigation", () => {
- it("has one tab per job, starting at the overview, and ends at the audit trail", () => {
- expect(ADMIN_LINKS.map((link) => link.href)).toEqual(["/admin", "/admin/people", "/admin/teams", "/admin/access", "/admin/policies", "/audit"]);
+ it("has one tab per job, starting at the overview, and ends at the audit trail inside Administration", () => {
+ expect(ADMIN_LINKS.map((link) => link.href)).toEqual(["/admin", "/admin/people", "/admin/teams", "/admin/access", "/admin/policies", "/admin/audit"]);
expect(ADMIN_LINKS[0].exact).toBe(true);
});
});
diff --git a/src/components/admin/admin-nav.tsx b/src/components/admin/admin-nav.tsx
index 73df86b..9b9c716 100644
--- a/src/components/admin/admin-nav.tsx
+++ b/src/components/admin/admin-nav.tsx
@@ -14,7 +14,7 @@ export const ADMIN_LINKS: AdminLink[] = [
{ href: "/admin/teams" as Route, label: "Teams", icon: UsersRound },
{ href: "/admin/access" as Route, label: "Agent access", icon: KeyRound },
{ href: "/admin/policies" as Route, label: "Policies & alerts", icon: Bell },
- { href: "/audit" as Route, label: "Audit trail", icon: ScrollText },
+ { href: "/admin/audit" as Route, label: "Audit trail", icon: ScrollText },
];
/** The sub-navigation of the Admin area: one tab per job, scrolling sideways on a phone. */
diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx
index 8d24830..5d85bee 100644
--- a/src/components/app-shell.tsx
+++ b/src/components/app-shell.tsx
@@ -63,7 +63,6 @@ const PRIMARY_TABS = new Set(["/", "/chat", "/inbox", "/tasks"]);
function useActive(nav: NavItem[]) {
const pathname = usePathname() ?? "/";
// The standalone approvals queue now lives in the inbox, so its review pages keep the Inbox item active.
- if (pathname === "/audit" || pathname.startsWith("/audit/")) return nav.find((item) => item.href === "/admin") ?? undefined;
const path = pathname === "/approvals" || pathname.startsWith("/approvals/") ? pathname.replace("/approvals", "/inbox") : pathname;
return nav.find((item) => (item.href === "/" ? path === "/" : path === item.href || path.startsWith(`${item.href}/`)));
}
diff --git a/src/components/audit/audit-view.tsx b/src/components/audit/audit-view.tsx
new file mode 100644
index 0000000..add8cfb
--- /dev/null
+++ b/src/components/audit/audit-view.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import type { Route } from "next";
+import { useRouter } from "next/navigation";
+import { Download, ScrollText } from "lucide-react";
+import { AuditTrail } from "@/components/audit/audit-trail";
+import { EmptyState } from "@/components/a2a/primitives";
+import { Page, PageMessage } from "@/components/console/page";
+import { FilterChips } from "@/components/patterns/filter-chips";
+import { Panel } from "@/components/patterns/layout";
+import { buttonVariants } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { auditParams, GROUP_LABEL } from "@/lib/audit";
+import { useServerResource } from "@/lib/use-server-resource";
+import type { AuditGroup } from "@/shared/audit-types";
+
+const GROUPS = Object.keys(GROUP_LABEL) as AuditGroup[];
+
+/**
+ * The audit trail. With a `taskId` it is that task's trail, readable by anyone who can read the task. Without one it is the
+ * organization-wide trail, which lives under Administration; an administrator who lands on `/audit` is sent there so the
+ * Administration navigation stays around the page.
+ */
+export function AuditView({ taskId, embedded = false }: { taskId?: string; embedded?: boolean }) {
+ const router = useRouter();
+ const [group, setGroup] = useState("all");
+ const [since, setSince] = useState("");
+ const [until, setUntil] = useState("");
+ // The same endpoint answers who may see what: a 403 means this member may only open a single task's trail.
+ const probe = useServerResource<{ viewer: { role: string } }>(`/api/audit?${auditParams({ taskId, limit: 1 })}`);
+ const admin = probe.data?.viewer.role === "admin";
+ useEffect(() => { if (!embedded && !taskId && admin) router.replace("/admin/audit" as Route); }, [embedded, taskId, admin, router]);
+
+ if (probe.loading) return Loading audit trail…;
+ if (!probe.data) {
+ return (
+ Back to tasks}
+ >
+ {taskId ? "This task is not available to you." : "The organization-wide audit trail is limited to administrators. Open a task to see its own trail."}
+
+ );
+ }
+ return (
+
+ {taskId && Back to task}
+ {admin && Export CSV}
+ >
+ }
+ >
+
+ admin || name === "all" || name === "approvals" || name === "ownership").map((name) => ({ value: name, label: GROUP_LABEL[name] }))}
+ value={[group]}
+ onValueChange={(next) => setGroup((next[0] as AuditGroup | undefined) ?? "all")}
+ />
+
+
setSince(event.target.value)} />
+
setUntil(event.target.value)} />
+
+
+
+
+
+
+ );
+}
+
diff --git a/src/components/ui/button.test.ts b/src/components/ui/button.test.ts
new file mode 100644
index 0000000..fce3ba6
--- /dev/null
+++ b/src/components/ui/button.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { buttonVariants } from "./button";
+
+describe("buttonVariants", () => {
+ // Links styled as buttons use this string directly, with no component to merge classes for them.
+ it("lets a variant's border colour replace the base transparent border", () => {
+ const classes = buttonVariants({ variant: "outline" }).split(" ");
+ expect(classes).toContain("border-border-strong");
+ expect(classes).not.toContain("border-transparent");
+ });
+
+ it("keeps the transparent border for variants that have no border of their own", () => {
+ expect(buttonVariants({ variant: "default" }).split(" ")).toContain("border-transparent");
+ });
+
+ it("lets a caller's class win over the variant's", () => {
+ expect(buttonVariants({ variant: "outline", className: "mt-3 h-10" }).split(" ")).toEqual(expect.arrayContaining(["mt-3", "h-10"]));
+ expect(buttonVariants({ size: "sm", className: "h-10" }).split(" ")).not.toContain("h-7");
+ });
+});
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index 13d42ca..52a47d5 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -2,7 +2,7 @@ import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
-const buttonVariants = cva(
+const buttonStyles = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
@@ -39,16 +39,23 @@ const buttonVariants = cva(
}
)
+/**
+ * The class string for a button. Merged with `cn` so a variant's own border colour replaces the base `border-transparent`; a raw
+ * `cva` string keeps both and the transparent one wins, which turns outline buttons built from links into plain text.
+ */
+type ButtonStyleProps = VariantProps
+const buttonVariants = (props?: Parameters[0]) => cn(buttonStyles(props))
+
function Button({
className,
variant = "default",
size = "default",
...props
-}: ButtonPrimitive.Props & VariantProps) {
+}: ButtonPrimitive.Props & ButtonStyleProps) {
return (
)