Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
463 changes: 454 additions & 9 deletions desktop/ui-next/src/features/sidebar/Sidebar.tsx

Large diffs are not rendered by default.

94 changes: 92 additions & 2 deletions desktop/ui-next/src/features/sidebar/listKit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,96 @@
// - SectionFold 小节折叠:Archive 形小节头(10px 图标行首、无计数),
// 开合走 prefs 契约键持久化,收起即卸载(部分 webview 里 details 收起
// 后嵌套 ul 残留占位空间)。
import { IconArchive, type TablerIcon } from "@tabler/icons-react";
import { IconArchive, IconPin, type TablerIcon } from "@tabler/icons-react";
import { useState, type MouseEvent, type ReactNode } from "react";

import { openMenu, type MenuItem } from "@/lib/contextMenu";
import { t } from "@/lib/i18n";
import type { TokenUsage } from "@/lib/ipc/usageStats";
import { pushEscLayer } from "@/lib/util/escLayer";
import { readFold, writeFold, type FoldKey } from "@/lib/util/prefs";

const fmt = (n: number): string => n.toLocaleString("en-US");

/** 紧凑数字:12.3k / 1.2M(行宽紧,徽标只放得下短格式) */
export const fmtCompact = (n: number): string => {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
return String(n);
};

let tokenPopCleanup: (() => void) | null = null;

function closeTokenPop() {
tokenPopCleanup?.();
}

/** 分组/项目头 token 用量弹窗:命令式 fixed 定位(行容器 overflow-hidden 会裁
* 掉普通 dropdown,与 openMenu 同一套机制)。点击徽标弹出,点外部/Esc 关。 */
export function showTokenPopover(pos: { x: number; y: number }, usage: TokenUsage) {
closeTokenPop();
const backdrop = document.createElement("div");
backdrop.className = "fixed inset-0 z-40";
const box = document.createElement("div");
box.className = "fixed z-50 w-60 rounded-box border border-base-300 bg-base-100 p-3 shadow-lg";

const title = document.createElement("div");
title.className = "mb-1.5 text-xs font-semibold";
title.textContent = t("sidebar.row.tokens");
box.appendChild(title);

const summary = document.createElement("div");
summary.className = "mb-2 text-[11px] text-base-content/70";
summary.textContent = `${t("sidebar.row.tokens.input")} ${fmt(usage.input)} · ${t("sidebar.row.tokens.output")} ${fmt(usage.output)} · ${t("sidebar.row.tokens.calls")} ${fmt(usage.calls)}`;
box.appendChild(summary);

if (usage.models.length > 0) {
const sep = document.createElement("div");
sep.className = "border-t border-base-300 pt-1.5";
const head = document.createElement("div");
head.className = "mb-0.5 text-[10px] text-base-content/50";
head.textContent = t("sidebar.row.tokens.byModel");
sep.appendChild(head);
for (const m of usage.models) {
const row = document.createElement("div");
row.className = "flex items-center justify-between gap-2 text-[11px]";
const name = document.createElement("span");
name.className = "min-w-0 truncate font-mono text-base-content/70";
name.textContent = m.model;
const val = document.createElement("span");
val.className = "shrink-0 tabular-nums text-base-content/60";
val.textContent = fmt(m.input_tokens + m.output_tokens);
row.append(name, val);
sep.appendChild(row);
}
box.appendChild(sep);
}

backdrop.addEventListener("mousedown", closeTokenPop);
backdrop.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
closeTokenPop();
});
const popEsc = pushEscLayer(() => {
closeTokenPop();
return true;
});
window.addEventListener("resize", closeTokenPop);
window.addEventListener("blur", closeTokenPop);
tokenPopCleanup = () => {
tokenPopCleanup = null;
popEsc();
window.removeEventListener("resize", closeTokenPop);
window.removeEventListener("blur", closeTokenPop);
backdrop.remove();
box.remove();
};
document.body.append(backdrop, box);
const rect = box.getBoundingClientRect();
box.style.left = `${Math.max(0, Math.min(pos.x, window.innerWidth - rect.width - 8))}px`;
box.style.top = `${Math.max(0, Math.min(pos.y, window.innerHeight - rect.height - 8))}px`;
}

