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")}

} - -
+
+ `${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 && } + +
); } 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 - 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)} + + {c.text} +
+ ); + })} +
+ )} +
+ {t("session.work_time")} {clock(usage.workMs)} + {onContext && } +
)} - {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 && } -
- ); -}