Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
6f20d05bb7 refactor(usage): rename Dashboard → Usage + dynamic per-agent leaderboard
The page added in #2462 lived at `/{slug}/dashboard` and was titled
"Dashboard", which collides with the conventional meaning ("personal
landing surface") and doesn't tell new users what the page is for. Its
actual contents — token spend, cost, run time, task counts — map cleanly
onto the OpenAI / Anthropic / Vercel "Usage" surface, so rename to that.

Renames (user-visible)
- Route: `/{slug}/dashboard` → `/{slug}/usage` (web App Router + desktop
  memory router)
- Sidebar entry: label "Dashboard" / "看板" → "Usage" / "用量", icon
  LayoutDashboard → BarChart3 (page header icon swapped in sync)
- Page title in en/zh-Hans
- Reserved-slugs: add `usage` to workspace route segments group;
  `dashboard` stays reserved in the marketing group (back-compat against
  workspace slug collisions + keeps the name free for a future Home page)
- i18n namespace `dashboard` → `usage` across resources-types.ts,
  locales/index.ts, and the moved JSON files
- WORKSPACE_ROUTE_SEGMENTS in editor link-handler
- paths.workspace(slug).dashboard() → .usage(), with matching test
  expectation updates

Per-agent leaderboard polish (`packages/views/dashboard/components/
dashboard-page.tsx`)
- Card title "Cost & run time by agent" → "Leaderboard" with a 4-way
  Segmented control: Tokens / Cost / Time / Tasks
- Active metric drives row order, progress-bar width, and the
  emphasised column header / cell — keeping ranking, visual quantity,
  and column emphasis in lockstep so users always see what's being
  measured
- Default sort = Tokens (most universally meaningful; Cost still one
  click away)
- Project filter dropdown:
  - Show ProjectIcon next to the selected project + each list item;
    FolderKanban as the "All projects" fallback (matches ProjectPicker
    language)
  - alignItemWithTrigger={false} so "All projects" doesn't get pushed
    above the trigger and clipped when the header sits at the top of
    the viewport (was the root cause of "can't re-select All projects"
    once a project was selected)
  - max-h-72 to cap the dropdown when workspaces accrue many projects;
    matches the runtime-detail Select precedent
- Folder name `packages/views/dashboard/*` and `DashboardPage`
  component name intentionally left in place — user-visible rename
  only, no broad code refactor.

Old `/dashboard` routes are not redirected because the page only landed
in #2462 (a few days ago); no real users, external links, or
desktop-tab persistence have settled on it yet.
2026-05-13 14:02:48 +08:00
17 changed files with 146 additions and 69 deletions

View File

@@ -148,9 +148,9 @@ export const appRoutes: RouteObject[] = [
},
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
{
path: "dashboard",
path: "usage",
element: <DashboardPage />,
handle: { title: "Dashboard" },
handle: { title: "Usage" },
},
{
path: "settings",

View File

@@ -17,7 +17,7 @@ describe("paths.workspace() shape", () => {
expect(new Set(parameterlessRoutes)).toEqual(
new Set([
"root",
"dashboard",
"usage",
"issues",
"projects",
"autopilots",
@@ -36,7 +36,7 @@ describe("paths.workspace() shape", () => {
// Check that none of the parameterless paths embed a leaked literal
// and that their second URL segment matches the method name's kebab-case.
const expectedSegments: Array<[string, string]> = [
["dashboard", "dashboard"],
["usage", "usage"],
["issues", "issues"],
["projects", "projects"],
["autopilots", "autopilots"],

View File

@@ -4,8 +4,8 @@ import { paths, isGlobalPath } from "./paths";
describe("paths.workspace(slug)", () => {
const ws = paths.workspace("acme");
it("builds dashboard paths with slug prefix", () => {
expect(ws.dashboard()).toBe("/acme/dashboard");
it("builds workspace paths with slug prefix", () => {
expect(ws.usage()).toBe("/acme/usage");
expect(ws.issues()).toBe("/acme/issues");
expect(ws.issueDetail("abc-123")).toBe("/acme/issues/abc-123");
expect(ws.projects()).toBe("/acme/projects");

View File

@@ -18,7 +18,7 @@ function workspaceScoped(slug: string) {
const ws = `/${encode(slug)}`;
return {
root: () => `${ws}/issues`,
dashboard: () => `${ws}/dashboard`,
usage: () => `${ws}/usage`,
issues: () => `${ws}/issues`,
issueDetail: (id: string) => `${ws}/issues/${encode(id)}`,
projects: () => `${ws}/projects`,

View File

@@ -70,7 +70,7 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
"search",
"members",
// Dashboard / workspace route segments
// Workspace route segments
// Reserving each segment name prevents `/{slug}/{view}` from being visually
// ambiguous (e.g. a workspace named `issues` would make `/issues/abc` mean two
// things). `workspaces` covers the global `/workspaces/new` workspace-creation
@@ -81,6 +81,7 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
"agents",
"inbox",
"my-issues",
"usage",
"runtimes",
"skills",
"settings",

View File

@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { LayoutDashboard, BarChart3 } from "lucide-react";
import { BarChart3, FolderKanban } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import {
@@ -23,6 +23,7 @@ import { useCustomPricingStore } from "@multica/core/runtimes/custom-pricing-sto
import { PageHeader } from "../../layout/page-header";
import { KpiCard } from "../../runtimes/components/shared";
import { DailyCostChart } from "../../runtimes/components/charts";
import { ProjectIcon } from "../../projects/components/project-icon";
import { ActorAvatar } from "../../common/actor-avatar";
import { formatTokens } from "../../runtimes/utils";
import { useT } from "../../i18n";
@@ -104,7 +105,7 @@ function Segmented<T extends string | number>({
* and the runtime page using one pricing table.
*/
export function DashboardPage() {
const { t } = useT("dashboard");
const { t } = useT("usage");
const wsId = useWorkspaceId();
const [days, setDays] = useState<TimeRange>(30);
const [projectValue, setProjectValue] = useState<string>(ALL_PROJECTS);
@@ -177,7 +178,7 @@ export function DashboardPage() {
<div className="flex h-full flex-col">
<PageHeader className="justify-between px-5">
<div className="flex items-center gap-2">
<LayoutDashboard className="h-4 w-4 text-muted-foreground" />
<BarChart3 className="h-4 w-4 text-muted-foreground" />
<h1 className="text-sm font-medium">{t(($) => $.title)}</h1>
</div>
<div className="flex items-center gap-2">
@@ -244,8 +245,9 @@ export function DashboardPage() {
{/* Daily cost chart — reuses the runtime DailyCostChart. */}
<DailyCostBlock dailyCost={dailyCost} />
{/* By-agent combined list. */}
<AgentList
{/* Per-agent leaderboard — user picks the ranking metric;
the progress bar and column emphasis follow the metric. */}
<Leaderboard
rows={agentRows}
agents={agents}
lessThanMinuteLabel={t(($) => $.duration.less_than_minute)}
@@ -263,16 +265,15 @@ function ProjectFilter({
value,
onChange,
}: {
projects: { id: string; title: string }[];
projects: { id: string; title: string; icon: string | null }[];
value: string;
onChange: (v: string) => void;
}) {
const { t } = useT("dashboard");
const { t } = useT("usage");
const allLabel = t(($) => $.filter.all_projects);
const selected = projects.find((p) => p.id === value);
const selectedTitle =
value === ALL_PROJECTS
? t(($) => $.filter.all_projects)
: projects.find((p) => p.id === value)?.title ??
t(($) => $.filter.all_projects);
value === ALL_PROJECTS ? allLabel : selected?.title ?? allLabel;
return (
<Select
@@ -280,13 +281,35 @@ function ProjectFilter({
onValueChange={(v) => onChange(v ?? ALL_PROJECTS)}
>
<SelectTrigger size="sm" className="min-w-[180px]">
<SelectValue>{() => selectedTitle}</SelectValue>
<SelectValue>
{() => (
<>
{selected ? (
<ProjectIcon project={selected} size="sm" />
) : (
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{selectedTitle}</span>
</>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_PROJECTS}>{t(($) => $.filter.all_projects)}</SelectItem>
{/* alignItemWithTrigger=false: the default aligns the *selected* item
to the trigger, which pushes "All projects" above the trigger and
clips it off-screen when the usage header sits at the top of the
viewport. Anchor the dropdown to the bottom of the trigger so
every entry stays reachable.
max-h-72: cap the dropdown so a long project list scrolls instead
of stretching to the bottom of the window. */}
<SelectContent align="start" alignItemWithTrigger={false} className="max-h-72">
<SelectItem value={ALL_PROJECTS}>
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{allLabel}</span>
</SelectItem>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.title}
<ProjectIcon project={p} size="sm" />
<span className="truncate">{p.title}</span>
</SelectItem>
))}
</SelectContent>
@@ -299,7 +322,7 @@ function DailyCostBlock({
}: {
dailyCost: ReturnType<typeof aggregateDailyCost>;
}) {
const { t } = useT("dashboard");
const { t } = useT("usage");
const total = dailyCost.reduce((sum, d) => sum + d.total, 0);
return (
<div className="rounded-lg border bg-card p-4">
@@ -322,7 +345,19 @@ function DailyCostBlock({
);
}
function AgentList({
// Which metric ranks the leaderboard. Drives row order, progress bar
// width, and which column header is emphasised — keeping the three in
// lockstep so the user always sees what the ranking actually measures.
type LeaderboardSort = "tokens" | "cost" | "time" | "tasks";
const SORT_METRIC: Record<LeaderboardSort, (r: AgentDashboardRow) => number> = {
tokens: (r) => r.tokens,
cost: (r) => r.cost,
time: (r) => r.seconds,
tasks: (r) => r.taskCount,
};
function Leaderboard({
rows,
agents,
lessThanMinuteLabel,
@@ -331,35 +366,67 @@ function AgentList({
agents: { id: string; name: string }[];
lessThanMinuteLabel: string;
}) {
const { t } = useT("dashboard");
const maxCost = rows.reduce((m, r) => Math.max(m, r.cost), 0);
const { t } = useT("usage");
const [sortBy, setSortBy] = useState<LeaderboardSort>("tokens");
const sortOptions = useMemo(
() => [
{ value: "tokens" as const, label: t(($) => $.leaderboard.header_tokens) },
{ value: "cost" as const, label: t(($) => $.leaderboard.header_cost) },
{ value: "time" as const, label: t(($) => $.leaderboard.header_time) },
{ value: "tasks" as const, label: t(($) => $.leaderboard.header_tasks) },
],
[t],
);
// Re-rank when the metric changes; keep the merged input untouched so
// upstream `mergeAgentDashboardRows`'s tiebreaker (run time desc) still
// applies inside an equal-bucket.
const sortedRows = useMemo(() => {
const metric = SORT_METRIC[sortBy];
return [...rows].sort((a, b) => metric(b) - metric(a));
}, [rows, sortBy]);
const maxValue = useMemo(() => {
const metric = SORT_METRIC[sortBy];
return sortedRows.reduce((m, r) => Math.max(m, metric(r)), 0);
}, [sortedRows, sortBy]);
// Active column gets foreground text; others stay muted. Helps the user
// see "this is what the bar is measuring" at a glance.
const colClass = (key: LeaderboardSort) =>
`text-right ${sortBy === key ? "text-foreground" : "text-muted-foreground"}`;
return (
<div className="rounded-lg border bg-card">
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 pt-4 pb-3">
<h4 className="text-sm font-semibold">{t(($) => $.by_agent.title)}</h4>
<span className="text-xs text-muted-foreground">
{t(($) => $.by_agent.caption, { count: rows.length })}
</span>
<h4 className="text-sm font-semibold">{t(($) => $.leaderboard.title)}</h4>
<div className="flex items-center gap-3">
<Segmented value={sortBy} onChange={setSortBy} options={sortOptions} />
<span className="text-xs text-muted-foreground">
{t(($) => $.leaderboard.caption, { count: rows.length })}
</span>
</div>
</div>
{rows.length === 0 ? (
{sortedRows.length === 0 ? (
<p className="px-4 py-8 text-center text-xs text-muted-foreground">
{t(($) => $.by_agent.no_data)}
{t(($) => $.leaderboard.no_data)}
</p>
) : (
<>
<div className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_5rem_5rem_5rem_4rem] items-center gap-3 border-b px-4 py-2 text-xs font-medium text-muted-foreground">
<span>{t(($) => $.by_agent.header_agent)}</span>
<span>{t(($) => $.leaderboard.header_agent)}</span>
<span />
<span className="text-right">{t(($) => $.by_agent.header_tokens)}</span>
<span className="text-right">{t(($) => $.by_agent.header_cost)}</span>
<span className="text-right">{t(($) => $.by_agent.header_time)}</span>
<span className="text-right">{t(($) => $.by_agent.header_tasks)}</span>
<span className={colClass("tokens")}>{t(($) => $.leaderboard.header_tokens)}</span>
<span className={colClass("cost")}>{t(($) => $.leaderboard.header_cost)}</span>
<span className={colClass("time")}>{t(($) => $.leaderboard.header_time)}</span>
<span className={colClass("tasks")}>{t(($) => $.leaderboard.header_tasks)}</span>
</div>
<div className="divide-y">
{rows.map((row) => {
{sortedRows.map((row) => {
const agent = agents.find((a) => a.id === row.agentId);
const pct = maxCost > 0 ? (row.cost / maxCost) * 100 : 0;
const value = SORT_METRIC[sortBy](row);
const pct = maxValue > 0 ? (value / maxValue) * 100 : 0;
return (
<div
key={row.agentId}
@@ -378,20 +445,28 @@ function AgentList({
</div>
<div className="relative h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-chart-1"
className="h-full rounded-full bg-chart-1 transition-[width] duration-300 ease-out"
style={{ width: `${pct}%` }}
/>
</div>
<div className="text-right text-xs tabular-nums text-muted-foreground">
<div
className={`text-right text-xs tabular-nums ${sortBy === "tokens" ? "font-medium text-foreground" : "text-muted-foreground"}`}
>
{formatTokens(row.tokens)}
</div>
<div className="text-right text-sm font-medium tabular-nums">
<div
className={`text-right tabular-nums ${sortBy === "cost" ? "text-sm font-medium" : "text-xs text-muted-foreground"}`}
>
${row.cost.toFixed(2)}
</div>
<div className="text-right text-xs tabular-nums text-muted-foreground">
<div
className={`text-right text-xs tabular-nums ${sortBy === "time" ? "font-medium text-foreground" : "text-muted-foreground"}`}
>
{formatDuration(row.seconds, lessThanMinuteLabel)}
</div>
<div className="text-right text-xs tabular-nums text-muted-foreground">
<div
className={`text-right text-xs tabular-nums ${sortBy === "tasks" ? "font-medium text-foreground" : "text-muted-foreground"}`}
>
{row.taskCount}
</div>
</div>
@@ -415,7 +490,7 @@ function DashboardSkeleton() {
}
function DashboardEmpty() {
const { t } = useT("dashboard");
const { t } = useT("usage");
return (
<div className="flex flex-col items-center rounded-lg border border-dashed py-12 text-center">
<BarChart3 className="h-6 w-6 text-muted-foreground/40" />

View File

@@ -19,7 +19,7 @@ import { isGlobalPath } from "@multica/core/paths";
* as intentional. Only "/issues/..." style paths get auto-prefixed.
*/
const WORKSPACE_ROUTE_SEGMENTS = new Set([
"dashboard",
"usage",
"issues",
"projects",
"autopilots",

View File

@@ -20,7 +20,7 @@ import type chat from "../locales/en/chat.json";
import type modals from "../locales/en/modals.json";
import type runtimes from "../locales/en/runtimes.json";
import type layout from "../locales/en/layout.json";
import type dashboard from "../locales/en/dashboard.json";
import type usage from "../locales/en/usage.json";
// Module augmentation enables i18next v26 selector API across the monorepo:
// `t($ => $.signin.title)` resolves to the value in en/auth.json.
@@ -55,7 +55,7 @@ declare module "i18next" {
modals: typeof modals;
runtimes: typeof runtimes;
layout: typeof layout;
dashboard: typeof dashboard;
usage: typeof usage;
};
enableSelector: true;
}

View File

@@ -90,7 +90,7 @@ vi.mock("@multica/core/paths", () => ({
projects: () => "/acme/projects",
autopilots: () => "/acme/autopilots",
agents: () => "/acme/agents",
dashboard: () => "/acme/dashboard",
usage: () => "/acme/usage",
runtimes: () => "/acme/runtimes",
skills: () => "/acme/skills",
settings: () => "/acme/settings",

View File

@@ -29,7 +29,7 @@ import {
SquarePen,
CircleUser,
FolderKanban,
LayoutDashboard,
BarChart3,
X,
Zap,
} from "lucide-react";
@@ -107,7 +107,7 @@ type NavKey =
| "projects"
| "autopilots"
| "agents"
| "dashboard"
| "usage"
| "runtimes"
| "skills"
| "settings";
@@ -120,7 +120,7 @@ type NavLabelKey =
| "projects"
| "autopilots"
| "agents"
| "dashboard"
| "usage"
| "runtimes"
| "skills"
| "settings";
@@ -135,7 +135,7 @@ const workspaceNav: { key: NavKey; labelKey: NavLabelKey; icon: typeof Inbox }[]
{ key: "projects", labelKey: "projects", icon: FolderKanban },
{ key: "autopilots", labelKey: "autopilots", icon: Zap },
{ key: "agents", labelKey: "agents", icon: Bot },
{ key: "dashboard", labelKey: "dashboard", icon: LayoutDashboard },
{ key: "usage", labelKey: "usage", icon: BarChart3 },
];
const configureNav: { key: NavKey; labelKey: NavLabelKey; icon: typeof Inbox }[] = [

View File

@@ -6,7 +6,7 @@
"projects": "Projects",
"autopilots": "Autopilot",
"agents": "Agents",
"dashboard": "Dashboard",
"usage": "Usage",
"runtimes": "Runtimes",
"skills": "Skills",
"settings": "Settings"

View File

@@ -1,5 +1,5 @@
{
"title": "Dashboard",
"title": "Usage",
"subtitle": "Token spend and agent activity across this workspace.",
"filter": {
"project_label": "Project",
@@ -19,8 +19,8 @@
"title": "Daily cost",
"no_data": "No usage in this window."
},
"by_agent": {
"title": "Cost & run time by agent",
"leaderboard": {
"title": "Leaderboard",
"caption": "{{count}} agents",
"header_agent": "Agent",
"header_tokens": "Tokens",
@@ -31,7 +31,7 @@
},
"empty": {
"title": "No usage yet",
"body": "Once agents start running tasks here, their token spend and run time will appear in this dashboard."
"body": "Once agents start running tasks here, their token spend and run time will appear in this view."
},
"duration": {
"less_than_minute": "<1m"

View File

@@ -20,7 +20,7 @@ import enChat from "./en/chat.json";
import enModals from "./en/modals.json";
import enRuntimes from "./en/runtimes.json";
import enLayout from "./en/layout.json";
import enDashboard from "./en/dashboard.json";
import enUsage from "./en/usage.json";
import zhHansCommon from "./zh-Hans/common.json";
import zhHansAuth from "./zh-Hans/auth.json";
import zhHansSettings from "./zh-Hans/settings.json";
@@ -42,7 +42,7 @@ import zhHansChat from "./zh-Hans/chat.json";
import zhHansModals from "./zh-Hans/modals.json";
import zhHansRuntimes from "./zh-Hans/runtimes.json";
import zhHansLayout from "./zh-Hans/layout.json";
import zhHansDashboard from "./zh-Hans/dashboard.json";
import zhHansUsage from "./zh-Hans/usage.json";
// Single source of truth for the resource bundle. Both apps (web layout +
// desktop App.tsx) import from here so adding a locale or namespace happens
@@ -70,7 +70,7 @@ export const RESOURCES: Record<SupportedLocale, LocaleResources> = {
modals: enModals,
runtimes: enRuntimes,
layout: enLayout,
dashboard: enDashboard,
usage: enUsage,
},
"zh-Hans": {
common: zhHansCommon,
@@ -94,6 +94,6 @@ export const RESOURCES: Record<SupportedLocale, LocaleResources> = {
modals: zhHansModals,
runtimes: zhHansRuntimes,
layout: zhHansLayout,
dashboard: zhHansDashboard,
usage: zhHansUsage,
},
};

View File

@@ -6,7 +6,7 @@
"projects": "项目",
"autopilots": "自动化",
"agents": "智能体",
"dashboard": "看板",
"usage": "用量",
"runtimes": "运行时",
"skills": "Skill",
"settings": "设置"

View File

@@ -1,5 +1,5 @@
{
"title": "看板",
"title": "用量",
"subtitle": "查看当前 Workspace 的 token 消耗和智能体运行情况。",
"filter": {
"project_label": "项目",
@@ -19,8 +19,8 @@
"title": "每日费用",
"no_data": "所选时间范围内暂无消耗。"
},
"by_agent": {
"title": "智能体维度的费用与运行时长",
"leaderboard": {
"title": "排行榜",
"caption": "{{count}} 个智能体",
"header_agent": "智能体",
"header_tokens": "Token",
@@ -31,7 +31,7 @@
},
"empty": {
"title": "暂无消耗数据",
"body": "当智能体在这里开始执行任务后,它们的 token 消耗和运行时长将出现在该看板中。"
"body": "当智能体在这里开始执行任务后,它们的 token 消耗和运行时长将出现在此处。"
},
"duration": {
"less_than_minute": "<1分钟"

View File

@@ -63,7 +63,7 @@
]
},
{
"label": "Dashboard / workspace route segments",
"label": "Workspace route segments",
"description": "Reserving each segment name prevents `/{slug}/{view}` from being visually ambiguous (e.g. a workspace named `issues` would make `/issues/abc` mean two things). `workspaces` covers the global `/workspaces/new` workspace-creation page; `teams` is reserved for future team management.",
"slugs": [
"issues",
@@ -72,6 +72,7 @@
"agents",
"inbox",
"my-issues",
"usage",
"runtimes",
"skills",
"settings",