feat(web): clickable agent stat icons deep-linking to settings tabs (#186)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-04 19:03:51 +08:00
committed by GitHub
parent 45f916cd1e
commit 67adf93b47
3 changed files with 114 additions and 23 deletions
@@ -10,7 +10,7 @@
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent } from "react";
import { useNavigate, useParams } from "react-router";
import { useNavigate, useParams, useSearchParams } from "react-router";
import type {
AgentConfigResponse,
AgentConfigUpdateRequest,
@@ -72,6 +72,19 @@ function numToStr(n: number | undefined): string {
return n === undefined ? "" : String(n);
}
/**
* Narrow an untrusted `?tab=` query value to a known tab key (exported for unit tests):
* validated against the live TABS keys — not a hardcoded list — so newly added tabs
* deep-link without touching this helper; missing/unknown values fall back to the default.
*/
export function resolveTabKey<K extends string>(
raw: string | null,
tabs: ReadonlyArray<{ key: K }>,
fallback: K,
): K {
return tabs.some((t) => t.key === raw) ? (raw as K) : fallback;
}
export function AgentSettingsPage() {
// Read inside the component: after a language switch remount, this picks up the current dictionary.
const TABS = [
@@ -90,7 +103,27 @@ export function AgentSettingsPage() {
const projectId = currentProject?.projectId ?? null;
const [data, setData] = useState<AgentConfigResponse | null>(null);
const [tab, setTab] = useState<TabKey>("overview");
// ?tab= deep link (from the Agents page's stat icons): a valid key lands the page on that
// tab; missing/unknown values fall back to "overview", exactly the previous behavior.
const [searchParams, setSearchParams] = useSearchParams();
const [tab, setTab] = useState<TabKey>(() =>
resolveTabKey(searchParams.get("tab"), TABS, "overview"),
);
/** Switch tab and mirror it into `?tab=` (replace history entry, keep other params) so the address stays shareable. */
const switchTab = useCallback(
(next: TabKey) => {
setTab(next);
setSearchParams(
(prev) => {
const p = new URLSearchParams(prev);
p.set("tab", next);
return p;
},
{ replace: true },
);
},
[setSearchParams],
);
// Only the initial config load failure renders inline (the page can't show without it); saves/imports report via toast.
const [error, setError] = useState<string | null>(null);
@@ -185,7 +218,7 @@ export function AgentSettingsPage() {
</Button>
<h1 className="mb-1 text-xl font-semibold">{data.config.name ?? agentId}</h1>
<p className="mb-4 font-mono text-xs text-gray-400">{agentId}</p>
<Tabs items={TABS} active={tab} onChange={setTab} />
<Tabs items={TABS} active={tab} onChange={switchTab} />
<div className="py-4">
{tab === "overview" && (
<OverviewTab
@@ -4,7 +4,9 @@
* one horizontal band of "info | 30-day activity sparkline | button group" per row.
* Info column has three lines: title line (small avatar + bold name + agentId); single-line
* truncated description; and a stats line — icon + number only (Session count / tool count) plus
* relative time (today/yesterday/n days ago), with meaning folded into the hover title.
* relative time (today/yesterday/n days ago), with meaning folded into the hover title; the
* tool / vault-key / schedule / skill counts deep-link to the settings page's matching tab
* (?tab=tools|vault|schedules|skills).
* Buttons sit to the right of the sparkline: "New Chat" (draft state, same as sidebar group
* header) and "Settings" (goes to settings page) show text labels; "Usage" / "Traces" (deep link
* via ?agentId= to the usage center / trace observability; traces use an eye line icon =
@@ -63,6 +65,14 @@ const CARD_ICONS = {
traces: "M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z",
} as const;
/**
* Stat entries that deep-link into a settings tab: same look as the plain stat spans
* (no button chrome) plus a subtle hover text-color shift and pointer cursor.
*/
const STAT_LINK_CLASS =
"inline-flex min-w-[2.25rem] shrink-0 cursor-pointer items-center gap-1 tabular-nums " +
"transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200";
export function AgentsPage() {
const navigate = useNavigate();
useDocumentTitle(S.nav.agents);
@@ -132,6 +142,16 @@ export function AgentsPage() {
navigate(`/chat/${DRAFT_SESSION_ID}`, { state: { agentId } });
};
/**
* Stat icon click: same navigation as the "Settings" button plus `?tab=` so the settings
* page lands directly on the matching tab (unknown keys fall back to Overview there, so
* "skills" is harmless until the Skills tab ships).
*/
const openSettingsTab = (agentId: string, tab: "tools" | "vault" | "schedules" | "skills") => {
setCurrentAgentId(agentId);
navigate(`/agents/${agentId}?tab=${tab}`);
};
const doDelete = async () => {
if (!projectId || !deleting) return;
setBusy(true);
@@ -197,7 +217,7 @@ export function AgentsPage() {
(same line as the name); description/stats share the same left edge as the
avatar (the column's left edge) */}
<div className="min-w-[14rem] flex-1">
{/* Title line: small avatar + name + agentId + active badge */}
{/* Title line: small avatar + name + agentId + version badge */}
<div className="flex items-center gap-2">
<AgentAvatar
id={a.agentId}
@@ -213,11 +233,6 @@ export function AgentsPage() {
{a.agentId}
</span>
<Badge tone="gray">v{a.version}</Badge>
{a.activeSessionCount > 0 && (
<Badge tone="brand">
{S.agent.activeSessions} {a.activeSessionCount}
</Badge>
)}
</div>
{/* Description truncated to one line (an empty description still takes up a line, keeping card heights equal) */}
<p className="mt-1.5 min-h-4 truncate text-xs text-gray-500 dark:text-gray-400">
@@ -225,7 +240,10 @@ export function AgentsPage() {
</p>
{/* Stats on their own line: same color/font size as the description; each
reserves a minimum width so they align vertically across cards; meaning
folded into the hover title */}
folded into the hover title. Tool/vault/schedule/skill counts are buttons
deep-linking to the matching settings tab (also for built-in Agents —
their Settings entry point has no gating either); session count and
last-modified stay plain text */}
<div className="mt-1.5 flex items-center gap-x-2.5 text-xs text-gray-500 dark:text-gray-400">
<span
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
@@ -234,34 +252,46 @@ export function AgentsPage() {
<GlyphIcon d={CARD_ICONS.sessions} size={12} />
{a.sessionCount}
</span>
<span
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
<button
type="button"
className={STAT_LINK_CLASS}
title={S.agent.toolCount(a.toolCount)}
aria-label={S.agent.toolCount(a.toolCount)}
onClick={() => openSettingsTab(a.agentId, "tools")}
>
<GlyphIcon d={STAT_ICONS.toolCalls} size={12} />
{a.toolCount}
</span>
<span
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
</button>
<button
type="button"
className={STAT_LINK_CLASS}
title={S.agent.vaultKeyCount(a.vaultKeyCount)}
aria-label={S.agent.vaultKeyCount(a.vaultKeyCount)}
onClick={() => openSettingsTab(a.agentId, "vault")}
>
<GlyphIcon d={CARD_ICONS.vaultKeys} size={12} />
{a.vaultKeyCount}
</span>
<span
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
</button>
<button
type="button"
className={STAT_LINK_CLASS}
title={S.agent.scheduleCount(a.scheduleCount)}
aria-label={S.agent.scheduleCount(a.scheduleCount)}
onClick={() => openSettingsTab(a.agentId, "schedules")}
>
<GlyphIcon d={CARD_ICONS.schedules} size={12} />
{a.scheduleCount}
</span>
<span
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
</button>
<button
type="button"
className={STAT_LINK_CLASS}
title={S.skills.skillCount(a.skillCount)}
aria-label={S.skills.skillCount(a.skillCount)}
onClick={() => openSettingsTab(a.agentId, "skills")}
>
<GlyphIcon d={CARD_ICONS.skills} size={12} />
{a.skillCount}
</span>
</button>
<span
className="inline-flex shrink-0 items-center gap-1"
title={`${S.agent.updatedAt} ${a.updatedAt ? formatDateTime(a.updatedAt) : "—"}`}
@@ -0,0 +1,28 @@
/**
* agent-settings-page.tsx resolveTabKey unit tests: the settings page's `?tab=` deep link
* narrows an untrusted query value against the live TABS keys — a known key passes through,
* anything else (absent, empty, unknown, wrong casing) falls back to the default — pinned so
* the validation stays dynamic and newly added tabs deep-link without a hardcoded key list.
*/
import { describe, expect, it } from "vitest";
import { resolveTabKey } from "../src/features/agents/agent-settings-page";
const TABS = [{ key: "overview" }, { key: "tools" }, { key: "vault" }] as const;
describe("resolveTabKey", () => {
it("passes a known tab key through", () => {
expect(resolveTabKey("tools", TABS, "overview")).toBe("tools");
expect(resolveTabKey("vault", TABS, "overview")).toBe("vault");
});
it("falls back for absent, empty, unknown, or wrong-cased values", () => {
expect(resolveTabKey(null, TABS, "overview")).toBe("overview");
expect(resolveTabKey("", TABS, "overview")).toBe("overview");
expect(resolveTabKey("bogus", TABS, "overview")).toBe("overview");
expect(resolveTabKey("Tools", TABS, "overview")).toBe("overview");
});
it("validates against the supplied keys, not a hardcoded list", () => {
expect(resolveTabKey("skills", [...TABS, { key: "skills" }], "overview")).toBe("skills");
});
});