// 嵌套 ul 的缩进引导竖线:**已撤**(用户定案 2026-08-10「本地会话项目列表的
// 竖线都去掉,包括 archive 的列表」;三列表同取此件,云端/对话一并去,§6.2
// 「不做两套」)。层级只剩缩进 + 组头小标签。
Expand Down Expand Up @@ -75,6 +159,7 @@ export function levelPad(level = 0): string {
export function ListRow({
primary,
trailing,
pinned,
tooltip,
level = 0,
active,
Expand All @@ -88,6 +173,8 @@ export function ListRow({
* (用户定案 2026-08-05「文字换状态图标」),进点的 title/aria-label。
* pulse = 进行中的活态(运行中/等待确认),渲染成「实心点 + 扩散环」 */
trailing?: { tone: string; label: string; pulse?: boolean } | null;
/** 置顶:行首小图钉标记 */
pinned?: boolean;
tooltip: string;
/** 缩进级(见 LEVELS):0 = 平铺行,1 = 项目内任务行,依此类推 */
level?: number;
Expand Down Expand Up @@ -117,7 +204,10 @@ export function ListRow({
}}
>
{/* 活跃行走正文色(不覆写);归档降到 /55,选中态不降——选中就该看清 */}
<span className={`min-w-0 flex-1 truncate ${archived && !active ? "text-base-content/55" : ""}`}>{primary}</span>
<span className={`min-w-0 flex-1 truncate ${archived && !active ? "text-base-content/55" : ""}`}>
{pinned && <IconPin size={10} stroke={2} className="-mt-px me-0.5 inline text-warning" aria-hidden />}
{primary}
</span>
{trailing && <StatusDot {...trailing} />}
</a>
</li>
Expand Down
18 changes: 18 additions & 0 deletions desktop/ui-next/src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ export const en: Record<MessageKey, string> = {
"sidebar.overview.queued": "{n} queued",
"sidebar.archivedTasks": "Archived tasks",
"sidebar.archivedChats": "Archived chats",
"sidebar.row.tokens": "Token usage",
"sidebar.row.tokens.input": "Input",
"sidebar.row.tokens.output": "Output",
"sidebar.row.tokens.calls": "Calls",
"sidebar.row.tokens.byModel": "By model",
"sidebar.group.title": "Custom groups",
"sidebar.group.new": "New group",
"sidebar.group.name": "Group name",
"sidebar.group.hint": "Right-click to manage",
"sidebar.group.pinProject": "Pin project",
"sidebar.group.unpin": "Unpin",
"sidebar.group.pinned": "Pinned",
"sidebar.group.delete": "Delete group",
"sidebar.group.rename": "Rename group",
"sidebar.group.moveTo": "Move to group",
"sidebar.group.moveOut": "Move out of group",
"sidebar.group.dropHint": "Drop a project here to move it into this group",
"sidebar.group.dropOut": "Drop here to remove from group",
"sidebar.archivedProjects": "Archived projects",
"sidebar.empty.local.title": "No local projects yet",
"sidebar.empty.local.detail": "Pick a folder to start your first local task.",
Expand Down
18 changes: 18 additions & 0 deletions desktop/ui-next/src/lib/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,24 @@ export const zh = {
"sidebar.overview.queued": "{n} 排队中",
"sidebar.archivedTasks": "已归档任务",
"sidebar.archivedChats": "已归档会话",
"sidebar.row.tokens": "Token 用量",
"sidebar.row.tokens.input": "输入",
"sidebar.row.tokens.output": "输出",
"sidebar.row.tokens.calls": "调用次数",
"sidebar.row.tokens.byModel": "按模型",
"sidebar.group.title": "自定义分组",
"sidebar.group.new": "新建分组",
"sidebar.group.name": "分组名称",
"sidebar.group.hint": "右键管理分组",
"sidebar.group.pinProject": "置顶项目",
"sidebar.group.unpin": "取消置顶",
"sidebar.group.pinned": "已置顶",
"sidebar.group.delete": "删除分组",
"sidebar.group.rename": "重命名分组",
"sidebar.group.moveTo": "移到分组",
"sidebar.group.moveOut": "移出分组",
"sidebar.group.dropHint": "拖拽项目到此分组放下",
"sidebar.group.dropOut": "拖到此处移出分组",
"sidebar.archivedProjects": "已归档项目",
"sidebar.empty.local.title": "还没有本地项目",
"sidebar.empty.local.detail": "选择一个文件夹,开始第一个本地任务。",
Expand Down
44 changes: 44 additions & 0 deletions desktop/ui-next/src/lib/ipc/usageStats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";

import { buildSessionUsageMap, sumUsage, type TokenUsage } from "./usageStats";

const sess = (session_id: string, parent: string | null, input: number, output: number, calls: number, model: string) => ({
session_id,
parent,
title: session_id,
input_tokens: input,
output_tokens: output,
calls,
days: [],
models: [{ model, input_tokens: input, output_tokens: output, calls }],
});

describe("buildSessionUsageMap", () => {
it("子代理归并进父任务:父任务总量含子会话", () => {
const map = buildSessionUsageMap([
sess("parent", null, 100, 50, 2, "gpt-5"),
sess("child", "parent", 300, 200, 5, "gpt-5"),
]);
const parent = map.get("parent")!;
expect(parent.input).toBe(400); // 100 + 300
expect(parent.output).toBe(250);
expect(parent.calls).toBe(7);
expect(parent.models[0]?.input_tokens).toBe(400);
// 子会话自身也有条目
expect(map.get("child")!.input).toBe(300);
});
});

describe("sumUsage", () => {
it("多会话合并成合计;无用量返回 null", () => {
const map = new Map<string, TokenUsage>([
["a", { input: 100, output: 50, calls: 2, models: [] }],
["b", { input: 300, output: 200, calls: 5, models: [] }],
]);
const total = sumUsage(["a", "b"], map)!;
expect(total.input).toBe(400);
expect(total.output).toBe(250);
expect(total.calls).toBe(7);
expect(sumUsage(["nope"], map)).toBeNull();
});
});
122 changes: 122 additions & 0 deletions desktop/ui-next/src/lib/ipc/usageStats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// 本地会话 token 用量统计(壳侧 usage 事件记账,按天/会话/模型聚合)。
// 浏览器模式无此能力:列表类返回空聚合(静态事实)。
import { inDesktopShell, invoke } from "./ipc";

export interface UsageStats {
totals: Bucket;
/** 按天汇总,倒序 */
days: (DayRow & Bucket)[];
/** 按模型汇总,用量倒序 */
models: ModelRow[];
/** 按会话汇总,用量倒序;子代理会话带 parent 可归并到父任务 */
sessions: SessionRow[];
}

export interface Bucket {
input_tokens: number;
output_tokens: number;
calls: number;
}

export interface DayRow extends Bucket {
date: string;
}

export interface ModelRow extends Bucket {
model: string;
}

export interface SessionRow extends Bucket {
session_id: string;
title: string;
/** 子代理会话的父会话 id;顶层任务为 null */
parent: string | null;
days: (DayRow & Bucket)[];
models: ModelRow[];
}

/** 壳内失败会抛(引擎重启时降级成空聚合会把面板洗成"全零")。 */
export async function usageStats(): Promise<UsageStats> {
if (!inDesktopShell()) {
return { totals: { input_tokens: 0, output_tokens: 0, calls: 0 }, days: [], models: [], sessions: [] };
}
return invoke<UsageStats>("usage_stats");
}

/** 行尾/头部展示用的 token 用量(子代理已归并进父任务)。 */
export interface TokenUsage {
input: number;
output: number;
calls: number;
models: { model: string; input_tokens: number; output_tokens: number; calls: number }[];
}

interface UsageAgg {
input: number;
output: number;
calls: number;
models: Map<string, { input: number; output: number; calls: number }>;
}

function addAgg(m: Map<string, UsageAgg>, sid: string, s: SessionRow) {
let agg = m.get(sid);
if (!agg) {
agg = { input: 0, output: 0, calls: 0, models: new Map() };
m.set(sid, agg);
}
agg.input += s.input_tokens;
agg.output += s.output_tokens;
agg.calls += s.calls;
for (const md of s.models) {
const cur = agg.models.get(md.model);
agg.models.set(md.model, {
input: (cur?.input ?? 0) + md.input_tokens,
output: (cur?.output ?? 0) + md.output_tokens,
calls: (cur?.calls ?? 0) + md.calls,
});
}
}

const aggToUsage = (a: UsageAgg): TokenUsage => ({
input: a.input,
output: a.output,
calls: a.calls,
models: a.models.size
? [...a.models.entries()].map(([model, v]) => ({ model, input_tokens: v.input, output_tokens: v.output, calls: v.calls }))
: [],
});

/** 把 usage_stats 快照聚合为「会话 id → 用量」;子代理(parent 非空)归并进父任务。 */
export function buildSessionUsageMap(sessions: UsageStats["sessions"]): Map<string, TokenUsage> {
const m = new Map<string, UsageAgg>();
for (const s of sessions) {
addAgg(m, s.session_id, s);
if (s.parent) addAgg(m, s.parent, s);
}
const out = new Map<string, TokenUsage>();
for (const [sid, agg] of m) out.set(sid, aggToUsage(agg));
return out;
}

/** 把一组会话 id 的用量合并成一个合计(文件夹级展示用)。 */
export function sumUsage(ids: Iterable<string>, map: ReadonlyMap<string, TokenUsage>): TokenUsage | null {
const agg: UsageAgg = { input: 0, output: 0, calls: 0, models: new Map() };
let any = false;
for (const id of ids) {
const u = map.get(id);
if (!u) continue;
any = true;
agg.input += u.input;
agg.output += u.output;
agg.calls += u.calls;
for (const md of u.models) {
const cur = agg.models.get(md.model);
agg.models.set(md.model, {
input: (cur?.input ?? 0) + md.input_tokens,
output: (cur?.output ?? 0) + md.output_tokens,
calls: (cur?.calls ?? 0) + md.calls,
});
}
}
return any ? aggToUsage(agg) : null;
}
2 changes: 1 addition & 1 deletion desktop/ui-next/src/lib/layoutContract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function sources(dir: string = SRC): string[] {
return out;
}

const rel = (p: string) => p.slice(SRC.length + 1);
const rel = (p: string) => p.slice(SRC.length + 1).replace(/\\/g, "/");

describe("LAYOUT §6.2 menu 截断铁律", () => {
// daisyUI 5 的 `.menu` **和** `.menu :where(li)` 都是 `flex-flow: column wrap`
Expand Down
Loading