diff --git a/desktop/frontend-next/src/i18n/msg/composer.ts b/desktop/frontend-next/src/i18n/msg/composer.ts
index f66269129e..6eda87ac24 100644
--- a/desktop/frontend-next/src/i18n/msg/composer.ts
+++ b/desktop/frontend-next/src/i18n/msg/composer.ts
@@ -159,7 +159,6 @@ export const COMPOSER: Messages = {
"composer.no_git_repository": { zh: "非 Git 仓库", en: "No Git repository" },
"composer.no_images": { zh: "不发送图片", en: "No images" },
"composer.no_matching_branches": { zh: "没有匹配的分支", en: "No matching branches" },
- "composer.no_mcp_services_enabled": { zh: "尚未启用 MCP 服务", en: "No MCP services enabled" },
"composer.no_model_service_is_connected": { zh: "还没有连接模型服务,发不出去", en: "No model service is connected, so nothing can be sent" },
"composer.no_models_match": { zh: "没有匹配的模型。", en: "No models match." },
"composer.no_observable_progress_for_n": { zh: "已连续 {n} 轮没有可观察的进展", en: "No observable progress for {n} rounds" },
diff --git a/desktop/frontend-next/src/i18n/msg/session.ts b/desktop/frontend-next/src/i18n/msg/session.ts
index 5ae3b0c062..d0938c6bf5 100644
--- a/desktop/frontend-next/src/i18n/msg/session.ts
+++ b/desktop/frontend-next/src/i18n/msg/session.ts
@@ -70,7 +70,6 @@ export const SESSION: Messages = {
"session.folder_is_gone": { zh: "文件夹已不在磁盘上", en: "Folder is gone" },
"session.folder_path_to_add": { zh: "要添加的文件夹路径", en: "Folder path to add" },
"session.folders_are_managed_in_the": { zh: "在左栏管理文件夹:底部添加,展开后可新建会话。会话归属于创建它的文件夹,不会移动到其他位置。", en: "Folders are managed in the left column: add at the bottom, expand to start a session. A session belongs to the folder it was created in and does not move." },
- "session.folds_automatically_at_n": { zh: "自动压缩于 {n}", en: "Folds automatically at {n}" },
"session.generate_a_title_from_the": { zh: "根据全部用户消息拼接后的最后 300 个字符生成标题", en: "Generate a title from the last 300 characters of all user messages joined together" },
"session.goal_evaluation": { zh: "目标评估", en: "Goal evaluation" },
"session.handing_over_the_task_and": { zh: "任务和附件正在交接…", en: "Handing over the task and attachments…" },
@@ -84,6 +83,7 @@ export const SESSION: Messages = {
"session.loading": { zh: "正在加载…", en: "Loading…" },
"session.local_workspace": { zh: "本地工作区", en: "Local workspace" },
"session.main_agent": { zh: "主代理", en: "Main agent" },
+ "session.main_n_sub_m": { zh: "主代理 {a} · 子代理 {b}", en: "Main agent {a} · subagents {b}" },
"session.model_responses_that_reported_usage": { zh: "返回了用量与价格的模型响应", en: "Model responses that reported usage and a price" },
"session.move": { zh: "移动", en: "Move" },
"session.move_name_to_another_project": { zh: "把「{name}」移到另一个项目", en: "Move \"{name}\" to another project" },
@@ -209,7 +209,6 @@ export const SESSION: Messages = {
"session.waiting_for_confirmation": { zh: "正在等待状态确认", en: "Waiting for confirmation" },
"session.waiting_n_s": { zh: "已等待 {n} 秒", en: "Waiting {n}s" },
"session.wallet_and_usage": { zh: "钱包与用量", en: "Wallet and usage" },
- "session.what_the_next_request_would": { zh: "下一次请求会带上的内容,是当前值,不是累计", en: "What the next request would carry: a current reading, not a running total" },
"session.what_the_old_project_allowed": { zh: "原项目的权限与目标设置不会跟着走,新项目按它自己的规则重新判断。不随会话移动的内容会从原项目移除。", en: "What the old project allowed does not travel; the new project decides that by its own rules. What stays behind is removed from the old project." },
"session.where_it_runs": { zh: "运行环境", en: "Where it runs" },
"session.where_this_session_writes": { zh: "会话写入位置", en: "Where this session writes" },
diff --git a/desktop/frontend-next/src/styles/overview.css b/desktop/frontend-next/src/styles/overview.css
index f46f11d7e0..dedd23dc29 100644
--- a/desktop/frontend-next/src/styles/overview.css
+++ b/desktop/frontend-next/src/styles/overview.css
@@ -17,11 +17,11 @@
.studio-overview-grid dt { color: var(--ghost); font: var(--w-reg) var(--fs-micro)/1.3 var(--ui); }
.studio-overview-grid dd { margin: 0; color: var(--text); font: var(--w-med) var(--fs-ui)/1.2 var(--mono); }
.studio-overview-grid small { color: var(--ghost); font: var(--w-reg) var(--fs-micro)/1.4 var(--ui); }
-.studio-overview-row { display: grid; grid-template-columns: minmax(0, 1.3fr) 3.2ch 5ch 4.5ch minmax(0, 1.2fr); align-items: center; gap: 8px; min-height: 22px; padding: 0 5px; border-radius: var(--r-xs); }
-.studio-overview-row:hover { background: var(--overlay); }
+.studio-overview-row { display: grid; grid-template-columns: 64px minmax(0, 1fr) auto; align-items: center; gap: 8px; min-height: 26px; padding: 4px 0; }
.studio-overview-row > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
-.studio-overview-row > b { color: var(--muted); font: var(--w-reg) var(--fs-micro)/1 var(--mono); text-align: right; }
-.studio-overview-row > small { text-align: right; font-family: var(--mono); }
+.studio-overview-row > b { min-width: 52px; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1 var(--ui); text-align: right; }
+.studio-overview-row > i { display: block; height: 6px; overflow: hidden; border-radius: var(--r-xs); background: var(--overlay); }
+.studio-overview-row > i > u { display: block; height: 100%; background: var(--net); text-decoration: none; }
@media (hover: hover) {
.studio-overview-anchor:hover .studio-overview-popover { opacity: 1; visibility: visible; pointer-events: auto; transform: none; transition-delay: 0s; }
.studio-meterrail:has(.studio-context-anchor[data-open]) .studio-overview-anchor:not([data-open]):hover .studio-overview-popover { opacity: 0; visibility: hidden; pointer-events: none; }
@@ -82,3 +82,16 @@
.errbar[data-cause="root-missing"] > span > b { font-weight: var(--w-reg); }
.errbar[data-cause="root-missing"] > span > small { color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); }
.errbar[data-cause="root-missing"] > button { height: 32px; padding: 0 14px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui) var(--ui); text-decoration: none; white-space: nowrap; }
+.studio-overview-foot { display: flex; align-items: center; gap: 8px; margin-top: 6px; padding: 10px 14px; border-top: 1px solid var(--hair); color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.3 var(--ui); }
+.studio-overview-foot > button { margin-left: auto; padding: 0; border: 0; background: none; color: var(--accent-ink); font: inherit; cursor: pointer; }
+.studio-overview-foot > button:hover { text-decoration: underline; text-underline-offset: 2px; }
+.chrome-context-card:not(.studio-overview-card) > .chrome-context-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; padding: 10px 14px; border-top: 1px solid var(--hair); color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.3 var(--ui); }
+.chrome-context-foot > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.chrome-context-foot > .manage { padding: 0; border: 0; background: none; color: var(--accent-ink); font: inherit; cursor: pointer; }
+.chrome-context-foot > .manage:hover { text-decoration: underline; text-underline-offset: 2px; }
+.chrome-context-foot .compact-now:has(.wsconfirm) { order: 5; flex: 1 0 100%; }
+.chrome-context-foot .compact-now > .btn { height: 24px; padding: 0 10px; border: 1px solid transparent; border-radius: var(--r-pill); background: transparent; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1 var(--ui); }
+.chrome-context-foot .compact-now > .btn:hover { background: var(--overlay); color: var(--text); }
+.chrome-context-card .chrome-context-more { display: inline-flex; align-items: center; gap: 5px; height: auto; padding: 0; border: 0; background: none; cursor: pointer; }
+.chrome-context-card .chrome-context-more::after { content: ""; width: 5px; height: 5px; border-top: 1.45px solid var(--faint); border-right: 1.45px solid var(--faint); transform: rotate(45deg); }
+.chrome-context-card .chrome-context-more[aria-expanded="true"]::after { transform: rotate(135deg); }
diff --git a/desktop/frontend-next/src/ui/ContextSummaryCard.tsx b/desktop/frontend-next/src/ui/ContextSummaryCard.tsx
index d74eb17571..cb549bf83b 100644
--- a/desktop/frontend-next/src/ui/ContextSummaryCard.tsx
+++ b/desktop/frontend-next/src/ui/ContextSummaryCard.tsx
@@ -57,13 +57,13 @@ export function ContextSummaryCard({ context, mcp, percent, onManage, compact: a
)}
>
) :
{t("composer.no_context_data_yet")}
}
- {action && }
-
- MCP {ready.length} / {active.length} {t("composer.connected")} · {tools} {t("composer.tools")}
- {active.slice(0, 4).map((server) => {server.name} {server.state === "ready" ? `${server.tools} ${t("composer.tools")}` : t("composer.waiting_to_connect")}
)}
- {!active.length && {t("composer.no_mcp_services_enabled")}
}
- {t("composer.manage_mcp_and_tools")}
-
+
+ `${server.name} · ${server.state === "ready" ? `${server.tools || 0} ${t("composer.tools")}` : t("composer.waiting_to_connect")}`).join("\n") || undefined}>
+ MCP · {ready.length} / {active.length} {t("composer.connected")} · {tools} {t("composer.tools")}
+
+ {action && }
+ {t("composer.manage_mcp_and_tools")}
+
);
}
diff --git a/desktop/frontend-next/src/ui/MeterRail.overview.interaction.test.tsx b/desktop/frontend-next/src/ui/MeterRail.overview.interaction.test.tsx
index d075a8c9d4..ace7962d25 100644
--- a/desktop/frontend-next/src/ui/MeterRail.overview.interaction.test.tsx
+++ b/desktop/frontend-next/src/ui/MeterRail.overview.interaction.test.tsx
@@ -67,10 +67,15 @@ describe("the session overview behind the cache meter", () => {
const view = within(card());
expect(view.getByText("自本次打开此会话起;恢复或分叉会重新计数")).toBeTruthy();
expect(view.getByText("请求").closest("div")!.textContent).toContain("3");
- expect(view.getByText("请求").closest("div")!.textContent).toContain("返回了用量与价格的模型响应");
+ expect(view.getByText("请求").closest("div")!.textContent).toContain("主代理 2 · 子代理 0");
expect(view.getByText("Token").closest("div")!.textContent).toContain("8.5k");
- expect(view.getByText("工作时长").closest("div")!.textContent).toContain("1 分 12 秒");
- expect(view.getByText("工作时长").closest("div")!.textContent).toContain("含审批与工具等待");
+ });
+
+ it("puts the work time in the footer, with what it adds up on hover", () => {
+ rail();
+ const foot = card().querySelector(".studio-overview-foot")!;
+ expect(foot.textContent).toContain("工作时长 1 分 12 秒");
+ expect(foot.querySelector("span")?.getAttribute("title")).toContain("含审批与工具等待");
});
it("states the hit rate over what the endpoint split, and says what it left out", () => {
@@ -90,12 +95,13 @@ describe("the session overview behind the cache meter", () => {
it("lists each source with its own cache and cost, and an unpriced one as unknown", () => {
rail();
- const rows = [...card().querySelectorAll(".studio-overview-row")].map((r) => r.textContent);
+ const lines = [...card().querySelectorAll(".studio-overview-row")];
+ const rows = lines.map((r) => r.textContent);
expect(rows[0]).toContain("压缩");
expect(rows[0]).toContain("—");
expect(rows[1]).toContain("主代理");
- expect(rows[1]).toContain("76%");
expect(rows[1]).toContain("¥3.20");
+ expect(lines[1]!.getAttribute("title")).toContain("76%");
const price = card().querySelectorAll(".studio-overview-row")[0]!.querySelector("b[title]");
expect(price?.getAttribute("title")).toBe("价格未知:no_rate");
});
@@ -130,30 +136,16 @@ describe("the session overview behind the cache meter", () => {
expect(button().textContent).toContain("76%");
});
- it("shows the context window in the same card, as the next request's content and not a running total", () => {
+ it("does not repeat the context window, which has a card of its own", () => {
rail();
- const section = within(card()).getByRole("region", { name: "上下文窗口" });
- expect(section.textContent).toContain("42k");
- expect(section.textContent).toContain("128k");
- expect(section.textContent).toContain("33%");
- expect(section.textContent).toContain("下一次请求会带上的内容,是当前值,不是累计");
- expect(section.querySelectorAll("[data-part]").length).toBe(5);
- });
-
- it("states the automatic fold line only when the kernel has one", () => {
- rail();
- expect(within(card()).getByRole("region", { name: "上下文窗口" }).textContent).toContain("自动压缩于 96k");
- cleanup();
- rail({ ctx: { ...context, compact_at: 0 } });
- expect(within(card()).getByRole("region", { name: "上下文窗口" }).textContent).not.toContain("自动压缩于");
- });
-
- it("keeps the window section when no request was billed, and omits it when the kernel sent none", () => {
- rail({ usage: { ...usage, requests: 0, sources: [] } });
- expect(within(card()).getByRole("region", { name: "上下文窗口" })).toBeTruthy();
- cleanup();
- rail({ ctx: null });
expect(within(card()).queryByRole("region", { name: "上下文窗口" })).toBeNull();
+ expect(card().querySelector("[data-part]")).toBeNull();
+ });
+
+ it("draws each source as a bar sized by what it cost", () => {
+ rail();
+ const bars = [...card().querySelectorAll(".studio-overview-row > i > u")].map((e) => (e as HTMLElement).style.width);
+ expect(bars).toEqual(["0%", "100%"]);
});
it("hands over to the context details without leaving both cards open", async () => {
diff --git a/desktop/frontend-next/src/ui/MeterRail.tsx b/desktop/frontend-next/src/ui/MeterRail.tsx
index 29a7a80019..181aa0cbee 100644
--- a/desktop/frontend-next/src/ui/MeterRail.tsx
+++ b/desktop/frontend-next/src/ui/MeterRail.tsx
@@ -92,7 +92,7 @@ export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compa
{ setMeterOpen(false); setOverviewOpen((open) => !open); }}>
{t("composer.cache")} {cacheRate === null ? "—" : `${cacheRate}%`}
- 0 ? () => { setOverviewOpen(false); setMeterOpen(true); } : undefined} />
+ 0 ? () => { setOverviewOpen(false); setMeterOpen(true); } : undefined} />
{ctx && ctx.window > 0 && (
diff --git a/desktop/frontend-next/src/ui/SessionOverviewCard.tsx b/desktop/frontend-next/src/ui/SessionOverviewCard.tsx
index c0dbddfea6..1ea8d07204 100644
--- a/desktop/frontend-next/src/ui/SessionOverviewCard.tsx
+++ b/desktop/frontend-next/src/ui/SessionOverviewCard.tsx
@@ -1,6 +1,5 @@
import { t } from "../i18n";
import { money } from "../i18n/format";
-import type { ContextBreakdown } from "../port/shell";
import type { SessionUsage } from "../port/session";
import type { CostQuote } from "../port/wire";
import { cacheRate, costSay, sessionTokens, sourceTokens, type CostSay } from "../state/overview";
@@ -48,9 +47,8 @@ function cost(say: CostSay, hide: boolean): { text: string; note: string } {
// The session as the kernel's ledger holds it, each figure beside what it counts.
// Everything here is since this session was opened in this run: a resumed or
// forked session counts again from the start.
-export function SessionOverviewCard({ usage, quote, ctx, hideAmounts, onContext, className = "" }: {
+export function SessionOverviewCard({ usage, quote, hideAmounts, onContext, className = "" }: {
usage?: SessionUsage;
- ctx?: ContextBreakdown | null;
onContext?: () => void;
quote?: CostQuote;
hideAmounts: boolean;
@@ -58,6 +56,10 @@ export function SessionOverviewCard({ usage, quote, ctx, hideAmounts, onContext,
}) {
const total = cost(costSay(quote), hideAmounts);
const unknown = usage?.cacheUnknown ?? 0;
+ const sources = usage?.sources ?? [];
+ const asked = (id: string) => sources.find((src) => src.source === id)?.requests ?? 0;
+ const priced = sources.map((src) => { const say = costSay(src.cost); return say.kind === "exact" ? say.amount : 0; });
+ const pricedTotal = priced.reduce((n, v) => n + v, 0);
return (
{t("session.session_overview")} {t("session.since_this_session_was_opened")}
@@ -66,49 +68,39 @@ export function SessionOverviewCard({ usage, quote, ctx, hideAmounts, onContext,
) : (
<>
-
{t("common.requests")} {usage.requests} {t("session.model_responses_that_reported_usage")}
+
+
{t("common.requests")} {usage.requests}
+ {asked("executor") + asked("subagent") > 0 ? t("session.main_n_sub_m", { a: asked("executor"), b: asked("subagent") }) : t("session.model_responses_that_reported_usage")}
+
+
{t("session.cost")} {total.text} {total.note && {total.note} }
{t("Token")} {tokens(sessionTokens(usage))} {t("session.in_a_out_b", { a: tokens(usage.promptTokens), b: tokens(usage.completionTokens) })}
{t("common.cache_hit")} {percent(cacheRate(usage.cacheHit, usage.cacheMiss, unknown))}
{unknown > 0 ? t("session.hit_hit_miss_leaves_out", { n: tokens(unknown) }) : t("session.hit_hit_miss")}
-
{t("session.work_time")} {clock(usage.workMs)} {t("session.sum_of_the_turns_that")}
-
{t("session.cost")} {total.text} {total.note && {total.note} }
-
- {t("common.by_source")} {t("session.requests_tokens_cache_cost")}
- {usage.sources.map((src) => {
- const c = cost(costSay(src.cost), hideAmounts);
- return (
-
- {sourceName(src.source)}
- {src.requests}
- {tokens(sourceTokens(src))}
- {percent(cacheRate(src.cacheHit, src.cacheMiss, src.cacheUnknown ?? 0))}
- {c.text}
-
- );
- })}
-
+ {sources.length > 0 && (
+
+ {t("common.by_source")}
+ {sources.map((src, i) => {
+ const c = cost(costSay(src.cost), hideAmounts);
+ const detail = `${t("session.requests_tokens_cache_cost")}: ${src.requests} · ${tokens(sourceTokens(src))} · ${percent(cacheRate(src.cacheHit, src.cacheMiss, src.cacheUnknown ?? 0))} · ${c.text}`;
+ return (
+
+ {sourceName(src.source)}
+ 0 ? Math.max(priced[i] > 0 ? 2 : 0, (priced[i] / pricedTotal) * 100) : 0}%` }} />
+ {c.text}
+
+ );
+ })}
+
+ )}
+
+ {t("session.work_time")} {clock(usage.workMs)}
+ {onContext && {t("common.context_details")} }
+
>
)}
- {ctx && ctx.window > 0 &&
}
);
}
-
-const PARTS = [["sys", "system"], ["tool", "tools"], ["user", "user"], ["reply", "reply"], ["output", "output"]] as const;
-
-// The window is a present reading, not a running total: what the next request would carry.
-function ContextWindow({ ctx, onContext }: { ctx: ContextBreakdown; onContext?: () => void }) {
- return (
-
- {t("common.context_window")} {Math.min(100, Math.round((ctx.used / ctx.window) * 100))}%
- {t("session.what_the_next_request_would")}
- {t("common.used")} {tokens(ctx.used)} {t("common.model_capacity")} {tokens(ctx.window)}
- {PARTS.map(([key, field]) => )}
- {ctx.compact_at > 0 && {t("session.folds_automatically_at_n", { n: tokens(ctx.compact_at) })} }
- {onContext && {t("common.context_details")} }
-
- );
-}