This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit d40d80ab09938a85f768a8fe2ed125a37dd17d0a
Author: Yang Luo <[email protected]>
AuthorDate: Sun Sep 6 01:34:45 2026 +0800

    feat(web): cut the home screen down to a summary strip and slim cards
---
 web/src/components/AgentCatalog.tsx         |  23 +-
 web/src/components/AgentGridCard.tsx        | 316 ++++++++--------------------
 web/src/components/AuthenticityOverview.tsx |  80 ++++---
 web/src/components/CcSwitchBanner.tsx       |  64 +++---
 web/src/components/HomeSummary.tsx          | 170 +++++++++++++++
 web/src/components/OnboardingChecklist.tsx  |  10 -
 web/src/components/shared/fold.tsx          |  61 ++++++
 web/src/lib/agents.ts                       |  49 ++++-
 web/src/locales/en/data.json                |   9 +-
 web/src/locales/zh/data.json                |   9 +-
 web/src/pages/AgentDetailPage.tsx           |  34 ++-
 web/src/pages/HomePage.tsx                  | 198 ++++++++++-------
 12 files changed, 623 insertions(+), 400 deletions(-)

diff --git a/web/src/components/AgentCatalog.tsx 
b/web/src/components/AgentCatalog.tsx
index e699a4d..8f17055 100644
--- a/web/src/components/AgentCatalog.tsx
+++ b/web/src/components/AgentCatalog.tsx
@@ -14,6 +14,7 @@
 
 import i18next from "i18next";
 
+import {Fold} from "@/components/shared/fold";
 import {ToolInstallRow} from "@/components/ToolInstallRow";
 import {useAgentCatalog, type useAgentInstall} from "@/lib/agents";
 import type {Agent} from "@/types";
@@ -21,16 +22,21 @@ import type {Agent} from "@/types";
 /**
  * The agents Gateway supports that this machine has none of, each with the
  * click that installs it here. Without them a fresh machine says only that
- * nothing was found, which is the one thing it cannot act on.
+ * nothing was found, which is the one thing it cannot act on - but on a 
machine
+ * that already has agents it is a dozen cards about software nobody asked for,
+ * so it folds down to the line that names it.
  */
 export function AgentCatalog({
   agents,
   enabled = true,
+  defaultOpen = true,
   installer,
   onLocated,
 }: {
   agents: Agent[];
   enabled?: boolean;
+  /** False where the page has more to say than this, which is the home 
screen. */
+  defaultOpen?: boolean;
   /** Called once a program is picked, which is when a rescan drops its row. */
   onLocated?: () => void;
   /**
@@ -48,12 +54,13 @@ export function AgentCatalog({
   }
 
   return (
-    <section className="space-y-3">
-      <div>
-        <h2 className="text-sm font-medium">{i18next.t("agent:Not 
installed")}</h2>
-        <p className="text-muted-foreground text-xs">{i18next.t("agent:Not 
installed hint")}</p>
-      </div>
-
+    <Fold
+      defaultOpen={defaultOpen}
+      title={i18next
+        .t("agent:{count} agents not installed")
+        .replace("{count}", String(missing.length))}
+      description={i18next.t("agent:Not installed hint")}
+    >
       <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 
xl:grid-cols-4">
         {missing.map(item => (
           <ToolInstallRow
@@ -66,6 +73,6 @@ export function AgentCatalog({
           />
         ))}
       </div>
-    </section>
+    </Fold>
   );
 }
diff --git a/web/src/components/AgentGridCard.tsx 
b/web/src/components/AgentGridCard.tsx
index d67080e..670e091 100644
--- a/web/src/components/AgentGridCard.tsx
+++ b/web/src/components/AgentGridCard.tsx
@@ -12,22 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import * as React from "react";
 import {Link} from "react-router-dom";
-import {Bot, ChevronRight, UserRound} from "lucide-react";
+import {Bot, ChevronRight} from "lucide-react";
 import i18next from "i18next";
 
 import {AgentIcon} from "@/components/AgentIcon";
-import {AgentCardAccounts} from "@/components/AgentAccounts";
 import {AgentCardInstances} from "@/components/AgentInstances";
-import {AgentUsageTrend, hasUsageTrend} from "@/components/AgentUsageTrend";
-import {AgentUpdateBadge} from "@/components/AgentVersionDialog";
 import {RunButton, RunDot} from "@/components/AgentRunControl";
 import {ProviderIcon} from "@/components/ProviderIcon";
-import {QuotaBadge} from "@/components/ProviderQuota";
 import {ConfirmDialog} from "@/components/shared/confirm-dialog";
 import {Card} from "@/components/ui/card";
-import {Progress} from "@/components/ui/progress";
 import {
   Select,
   SelectContent,
@@ -42,133 +36,35 @@ import {
   agentCanUse,
   agentDetailPath,
   builtinProvider,
-  directMode,
-  type AgentAccountControls,
   type AgentInstanceControls,
+  type AgentSpend,
 } from "@/lib/agents";
 import {providerIdOf, providerProtocol} from "@/lib/providers";
-import {formatCost, formatTokens} from "@/lib/usage";
-import type {
-  Agent,
-  AgentAccount,
-  AgentRuntime,
-  AgentUpdate,
-  AgentUsageStat,
-  LlmAgentStat,
-  Provider,
-  ProviderHealth,
-  ProviderQuota,
-} from "@/types";
-
-/** A hairline across the whole card, drawn out of a section that is padded. */
-function Divider() {
-  return <div className="border-border -mx-4 border-t" />;
-}
-
-/** One labelled line of the card, with the value pinned right. */
-function Row({label, children}: {label: React.ReactNode; children: 
React.ReactNode}) {
-  return (
-    <div className="flex min-w-0 items-center justify-between gap-2">
-      <span className="text-muted-foreground shrink-0 
text-[11px]">{label}</span>
-      {children}
-    </div>
-  );
-}
-
-/** One number of the card, and what it is. */
-function Metric({
-  label,
-  value,
-  hint,
-}: {
-  label: React.ReactNode;
-  value: React.ReactNode;
-  hint?: React.ReactNode;
-}) {
-  const shown = <span className="block truncate text-[13px] font-medium 
tabular-nums">{value}</span>;
-
-  return (
-    <div className="min-w-0">
-      <span className="text-muted-foreground block truncate 
text-[11px]">{label}</span>
-      {hint ? (
-        <SimpleTooltip title={hint}>
-          <span className="block min-w-0">{shown}</span>
-        </SimpleTooltip>
-      ) : (
-        shown
-      )}
-    </div>
-  );
-}
+import {formatCost} from "@/lib/usage";
+import type {Agent, AgentAccount, AgentRuntime, AgentUpdate, Provider, 
ProviderHealth} from "@/types";
 
 /** The label for a signed-in account: its name, or its email when unnamed. */
 export function accountLabel(account: AgentAccount) {
   return account.name || account.email || "";
 }
 
-/** The signed-in account of an agent, shown under its path. */
-function AccountLine({account}: {account?: AgentAccount}) {
-  const label = account ? accountLabel(account) : "";
-  if (!label) {
-    return null;
-  }
-  const title = account?.email && account.email !== label ? `${label} · 
${account.email}` : label;
-
-  return (
-    <SimpleTooltip title={title}>
-      <p className="text-muted-foreground flex items-center gap-1 truncate 
text-[11px]">
-        <UserRound className="size-3 shrink-0" />
-        <span className="truncate">{label}</span>
-      </p>
-    </SimpleTooltip>
-  );
-}
-
-/** What the vendor of the bound provider says is left on the plan. */
-function PlanUsage({quota}: {quota?: ProviderQuota}) {
-  // A provider nobody can read a balance from has nothing to show here.
-  if (!quota || (!quota.supported && quota.error === "")) {
-    return null;
-  }
-
-  const percent =
-    quota.total !== null && quota.total > 0 && quota.used !== null
-      ? Math.min(100, (quota.used / quota.total) * 100)
-      : null;
-
-  return (
-    <div className="space-y-1.5">
-      <Row label={i18next.t("provider:Plan usage")}>
-        <QuotaBadge quota={quota} />
-      </Row>
-      {percent === null ? null : (
-        <Progress
-          value={percent}
-          tone={percent >= 90 ? "danger" : percent >= 75 ? "warning" : 
"default"}
-          className="h-1"
-        />
-      )}
-    </div>
-  );
-}
-
 /**
  * One agent installed on this machine, as the card the home page is built out
- * of: what it is on now, what it has spent there, and the controls that change
- * both. Picking another provider is the one click the page exists for.
+ * of. It carries three things and no more: which agent it is, what it has 
spent,
+ * and the provider box that switches it over - the one click the page exists
+ * for. Its account, its instances, its plan balance and its configuration are 
a
+ * click deeper, on the page named at the bottom right, because eighteen cards
+ * carrying all of that is a page nobody reads.
  */
 export function AgentGridCard({
   agent,
   agents,
   providers,
   health,
-  quota,
-  stats,
-  usage,
+  spend,
   status,
   update,
   instances,
-  accounts,
   recording,
   busy,
   runBusy,
@@ -182,20 +78,13 @@ export function AgentGridCard({
   agents: Agent[];
   providers: Provider[];
   health: ProviderHealth[];
-  quota?: ProviderQuota;
-  stats?: LlmAgentStat;
-  /**
-   * What this agent's own transcripts say it spent, which counts the requests
-   * it made straight to its vendor as well as the ones Gateway relayed.
-   */
-  usage?: AgentUsageStat;
+  /** What this agent spent, from whichever of the two accounts has it. */
+  spend: AgentSpend;
   status?: AgentRuntime;
   /** The release check for this installation, absent until it lands. */
   update?: AgentUpdate;
   /** The extra copies of this agent, for the agents that can run more than 
one. */
   instances?: AgentInstanceControls;
-  /** The sign-ins kept for this agent, for the agents that hold more than 
one. */
-  accounts?: AgentAccountControls;
   /** False while llmRecordMode is off, when a zero would be a lie. */
   recording: boolean;
   busy: boolean;
@@ -206,7 +95,6 @@ export function AgentGridCard({
   onToggleRunning: (agent: Agent, running: boolean) => void;
   onTogglePatch: () => void;
 }) {
-  const bound = providers.find(provider => providerIdOf(provider) === 
agent.provider);
   const boundHealth = health.find(item => item.provider === agent.provider);
   // An agent bound directly reads the provider's own answers, so only the ones
   // it can speak to are offered; through the gateway they all are. Whatever it
@@ -215,25 +103,25 @@ export function AgentGridCard({
     provider => agentCanUse(agent, provider) || providerIdOf(provider) === 
agent.provider,
   );
   const patchAction = i18next.t(`agent:${agent.patched ? "Unpatch" : 
"Patch"}`);
-  const dash = <span className="text-muted-foreground">-</span>;
-  const offHint = recording ? undefined : i18next.t("llm:Recording is off");
-  // An agent's own transcript accounts for every request it made, the ones 
that
-  // never came near Gateway included, so it is the fuller of the two and wins
-  // wherever it has anything to say. The relayed totals answer for the agents
-  // that keep no transcript, and for the failures a transcript cannot show.
-  const spent = usage && usage.requests > 0 ? usage : undefined;
-  const sourceHint = spent ? i18next.t("agent:Read from the agent's own logs") 
: offHint;
+  const detail = agentDetailPath(agent, agents);
+
   // Off is only the truth about the relayed totals; a transcript is written
   // whether or not Gateway is recording anything.
-  const counted = spent !== undefined || recording;
+  const counted = spend.own !== undefined || recording;
+  const sourceHint = spend.own
+    ? i18next.t("agent:Read from the agent's own logs")
+    : recording
+      ? undefined
+      : i18next.t("llm:Recording is off");
   const costHint =
-    spent && spent.unpriced > 0
+    spend.own && spend.own.unpriced > 0
       ? i18next
         .t("llm:{count} of these requests have no list price")
-        .replace("{count}", spent.unpriced.toLocaleString())
+        .replace("{count}", spend.own.unpriced.toLocaleString())
       : sourceHint;
-  const lastModel = spent?.lastModel || stats?.lastModel;
-  const lastTime = spent?.lastTime || stats?.lastTime;
+  const subtitle = [agent.version || i18next.t("agent:Unknown"), agent.account 
? accountLabel(agent.account) : ""]
+    .filter(Boolean)
+    .join(" · ");
 
   return (
     <Card className="hover:border-foreground/25 gap-0 overflow-hidden py-0 
shadow-xs transition-colors">
@@ -245,45 +133,62 @@ export function AgentGridCard({
             fallback={<Bot className="text-muted-foreground size-5" />}
           />
           <div className="min-w-0 flex-1">
-            <Link
-              to={agentDetailPath(agent, agents)}
-              className="block truncate text-sm font-medium hover:underline"
-            >
-              {agent.name}
-            </Link>
+            <div className="flex min-w-0 items-center gap-1.5">
+              <Link to={detail} className="truncate text-sm font-medium 
hover:underline">
+                {agent.name}
+              </Link>
+              {/* A waiting release is a dot rather than a line of its own: it 
is
+                  worth noticing and not worth a sentence on every card. */}
+              {update?.available ? (
+                <SimpleTooltip title={`${i18next.t("agent:New version")} 
${update.latest}`}>
+                  <span className="bg-warning size-1.5 shrink-0 rounded-full" 
/>
+                </SimpleTooltip>
+              ) : null}
+            </div>
             <SimpleTooltip title={agent.path}>
-              <p className="text-muted-foreground/80 truncate font-mono 
text-[11px]">
-                <span className="tabular-nums">{agent.version || 
i18next.t("agent:Unknown")}</span>
-                <span className="mx-1">·</span>
-                {agent.path}
-              </p>
+              <p className="text-muted-foreground/80 truncate 
text-[11px]">{subtitle}</p>
             </SimpleTooltip>
-            <AgentUpdateBadge update={update} className="mt-1 text-[11px]" />
-            <AccountLine account={agent.account} />
           </div>
           <RunDot status={status} />
         </div>
 
-        <Divider />
-
-        <div className="space-y-1.5">
-          <Row label={i18next.t("agent:Provider")}>
-            <span className="flex min-w-0 items-center gap-2">
-              {boundHealth && !boundHealth.healthy ? (
-                <SimpleTooltip title={boundHealth.lastError}>
-                  <span className="text-warning truncate text-[11px]">
-                    {i18next.t("agent:Cooling down")}
-                  </span>
-                </SimpleTooltip>
-              ) : null}
-              {bound === undefined ? null : (
+        {/* The one number. An agent nobody has run says so in words: a zero
+            there reads as free rather than as unused. */}
+        <SimpleTooltip title={costHint}>
+          <div className="flex min-w-0 flex-col gap-0.5">
+            {counted && spend.requests > 0 ? (
+              <>
+                <span className="text-lg leading-none font-semibold 
tabular-nums">
+                  {formatCost(spend.cost)}
+                </span>
+                <span className="text-muted-foreground min-w-0 truncate 
text-[11px] tabular-nums">
+                  {i18next.t("llm:{count} requests").replace("{count}", 
spend.requests.toLocaleString())}
+                  {spend.lastModel ? <span className="font-mono"> · 
{spend.lastModel}</span> : null}
+                </span>
+              </>
+            ) : (
+              <>
+                <span className="text-muted-foreground text-lg leading-none 
font-semibold">-</span>
                 <span className="text-muted-foreground truncate text-[11px]">
-                  {i18next.t(agent.mode === directMode ? "agent:Direct" : 
"agent:Gateway")}
+                  {i18next.t(counted ? "agent:Never used" : "llm:Recording is 
off")}
                 </span>
-              )}
+              </>
+            )}
+          </div>
+        </SimpleTooltip>
+
+        {boundHealth && !boundHealth.healthy ? (
+          <SimpleTooltip title={boundHealth.lastError}>
+            <span className="text-warning -mb-1 truncate text-[11px]">
+              {i18next.t("agent:Cooling down")}
             </span>
-          </Row>
+          </SimpleTooltip>
+        ) : null}
 
+        {/* The bottom block of the card: where it points, and the copies that
+            point there. Held together so the boxes line up across a row of
+            cards whose spend lines are different heights. */}
+        <div className="mt-auto flex flex-col gap-2">
           <Select
             value={agent.provider === "" ? builtinProvider : agent.provider}
             disabled={busy}
@@ -296,9 +201,7 @@ export function AgentGridCard({
               <SelectItem value={builtinProvider}>
                 <AgentIcon agent={agent.agentId || agent.name} size={16} 
fallback={<Bot className="size-4" />} />
                 {agentBuiltin(agent)}
-                <span className="text-muted-foreground ml-2 text-xs">
-                  {i18next.t("agent:Built-in")}
-                </span>
+                <span className="text-muted-foreground ml-2 
text-xs">{i18next.t("agent:Built-in")}</span>
               </SelectItem>
               {options.map(provider => (
                 <SelectItem key={providerIdOf(provider)} 
value={providerIdOf(provider)}>
@@ -312,64 +215,11 @@ export function AgentGridCard({
             </SelectContent>
           </Select>
 
-          {accounts ? <AgentCardAccounts agent={agent} controls={accounts} /> 
: null}
-
-          <Row label={i18next.t("agent:Model")}>
-            {lastModel ? (
-              <SimpleTooltip
-                title={`${i18next.t("agent:Last activity")}: ${new 
Date(lastTime ?? "").toLocaleString()}`}
-              >
-                <span className="min-w-0 truncate font-mono 
text-[11px]">{lastModel}</span>
-              </SimpleTooltip>
-            ) : (
-              <span className="text-muted-foreground text-[11px]">-</span>
-            )}
-          </Row>
-        </div>
-
-        <Divider />
-
-        <div className="space-y-3">
-          {/* The shape of the spend wherever the transcripts carry a history,
-              and the bare totals for the agents whose only account is what
-              Gateway relayed - that source keeps no per-day breakdown. */}
-          {spent && hasUsageTrend(spent) ? (
-            <AgentUsageTrend
-              stat={spent}
-              hints={{tokens: sourceHint, requests: sourceHint, cost: 
costHint}}
-            />
-          ) : (
-            <div className="grid grid-cols-3 gap-1.5">
-              <Metric
-                label={i18next.t("llm:Requests")}
-                value={counted ? ((spent ?? stats)?.requests ?? 
0).toLocaleString() : dash}
-                hint={
-                  sourceHint ??
-                  (stats && stats.failed > 0
-                    ? `${stats.failed.toLocaleString()} 
${i18next.t("llm:failed")}`
-                    : undefined)
-                }
-              />
-              <Metric
-                label={i18next.t("llm:Tokens")}
-                value={counted ? formatTokens(spent?.totalTokens ?? 
stats?.tokens ?? 0) : dash}
-                hint={sourceHint}
-              />
-              <Metric
-                label={i18next.t("llm:Cost")}
-                value={counted ? formatCost(spent?.cost ?? stats?.cost ?? 0) : 
dash}
-                hint={costHint}
-              />
-            </div>
-          )}
-
-          <PlanUsage quota={quota} />
-
           {instances ? <AgentCardInstances agent={agent} controls={instances} 
/> : null}
         </div>
       </div>
 
-      <div className="bg-muted/40 mt-auto flex flex-wrap items-center gap-x-2 
gap-y-1 border-t px-3 py-2">
+      <div className="bg-muted/40 mt-auto flex items-center gap-2 border-t 
px-3 py-1.5">
         <RunButton
           agent={agent}
           status={status}
@@ -379,8 +229,8 @@ export function AgentGridCard({
           onToggle={onToggleRunning}
         />
 
-        <label className="text-muted-foreground flex items-center gap-1.5 
text-[11px]">
-          {i18next.t("agent:Monitored")}
+        <label className="text-muted-foreground flex min-w-0 items-center 
gap-1.5 text-[11px]">
+          <span className="truncate">{i18next.t("agent:Monitored")}</span>
           {agent.supported ? (
             <ConfirmDialog
               title={`${patchAction} ${agent.name}?`}
@@ -407,13 +257,15 @@ export function AgentGridCard({
           )}
         </label>
 
-        <Link
-          to={agentDetailPath(agent, agents)}
-          className="text-muted-foreground hover:text-foreground ml-auto 
inline-flex items-center text-xs"
-        >
-          {i18next.t("agent:Details")}
-          <ChevronRight className="size-3.5" />
-        </Link>
+        <SimpleTooltip title={i18next.t("agent:Details")}>
+          <Link
+            to={detail}
+            aria-label={i18next.t("agent:Details")}
+            className="text-muted-foreground hover:text-foreground ml-auto 
inline-flex shrink-0 items-center"
+          >
+            <ChevronRight className="size-4" />
+          </Link>
+        </SimpleTooltip>
       </div>
     </Card>
   );
diff --git a/web/src/components/AuthenticityOverview.tsx 
b/web/src/components/AuthenticityOverview.tsx
index 7098304..c1211ef 100644
--- a/web/src/components/AuthenticityOverview.tsx
+++ b/web/src/components/AuthenticityOverview.tsx
@@ -108,24 +108,9 @@ function ProviderScore({
   );
 }
 
-/**
- * Whether the APIs on this machine are what they are sold as, in one card. It
- * is measured without being asked for: every provider is probed when it is
- * added and again once its report goes stale, so this says something before
- * anyone thinks to check.
- */
-export function AuthenticityOverview({
-  providers,
-  showLink = true,
-  className,
-}: {
-  providers: Provider[];
-  /** False on the page this links to, where the link would point at itself. */
-  showLink?: boolean;
-  className?: string;
-}) {
+/** The scores as they stand, re-read while the page is open. */
+export function useProviderProbes() {
   const [probes, setProbes] = React.useState<ProviderProbe[]>([]);
-  const [cases, setCases] = React.useState<ProbeCase[]>([]);
   const [mode, setMode] = React.useState<ProviderProbeMode>("auto");
   const [loaded, setLoaded] = React.useState(false);
 
@@ -148,14 +133,15 @@ export function AuthenticityOverview({
     return () => clearInterval(interval);
   }, []);
 
-  // Read once: the suite names the cases a row failed, and it does not change
-  // while a page is open.
-  React.useEffect(() => {
-    ProbeCaseBackend.getProbeCases()
-      .then(res => setCases(res.status === "ok" ? (res.data ?? []) : []))
-      .catch(() => undefined);
-  }, []);
+  return {probes: probes, mode: mode, loaded: loaded};
+}
 
+/**
+ * The scores of the providers in use, worst first, and what they add up to. 
The
+ * headline grade is the worst one held rather than the average: a machine with
+ * four honest providers and one that is not is not four fifths fine.
+ */
+export function authenticityOf(providers: Provider[], probes: ProviderProbe[]) 
{
   const configured = providers.filter(provider => provider.status !== 
"disabled");
   const byProvider = new Map(probes.map(probe => [probe.provider, probe]));
   const scored = configured
@@ -166,13 +152,45 @@ export function AuthenticityOverview({
       return score(left) - score(right);
     });
 
-  const summary = summarizeAuthenticity(
-    scored.filter(entry => entry.probe).map(entry => entry.probe as 
ProviderProbe),
-    configured.length,
-  );
-  // The headline grade is the worst one held, not the average: a machine with
-  // four honest providers and one that is not is not four fifths fine.
-  const worst = scored.find(entry => entry.probe?.ok && entry.probe.grade !== 
"unknown")?.probe;
+  return {
+    configured: configured,
+    scored: scored,
+    summary: summarizeAuthenticity(
+      scored.filter(entry => entry.probe).map(entry => entry.probe as 
ProviderProbe),
+      configured.length,
+    ),
+    worst: scored.find(entry => entry.probe?.ok && entry.probe.grade !== 
"unknown")?.probe,
+  };
+}
+
+/**
+ * Whether the APIs on this machine are what they are sold as, in one card. It
+ * is measured without being asked for: every provider is probed when it is
+ * added and again once its report goes stale, so this says something before
+ * anyone thinks to check.
+ */
+export function AuthenticityOverview({
+  providers,
+  showLink = true,
+  className,
+}: {
+  providers: Provider[];
+  /** False on the page this links to, where the link would point at itself. */
+  showLink?: boolean;
+  className?: string;
+}) {
+  const [cases, setCases] = React.useState<ProbeCase[]>([]);
+  const {probes, mode, loaded} = useProviderProbes();
+
+  // Read once: the suite names the cases a row failed, and it does not change
+  // while a page is open.
+  React.useEffect(() => {
+    ProbeCaseBackend.getProbeCases()
+      .then(res => setCases(res.status === "ok" ? (res.data ?? []) : []))
+      .catch(() => undefined);
+  }, []);
+
+  const {configured, scored, summary, worst} = authenticityOf(providers, 
probes);
   const style = gradeStyleOf(worst?.grade);
 
   if (configured.length === 0) {
diff --git a/web/src/components/CcSwitchBanner.tsx 
b/web/src/components/CcSwitchBanner.tsx
index 9dc2e72..e527f8d 100644
--- a/web/src/components/CcSwitchBanner.tsx
+++ b/web/src/components/CcSwitchBanner.tsx
@@ -14,11 +14,10 @@
 
 import * as React from "react";
 import {Link} from "react-router-dom";
-import {ArrowDownToLine} from "lucide-react";
+import {ArrowDownToLine, X} from "lucide-react";
 import i18next from "i18next";
 
 import * as CcSwitchBackend from "@/backend/CcSwitchBackend";
-import {MessageAlert} from "@/components/ui/alert";
 import {Button} from "@/components/ui/button";
 import type {CcSwitchImport} from "@/types";
 
@@ -90,40 +89,33 @@ export function CcSwitchBanner() {
     return null;
   }
 
+  // One row rather than a titled alert: the button says what happens, so the
+  // sentence that used to say it as well was a line of reading for nothing.
   return (
-    <MessageAlert
-      variant="info"
-      title={i18next.t("link:CC Switch is installed here")}
-      // Two lines rather than one sentence: the counts read as a list, and
-      // joining them onto the hint would need a separator that is a space in
-      // English and nothing at all in Chinese.
-      description={
-        <>
-          <span className="block">{i18next.t("link:CC Switch is installed here 
hint")}</span>
-          <span className="block font-medium">{summary(found)}</span>
-        </>
-      }
-      action={
-        <div className="flex flex-wrap gap-2">
-          <Button asChild size="sm">
-            <Link to="/import">
-              <ArrowDownToLine />
-              {i18next.t("provider:Bring it all over")}
-            </Link>
-          </Button>
-          <Button
-            type="button"
-            size="sm"
-            variant="ghost"
-            onClick={() => {
-              writeDismissed();
-              setDismissed(true);
-            }}
-          >
-            {i18next.t("general:Dismiss")}
-          </Button>
-        </div>
-      }
-    />
+    <div className="bg-card flex flex-wrap items-center gap-x-3 gap-y-2 
rounded-lg border px-4 py-2.5">
+      <ArrowDownToLine className="text-info size-4 shrink-0" />
+      <span className="min-w-0 truncate text-sm font-medium">
+        {i18next.t("link:CC Switch is installed here")}
+      </span>
+      <span className="text-muted-foreground min-w-0 truncate 
text-xs">{summary(found)}</span>
+
+      <div className="ml-auto flex shrink-0 items-center gap-1">
+        <Button asChild size="sm">
+          <Link to="/import">{i18next.t("provider:Bring it all over")}</Link>
+        </Button>
+        <Button
+          type="button"
+          size="icon-sm"
+          variant="ghost"
+          aria-label={i18next.t("general:Dismiss")}
+          onClick={() => {
+            writeDismissed();
+            setDismissed(true);
+          }}
+        >
+          <X />
+        </Button>
+      </div>
+    </div>
   );
 }
diff --git a/web/src/components/HomeSummary.tsx 
b/web/src/components/HomeSummary.tsx
new file mode 100644
index 0000000..00f81ab
--- /dev/null
+++ b/web/src/components/HomeSummary.tsx
@@ -0,0 +1,170 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {Link} from "react-router-dom";
+import {ChevronRight} from "lucide-react";
+import i18next from "i18next";
+
+import {authenticityOf, useProviderProbes} from 
"@/components/AuthenticityOverview";
+import {Card} from "@/components/ui/card";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {formatScore, gradeLetter, gradeStyleOf} from "@/lib/authenticity";
+import {cn} from "@/lib/utils";
+import {formatCost} from "@/lib/usage";
+import type {AgentSpend} from "@/lib/agents";
+import type {Provider} from "@/types";
+
+/** Fills the {name} placeholders these strings carry. */
+function fill(key: string, values: Record<string, string | number>) {
+  return Object.entries(values).reduce(
+    (text, [name, value]) => text.split(`{${name}}`).join(String(value)),
+    i18next.t(key),
+  );
+}
+
+interface Cell {
+  key: string;
+  label: React.ReactNode;
+  value: React.ReactNode;
+  note?: React.ReactNode;
+  tone?: string;
+  to?: string;
+  hint?: React.ReactNode;
+}
+
+/** One figure of the strip: what it reads, then what it counts. */
+function Figure({cell, className}: {cell: Cell; className?: string}) {
+  const body = (
+    <>
+      <span
+        className={cn(
+          "flex items-baseline gap-1 truncate text-xl font-semibold 
tabular-nums",
+          cell.tone,
+        )}
+      >
+        {cell.value}
+        {cell.to ? <ChevronRight className="text-muted-foreground size-4 
shrink-0 self-center" /> : null}
+      </span>
+      <span className="text-muted-foreground truncate text-xs">
+        {cell.label}
+        {cell.note ? <span className="opacity-70"> · {cell.note}</span> : null}
+      </span>
+    </>
+  );
+
+  const inner = cell.to ? (
+    <Link to={cell.to} className="flex min-w-0 flex-col gap-0.5">
+      {body}
+    </Link>
+  ) : (
+    <div className="flex min-w-0 flex-col gap-0.5">{body}</div>
+  );
+
+  return (
+    <div className={cn("min-w-0 px-4 py-3", cell.to && "hover:bg-accent/40 
transition-colors", className)}>
+      {cell.hint ? <SimpleTooltip title={cell.hint}>{inner}</SimpleTooltip> : 
inner}
+    </div>
+  );
+}
+
+/**
+ * The whole machine in one line: what the agents on it have spent, how many of
+ * them there are, and whether the keys behind them are what they were sold as.
+ * Everything here is otherwise only readable by adding up the cards below it,
+ * which is the reading the home screen should not ask anyone to do.
+ */
+export function HomeSummary({
+  total,
+  agents,
+  running,
+  providers,
+  loaded,
+}: {
+  /** The spend of every agent added up, by the same rule each card uses. */
+  total: Pick<AgentSpend, "requests" | "cost">;
+  agents: number;
+  running: number;
+  providers: Provider[];
+  /** False until the first listing lands, when a zero would be a guess. */
+  loaded: boolean;
+}) {
+  const {probes} = useProviderProbes();
+  const {configured, summary, worst} = authenticityOf(providers, probes);
+  const style = gradeStyleOf(worst?.grade);
+  const dash = <span className="text-muted-foreground">-</span>;
+
+  const cells: Cell[] = [
+    {
+      key: "cost",
+      label: i18next.t("llm:Cost"),
+      note: i18next.t("llm:All time"),
+      value: loaded ? formatCost(total.cost) : dash,
+      hint: i18next.t("agent:Read from the agent's own logs"),
+    },
+    {
+      key: "requests",
+      label: i18next.t("llm:Requests"),
+      value: loaded ? total.requests.toLocaleString() : dash,
+      to: "/usage",
+    },
+    {
+      key: "agents",
+      label: i18next.t("agent:Agents"),
+      note: fill("agent:{count} running", {count: running}),
+      value: agents,
+    },
+  ];
+
+  if (configured.length > 0) {
+    cells.push({
+      key: "authenticity",
+      label: i18next.t("audit:Authenticity"),
+      note:
+        summary.alerting > 0
+          ? fill("audit:Providers alerting", {count: summary.alerting})
+          : fill("audit:Graded providers", {graded: summary.graded, total: 
configured.length}),
+      value: (
+        <span className="tabular-nums">
+          {gradeLetter(worst?.grade)}
+          {worst ? ` · ${formatScore(worst)}` : ""}
+        </span>
+      ),
+      tone: style.text,
+      to: "/authenticity",
+      hint: i18next.t(style.verdict),
+    });
+  }
+
+  return (
+    <Card className="gap-0 overflow-hidden py-0 shadow-xs">
+      <div className={cn("grid grid-cols-2", cells.length > 3 ? 
"sm:grid-cols-4" : "sm:grid-cols-3")}>
+        {cells.map((cell, index) => (
+          <Figure
+            key={cell.key}
+            cell={cell}
+            // Two columns on a phone rule a cross through the middle; one row 
on
+            // a wide screen rules only between the figures.
+            className={cn(
+              index % 2 === 1 && "border-l",
+              index >= 2 && "border-t",
+              "sm:border-t-0",
+              index > 0 && "sm:border-l",
+            )}
+          />
+        ))}
+      </div>
+    </Card>
+  );
+}
diff --git a/web/src/components/OnboardingChecklist.tsx 
b/web/src/components/OnboardingChecklist.tsx
index 94b7c09..24d6ba9 100644
--- a/web/src/components/OnboardingChecklist.tsx
+++ b/web/src/components/OnboardingChecklist.tsx
@@ -118,16 +118,6 @@ export function useOnboarding({
   };
 }
 
-/** The entry that brings the card back, for whoever closed it. */
-export function OnboardingButton({onboarding}: {onboarding: Onboarding}) {
-  return (
-    <Button variant="ghost" onClick={onboarding.reopen}>
-      <ListChecks />
-      {i18next.t("agent:Getting started")}
-    </Button>
-  );
-}
-
 export function OnboardingChecklist({onboarding}: {onboarding: Onboarding}) {
   const steps = onboarding.steps;
   const doneCount = steps.filter(step => step.done).length;
diff --git a/web/src/components/shared/fold.tsx 
b/web/src/components/shared/fold.tsx
new file mode 100644
index 0000000..46674ae
--- /dev/null
+++ b/web/src/components/shared/fold.tsx
@@ -0,0 +1,61 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {ChevronDown} from "lucide-react";
+
+import {cn} from "@/lib/utils";
+
+/**
+ * A block of the page that stays folded until it is asked for, named by the 
one
+ * line that says what is inside it. What is worth a whole screen to somebody
+ * looking for it is worth a line to everybody else.
+ */
+export function Fold({
+  title,
+  description,
+  defaultOpen = false,
+  className,
+  children,
+}: {
+  title: React.ReactNode;
+  /** Shown only once the block is open, where it has something to describe. */
+  description?: React.ReactNode;
+  defaultOpen?: boolean;
+  className?: string;
+  children: React.ReactNode;
+}) {
+  const [open, setOpen] = React.useState(defaultOpen);
+
+  return (
+    <section className={cn("flex flex-col gap-3", className)}>
+      <button
+        type="button"
+        onClick={() => setOpen(!open)}
+        aria-expanded={open}
+        className="text-muted-foreground hover:text-foreground -mx-1 flex 
w-fit items-center gap-1.5 px-1 text-xs transition-colors"
+      >
+        <ChevronDown className={cn("size-3.5 transition-transform", open && 
"rotate-180")} />
+        {title}
+      </button>
+
+      {open ? (
+        <>
+          {description ? <p className="text-muted-foreground -mt-1 
text-xs">{description}</p> : null}
+          {children}
+        </>
+      ) : null}
+    </section>
+  );
+}
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index cb4af5a..a723901 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -30,6 +30,7 @@ import type {
   AgentUpdate,
   AgentUsage,
   AgentUsageStat,
+  LlmAgentStat,
   Provider,
   SavedAccount,
   SavedAccounts,
@@ -779,12 +780,14 @@ export function useAgentSessions(enabled = true, agent = 
"", refreshMs = 0) {
 }
 
 /**
- * Transcripts are written per tool rather than per installation: every Codex
- * front end shares one directory, so all of them read the same usage. That is
- * also the limit of what the files can tell apart.
+ * Transcripts are written per tool rather than per installation, and the Codex
+ * front ends share one directory: the reader tells the desktop app apart from
+ * the CLI by the originator each session reports, and keys them the way
+ * agentmonitor spells them. The VS Code extension is counted as the CLI, which
+ * is the one front end that keeps no id of its own.
  */
 export function usageAgentId(agentId: string) {
-  return agentId.startsWith("codex") ? "codex-cli" : agentId;
+  return agentId === "codex_vscode" || agentId === "codex-vscode" ? 
"codex-cli" : agentId;
 }
 
 /** What one installation spent, out of the totals read off the transcripts. */
@@ -792,6 +795,44 @@ export function usageOf(usage: AgentUsage | undefined, 
agent: Agent): AgentUsage
   return usage?.agents.find(item => item.name === usageAgentId(agent.agentId));
 }
 
+/** What one agent spent, and which of the two accounts it was read from. */
+export interface AgentSpend {
+  requests: number;
+  tokens: number;
+  cost: number;
+  /** The transcript this was read from, absent when it had nothing to say. */
+  own?: AgentUsageStat;
+  relayed?: LlmAgentStat;
+  lastModel: string;
+  lastTime: string;
+}
+
+/**
+ * What one agent spent. An agent's own transcript accounts for every request 
it
+ * made, the ones that never came near Gateway included, so it wins wherever it
+ * has anything to say; the relayed totals answer for the agents that keep no
+ * transcript. One rule, so a card and the strip above it never disagree.
+ */
+export function agentSpend(
+  agent: Agent,
+  usage: AgentUsage | undefined,
+  stats: LlmAgentStat[],
+): AgentSpend {
+  const own = usageOf(usage, agent);
+  const relayed = stats.find(item => item.agent === agent.agentId);
+  const spent = own && own.requests > 0 ? own : undefined;
+
+  return {
+    requests: spent?.requests ?? relayed?.requests ?? 0,
+    tokens: spent?.totalTokens ?? relayed?.tokens ?? 0,
+    cost: spent?.cost ?? relayed?.cost ?? 0,
+    own: spent,
+    relayed: relayed,
+    lastModel: spent?.lastModel || relayed?.lastModel || "",
+    lastTime: spent?.lastTime || relayed?.lastTime || "",
+  };
+}
+
 /** The badge colour every record table paints an outcome with. */
 export function getOutcomeVariant(outcome: string | undefined): BadgeVariant {
   return (
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 8df8495..37e4c07 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -374,8 +374,11 @@
     "opencode config hint": "opencode reads no base URL from the environment: 
the endpoint is a \"provider\" entry in ~/.config/opencode/opencode.json, 
loaded through @ai-sdk/openai-compatible and selected with \"model\": 
\"<provider>/<model>\".",
     "switches": "switches",
     "updates available": "updates available",
+    "{count} agents not in use": "{count} agents not in use",
+    "{count} agents not installed": "{count} agents not installed",
     "{count} from transcripts": "{count} from transcripts",
-    "{count} messages": "{count} messages"
+    "{count} messages": "{count} messages",
+    "{count} running": "{count} running"
   },
   "agentConfig": {
     "1 file": "1 file",
@@ -953,6 +956,7 @@
     "Loading": "Loading...",
     "Logged in successfully": "Logged in successfully",
     "Login Error": "Login Error",
+    "More": "More",
     "Name": "Name",
     "Name cannot be empty": "Name cannot be empty",
     "Neutral": "Neutral",
@@ -1153,7 +1157,8 @@
     "written": "written",
     "{count} new records": "{count} new records",
     "{count} of these requests have no list price": "{count} of these requests 
have no list price",
-    "{count} records have no price": "{count} records have no price"
+    "{count} records have no price": "{count} records have no price",
+    "{count} requests": "{count} requests"
   },
   "provider": {
     "API Key": "API Key",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index df2c987..7bbe8a1 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -374,8 +374,11 @@
     "opencode config hint": "opencode不从环境变量读取base 
URL:端点是~/.config/opencode/opencode.json里的一个\"provider\"条目,用@ai-sdk/openai-compatible加载,再用\"model\":
 \"<provider>/<model>\"选中它。",
     "switches": "个开关",
     "updates available": "个可更新",
+    "{count} agents not in use": "{count}个Agent未在使用",
+    "{count} agents not installed": "{count}个Agent未安装",
     "{count} from transcripts": "{count}个来自会话记录",
-    "{count} messages": "{count}条消息"
+    "{count} messages": "{count}条消息",
+    "{count} running": "{count}个在运行"
   },
   "agentConfig": {
     "1 file": "1个文件",
@@ -1022,6 +1025,7 @@
     "Loading": "加载中...",
     "Logged in successfully": "登录成功",
     "Login Error": "登录失败",
+    "More": "更多",
     "Name": "名称",
     "Name cannot be empty": "名称不能为空",
     "Neutral": "中性灰",
@@ -1222,7 +1226,8 @@
     "written": "写入",
     "{count} new records": "{count}条新记录",
     "{count} of these requests have no list price": "其中{count}个请求没有对应的价目",
-    "{count} records have no price": "{count}条记录没有价格"
+    "{count} records have no price": "{count}条记录没有价格",
+    "{count} requests": "{count}次请求"
   },
   "provider": {
     "API Key": "API密钥",
diff --git a/web/src/pages/AgentDetailPage.tsx 
b/web/src/pages/AgentDetailPage.tsx
index 82ec6a6..7d9a2af 100644
--- a/web/src/pages/AgentDetailPage.tsx
+++ b/web/src/pages/AgentDetailPage.tsx
@@ -25,6 +25,7 @@ import * as Setting from "@/Setting";
 import {AgentIcon} from "@/components/AgentIcon";
 import {AgentAccounts} from "@/components/AgentAccounts";
 import {AgentInstances} from "@/components/AgentInstances";
+import {AgentUsageTrend, hasUsageTrend} from "@/components/AgentUsageTrend";
 import {accountLabel} from "@/components/AgentGridCard";
 import {RunBadge, RunButton} from "@/components/AgentRunControl";
 import {
@@ -55,6 +56,8 @@ import {
   useAgents,
   useAgentSessions,
   useAgentUpdates,
+  usageAgentId,
+  usageOf,
 } from "@/lib/agents";
 import {formatCost, formatTokens} from "@/lib/usage";
 import type {
@@ -63,6 +66,8 @@ import type {
   AgentRecord,
   AgentRuntime,
   AgentSession,
+  AgentUsage,
+  AgentUsageStat,
   Provider,
   ProviderHealth,
   LlmRecordStats,
@@ -183,14 +188,29 @@ function RuntimeCard({
   );
 }
 
-/** What this agent has spent through the proxy, and on which provider. */
-function UsageCard({stats}: {stats: LlmRecordStats | null}) {
+/**
+ * What this agent has spent through the proxy, and on which provider - with 
the
+ * shape of what its own transcripts say above it, which is the fuller of the 
two
+ * accounts and the only one that sees a request Gateway never relayed.
+ */
+function UsageCard({stats, spent}: {stats: LlmRecordStats | null; spent?: 
AgentUsageStat}) {
   return (
     <Card>
       <CardHeader className="p-4 pb-2">
         <CardTitle className="text-base">{i18next.t("agent:Usage")}</CardTitle>
       </CardHeader>
       <CardContent className="space-y-3 p-4 pt-0">
+        {spent && hasUsageTrend(spent) ? (
+          <AgentUsageTrend
+            stat={spent}
+            hints={{
+              tokens: i18next.t("agent:Read from the agent's own logs"),
+              requests: i18next.t("agent:Read from the agent's own logs"),
+              cost: i18next.t("agent:Read from the agent's own logs"),
+            }}
+          />
+        ) : null}
+
         <div className="grid grid-cols-3 gap-2">
           <div>
             <div className="text-xs 
text-muted-foreground">{i18next.t("llm:Requests")}</div>
@@ -264,6 +284,7 @@ export default function AgentDetailPage({account}: 
{account: Account}) {
   const [providers, setProviders] = React.useState<Provider[]>([]);
   const [health, setHealth] = React.useState<ProviderHealth[]>([]);
   const [stats, setStats] = React.useState<LlmRecordStats | null>(null);
+  const [usage, setUsage] = React.useState<AgentUsage>();
 
   const agentId = params.agentId ?? "";
   const path = searchParams.get("path") ?? "";
@@ -303,8 +324,15 @@ export default function AgentDetailPage({account}: 
{account: Account}) {
     LlmRecordBackend.getLlmRecordStats({agent: agentId})
       .then(res => setStats(res.status === "ok" ? (res.data ?? null) : null))
       .catch(() => setStats(null));
+
+    // What the agent wrote down itself, which counts the requests that never
+    // came through Gateway. Read once: walking the transcripts is not cheap.
+    AgentBackend.getAgentUsage(usageAgentId(agentId))
+      .then(res => setUsage(res.status === "ok" ? (res.data ?? undefined) : 
undefined))
+      .catch(() => undefined);
   }, [isAdmin, agentId]);
 
+  const spent = agent ? usageOf(usage, agent) : undefined;
   const monitorId = agent ? monitorAgentId(agent.agentId) : "";
   const watching = Boolean(agent?.patched);
   const {sessions} = useAgentSessions(isAdmin && watching, monitorId, 5000);
@@ -496,7 +524,7 @@ export default function AgentDetailPage({account}: 
{account: Account}) {
           onWrite={restore => writeProvider(agent, restore)}
         />
 
-        <UsageCard stats={stats} />
+        <UsageCard stats={stats} spent={spent} />
 
         <Card>
           <CardHeader className="p-4 pb-2">
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index cfb9754..30d7385 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -14,7 +14,7 @@
 
 import * as React from "react";
 import {Link} from "react-router-dom";
-import {Bot, Container, Plus, RefreshCw, Table2} from "lucide-react";
+import {Bot, Container, ListChecks, MoreHorizontal, Plus, RefreshCw, Table2} 
from "lucide-react";
 import i18next from "i18next";
 
 import * as AgentBackend from "@/backend/AgentBackend";
@@ -23,36 +23,35 @@ import * as ProviderBackend from 
"@/backend/ProviderBackend";
 import * as Setting from "@/Setting";
 import {AgentCatalog} from "@/components/AgentCatalog";
 import {CcSwitchBanner} from "@/components/CcSwitchBanner";
-import {AuthenticityOverview} from "@/components/AuthenticityOverview";
-import {AgentSigninDialog} from "@/components/AgentAccounts";
 import {AgentGridCard} from "@/components/AgentGridCard";
-import {OnboardingButton, OnboardingChecklist, useOnboarding} from 
"@/components/OnboardingChecklist";
+import {HomeSummary} from "@/components/HomeSummary";
+import {OnboardingChecklist, useOnboarding} from 
"@/components/OnboardingChecklist";
 import {EmptyState, ErrorState} from "@/components/shared/empty-state";
+import {Fold} from "@/components/shared/fold";
 import {Loading} from "@/components/shared/loading";
 import {UnauthorizedResult} from "@/components/shared/misc";
 import {PageContainer, PageHeader} from "@/components/shared/page-header";
 import {MessageAlert} from "@/components/ui/alert";
 import {Button} from "@/components/ui/button";
 import {Card} from "@/components/ui/card";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
 import {
   agentKey,
+  agentSpend,
   runtimeOf,
-  useAgentAccounts,
   useAgentInstall,
   useAgentInstances,
   useAgents,
   useAgentUpdates,
   updateOf,
-  usageOf,
+  type AgentSpend,
 } from "@/lib/agents";
-import type {
-  Account,
-  AgentUsage,
-  LlmAgentStat,
-  Provider,
-  ProviderHealth,
-  ProviderQuota,
-} from "@/types";
+import type {Agent, Account, AgentUsage, LlmAgentStat, Provider, 
ProviderHealth} from "@/types";
 
 /** How often the numbers on the cards are read again. */
 const refreshMs = 10000;
@@ -64,12 +63,17 @@ const refreshMs = 10000;
  */
 const usageRefreshMs = 60000;
 
+/** Whether this installation is worth a card before anyone asks for one. */
+function inUse(agent: Agent, spend: AgentSpend, running: boolean) {
+  return running || agent.provider !== "" || spend.requests > 0;
+}
+
 /**
- * The home screen: one card per agent installed on this machine. Each card
- * carries what that agent is on, what it has spent there and whether it is
- * running, and the provider box switches it over in one click — the routing is
- * stored and, where Gateway writes the agent's configuration file, that file 
is
- * rewritten with it.
+ * The home screen. It answers two questions and leaves the rest to the pages
+ * below it: what did this machine spend, and what is each agent on. Everything
+ * an agent carries beyond that - its path, its account, its extra copies, its
+ * plan balance - is a click away on its own page, because eighteen cards 
saying
+ * all of it at once is a page that gets scrolled past rather than read.
  */
 export default function HomePage({account}: {account: Account}) {
   const isAdmin = Setting.isAdminUser(account);
@@ -98,10 +102,8 @@ export default function HomePage({account}: {account: 
Account}) {
   // Every agent's extra copies in one listing, so a page of cards costs one
   // request rather than one per card.
   const instances = useAgentInstances("", isAdmin);
-  const accounts = useAgentAccounts(agents, isAdmin);
   const [providers, setProviders] = React.useState<Provider[]>([]);
   const [health, setHealth] = React.useState<ProviderHealth[]>([]);
-  const [quotas, setQuotas] = React.useState<ProviderQuota[]>([]);
   const [stats, setStats] = React.useState<LlmAgentStat[]>([]);
   const [usage, setUsage] = React.useState<AgentUsage>();
   // Nothing is recorded until it is asked for, and until it is a zero on a 
card
@@ -128,16 +130,6 @@ export default function HomePage({account}: {account: 
Account}) {
       })
       .catch(failure => setProviderError(failure.message || String(failure)))
       .then(() => setLoaded(true));
-
-    // What is already known paints at once; the refresh then asks only the
-    // vendors whose answer has gone stale, so a page view costs at most one
-    // request per provider every ten minutes.
-    ProviderBackend.getProviderQuotas()
-      .then(res => setQuotas(res.status === "ok" ? (res.data ?? []) : []))
-      .catch(() => undefined)
-      .then(() => ProviderBackend.refreshProviderQuotas("", false))
-      .then(res => setQuotas(res?.status === "ok" ? (res.data ?? []) : []))
-      .catch(() => undefined);
   }, [isAdmin, account.name]);
 
   React.useEffect(() => {
@@ -197,6 +189,28 @@ export default function HomePage({account}: {account: 
Account}) {
   // back to whoever closed it.
   const onboarding = useOnboarding({providers: providers, agents: agents, 
stats: stats});
 
+  // One reading of each agent, shared by the cards, the strip above them and 
the
+  // split below: the totals cannot disagree with the cards they add up.
+  const rows = agents
+    .map(agent => {
+      const status = runtimeOf(runtime, agent);
+      const spend = agentSpend(agent, usage, stats);
+      return {agent: agent, status: status, spend: spend, using: inUse(agent, 
spend, status?.running === true)};
+    })
+    // Busiest first, and whatever is running before whatever is not: a card is
+    // worth its place by what it is doing, not by where it was installed.
+    .sort((left, right) => {
+      const run = Number(right.status?.running ?? false) - 
Number(left.status?.running ?? false);
+      return run !== 0 ? run : right.spend.cost - left.spend.cost || 
right.spend.requests - left.spend.requests;
+    });
+  const using = rows.filter(row => row.using);
+  const idle = rows.filter(row => !row.using);
+  const running = rows.filter(row => row.status?.running).length;
+  const total = rows.reduce(
+    (sum, row) => ({requests: sum.requests + row.spend.requests, cost: 
sum.cost + row.spend.cost}),
+    {requests: 0, cost: 0},
+  );
+
   if (!isAdmin) {
     return <UnauthorizedResult />;
   }
@@ -215,6 +229,31 @@ export default function HomePage({account}: {account: 
Account}) {
     </Button>
   );
 
+  const cards = (rendered: typeof rows) => (
+    <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 
xl:grid-cols-4">
+      {rendered.map(row => (
+        <AgentGridCard
+          key={agentKey(row.agent)}
+          agent={row.agent}
+          agents={agents}
+          providers={providers}
+          health={health}
+          spend={row.spend}
+          status={row.status}
+          update={updateOf(updates.updates, row.agent)}
+          instances={instances}
+          recording={recording}
+          busy={busyKey === agentKey(row.agent)}
+          runBusy={runBusyKey === agentKey(row.agent)}
+          onEnable={providerId => activateProvider(row.agent, providerId)}
+          onLocated={() => scan(true)}
+          onToggleRunning={toggleRunning}
+          onTogglePatch={() => togglePatch(row.agent)}
+        />
+      ))}
+    </div>
+  );
+
   return (
     <PageContainer>
       <PageHeader
@@ -222,13 +261,27 @@ export default function HomePage({account}: {account: 
Account}) {
         description={account.hostname}
         actions={
           <>
-            {loaded && !onboarding.open ? <OnboardingButton 
onboarding={onboarding} /> : null}
-            <Button asChild variant="ghost">
-              <Link to="/agents">
-                <Table2 />
-                {i18next.t("agent:Advanced view")}
-              </Link>
-            </Button>
+            {/* Two controls, not four: the rescan is the one anybody presses,
+                and the rest are places rather than actions. */}
+            <DropdownMenu>
+              <DropdownMenuTrigger asChild>
+                <Button variant="ghost" size="icon" 
aria-label={i18next.t("general:More")}>
+                  <MoreHorizontal />
+                </Button>
+              </DropdownMenuTrigger>
+              <DropdownMenuContent align="end">
+                <DropdownMenuItem asChild>
+                  <Link to="/agents">
+                    <Table2 />
+                    {i18next.t("agent:Advanced view")}
+                  </Link>
+                </DropdownMenuItem>
+                <DropdownMenuItem onSelect={onboarding.reopen}>
+                  <ListChecks />
+                  {i18next.t("agent:Getting started")}
+                </DropdownMenuItem>
+              </DropdownMenuContent>
+            </DropdownMenu>
             {rescan}
           </>
         }
@@ -240,6 +293,19 @@ export default function HomePage({account}: {account: 
Account}) {
           the import page is the first thing they want rather than a form. */}
       <CcSwitchBanner />
 
+      {/* What the machine adds up to, including whether the keys behind it are
+          what they were sold as. Every figure here is otherwise only reachable
+          by reading every card below. */}
+      {scanned && agents.length > 0 ? (
+        <HomeSummary
+          total={total}
+          agents={agents.length}
+          running={running}
+          providers={providers}
+          loaded={usage !== undefined}
+        />
+      ) : null}
+
       {/* With no agents to show, the failure is the whole page and is rendered
           below instead; here it sits above the cards that are still on 
screen. */}
       {error !== "" && agents.length > 0 ? <MessageAlert title={error} /> : 
null}
@@ -271,11 +337,6 @@ export default function HomePage({account}: {account: 
Account}) {
         />
       ) : null}
 
-      {/* Whether the keys behind these agents reach what they are sold as. It
-          is measured without being asked for, so it belongs above the fold
-          rather than behind a button on another page. */}
-      {providers.length > 0 ? <AuthenticityOverview providers={providers} /> : 
null}
-
       {!scanned ? (
         <Loading tip={i18next.t("agent:Scan")} />
       ) : error !== "" && agents.length === 0 ? (
@@ -298,38 +359,31 @@ export default function HomePage({account}: {account: 
Account}) {
           />
         </Card>
       ) : (
-        <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 
xl:grid-cols-4">
-          {agents.map(agent => (
-            <AgentGridCard
-              key={agentKey(agent)}
-              agent={agent}
-              agents={agents}
-              providers={providers}
-              health={health}
-              quota={quotas.find(item => item.provider === agent.provider)}
-              stats={stats.find(item => item.agent === agent.agentId)}
-              usage={usageOf(usage, agent)}
-              status={runtimeOf(runtime, agent)}
-              update={updateOf(updates.updates, agent)}
-              instances={instances}
-              accounts={accounts}
-              recording={recording}
-              busy={busyKey === agentKey(agent)}
-              runBusy={runBusyKey === agentKey(agent)}
-              onEnable={providerId => activateProvider(agent, providerId)}
-              onLocated={() => scan(true)}
-              onToggleRunning={toggleRunning}
-              onTogglePatch={() => togglePatch(agent)}
-            />
-          ))}
-        </div>
-      )}
+        <>
+          {/* Everything bound, running or spending. On a machine where nothing
+              is any of those the split has nothing to hide, so it is skipped. 
*/}
+          {cards(using.length > 0 ? using : rows)}
 
-      {/* One sign-in runs at a time, so the whole grid shares one dialog. */}
-      <AgentSigninDialog controls={accounts} />
+          {using.length > 0 && idle.length > 0 ? (
+            <Fold
+              title={i18next
+                .t("agent:{count} agents not in use")
+                .replace("{count}", String(idle.length))}
+            >
+              {cards(idle)}
+            </Fold>
+          ) : null}
+        </>
+      )}
 
       {/* What is not here yet, so an empty machine has somewhere to go. */}
-      <AgentCatalog agents={agents} enabled={scanned} installer={installer} 
onLocated={() => scan(true)} />
+      <AgentCatalog
+        agents={agents}
+        enabled={scanned}
+        defaultOpen={agents.length === 0}
+        installer={installer}
+        onLocated={() => scan(true)}
+      />
     </PageContainer>
   );
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to