Skip to content
Merged
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
144 changes: 144 additions & 0 deletions desktop/renderer/src/components/SidePanel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { flushPromises, mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setLocale, t } from "@/i18n";
import SidePanel from "./SidePanel.vue";
import sidePanelSource from "./SidePanel.vue?raw";

const mocks = vi.hoisted(() => ({
getStats: vi.fn(),
push: vi.fn(),
gateway: {
ready: true,
status: "running",
refreshWeixinStatus: vi.fn(),
},
chat: {
wsConnected: true,
messages: [],
sessionKey: "test-session",
currentSessionAgentId: "main",
sessionTitleRefreshRevision: 0,
},
sessions: {
sessions: [],
setGatewayTitles: vi.fn(),
},
agents: {
currentAgentId: "main",
agents: [{ id: "main", name: "MicroClaw", avatar: "" }],
},
}));

vi.mock("vue-router", () => ({
useRouter: () => ({ push: mocks.push }),
useRoute: () => ({ name: "chat", path: "/chat/main" }),
}));
vi.mock("@/stores/gateway", () => ({ useGatewayStore: () => mocks.gateway }));
vi.mock("@/stores/chat", () => ({ useChatStore: () => mocks.chat }));
vi.mock("@/stores/sessions", () => ({ useSessionStore: () => mocks.sessions }));
vi.mock("@/stores/agents", () => ({ useAgentStore: () => mocks.agents }));

const wrappers: Array<ReturnType<typeof mount>> = [];

beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
localStorage.clear();
setLocale("en-US");
mocks.getStats.mockResolvedValue({
totalSpend: 42.35,
totalTokens: 4_400_000,
exchangeRate: 7.2,
});
vi.stubGlobal("openclaw", { usage: { getStats: mocks.getStats } });
});

afterEach(() => {
wrappers.splice(0).forEach((wrapper) => wrapper.unmount());
localStorage.clear();
setLocale("en-US");
vi.unstubAllGlobals();
vi.useRealTimers();
});

async function mountPanel() {
const wrapper = mount(SidePanel);
wrappers.push(wrapper);
await flushPromises();
return wrapper;
}

describe("SidePanel token-only usage snapshot", () => {
it.each([
{ locale: "en-US" as const, spendLabel: "Spend" },
{ locale: "zh-CN" as const, spendLabel: "花费" },
])("shows tokens without a monetary card in $locale", async ({ locale, spendLabel }) => {
setLocale(locale);
const wrapper = await mountPanel();
const snapshot = wrapper.get(".sp-usage-snapshot");

expect(snapshot.findAll(".sp-usage-card")).toHaveLength(1);
expect(snapshot.get(".sp-usage-label").text()).toBe("Tokens");
expect(snapshot.get(".sp-usage-value").text()).toBe("4.4M");
expect(snapshot.text()).not.toContain(spendLabel);
expect(snapshot.text()).not.toMatch(/[¥¥$]/);
expect(snapshot.get(".sp-usage-foot").text()).toContain(t("sidebar.usageSnapshotPeriod"));
});

it("keeps the token card visible when usage is zero", async () => {
mocks.getStats.mockResolvedValue({ totalSpend: 0, totalTokens: 0 });
const wrapper = await mountPanel();

expect(wrapper.findAll(".sp-usage-card")).toHaveLength(1);
expect(wrapper.get(".sp-usage-value").text()).toBe("0");
});

it("refreshes tokens and stops polling when unmounted", async () => {
const wrapper = await mountPanel();
mocks.getStats.mockResolvedValue({ totalSpend: 99, totalTokens: 5_000_000 });

await vi.advanceTimersByTimeAsync(60_000);
expect(mocks.getStats).toHaveBeenCalledTimes(2);
expect(wrapper.get(".sp-usage-value").text()).toBe("5M");
expect(wrapper.findAll(".sp-usage-card")).toHaveLength(1);

wrappers.splice(wrappers.indexOf(wrapper), 1);
wrapper.unmount();
await vi.advanceTimersByTimeAsync(60_000);
expect(mocks.getStats).toHaveBeenCalledTimes(2);
});

it("preserves the saved collapse preference and expands to tokens only", async () => {
localStorage.setItem("microclaw:usageSnapshotCollapsed", "1");
const wrapper = await mountPanel();
const toggle = wrapper.get(".sp-usage-toggle");

expect(wrapper.find(".sp-usage-card").exists()).toBe(false);
expect(toggle.attributes("aria-label")).toBe(t("sidebar.usageSnapshotExpand"));

await toggle.trigger("click");
expect(localStorage.getItem("microclaw:usageSnapshotCollapsed")).toBe("0");
expect(wrapper.findAll(".sp-usage-card")).toHaveLength(1);
expect(toggle.attributes("aria-label")).toBe(t("sidebar.usageSnapshotCollapse"));

await toggle.trigger("click");
expect(localStorage.getItem("microclaw:usageSnapshotCollapsed")).toBe("1");
expect(wrapper.find(".sp-usage-card").exists()).toBe(false);
});

it("keeps chat creation and settings navigation available", async () => {
const wrapper = await mountPanel();

await wrapper.get(".sp-create-btn").trigger("click");
expect(mocks.push).toHaveBeenLastCalledWith("/chat/main");
await wrapper.get(".sp-menu-item").trigger("click");
expect(mocks.push).toHaveBeenLastCalledWith("/settings");
});

it("uses one full-width column for the remaining token card", () => {
const gridRule = sidePanelSource.match(/\.sp-usage-grid\s*\{(?<body>[\s\S]*?)\}/)?.groups
?.body;

expect(gridRule).toMatch(/grid-template-columns:\s*minmax\(0,\s*1fr\)/);
});
});
21 changes: 1 addition & 20 deletions desktop/renderer/src/components/SidePanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,6 @@
<template v-if="!usageCollapsed">
<div v-if="usageLoading" class="sp-usage-state">{{ t("settings.usageLoading") }}</div>
<div v-else-if="usageSnapshot" class="sp-usage-grid">
<div class="sp-usage-card">
<span class="sp-usage-label">{{ t("sidebar.usageSnapshotSpend") }}</span>
<span class="sp-usage-value">{{ formatCny(usageSnapshot.totalSpend) }}</span>
</div>
<div class="sp-usage-card">
<span class="sp-usage-label">{{ t("sidebar.usageSnapshotTokens") }}</span>
<span class="sp-usage-value">{{ formatCompact(usageSnapshot.totalTokens) }}</span>
Expand Down Expand Up @@ -226,14 +222,9 @@ const sessionStore = useSessionStore();
const agentStore = useAgentStore();

interface UsageSnapshot {
totalSpend: number;
totalTokens: number;
exchangeRate: number | null;
}

// Fallback mirrors USD_TO_CNY_FALLBACK_RATE in SettingsView.vue / the main process.
const USD_TO_CNY_FALLBACK_RATE = 7.2;

const usageSnapshot = ref<UsageSnapshot | null>(null);
const usageLoading = ref(false);
const usageLastUpdated = ref<Date | null>(null);
Expand Down Expand Up @@ -308,9 +299,7 @@ async function loadUsageSnapshot() {

const stats = await usageApi();
usageSnapshot.value = {
totalSpend: Number(stats?.totalSpend || 0),
totalTokens: Number(stats?.totalTokens || 0),
exchangeRate: Number(stats?.exchangeRate) || null,
};
usageLastUpdated.value = new Date();
} catch {
Expand All @@ -326,9 +315,7 @@ async function loadUsageSnapshot() {

function applyMockSnapshot() {
usageSnapshot.value = {
totalSpend: 42.35,
totalTokens: 128400,
exchangeRate: null,
};
usageLastUpdated.value = new Date();
}
Expand All @@ -347,11 +334,6 @@ function formatCompact(value: number) {
}).format(value || 0);
}

function formatCny(value: number) {
const rate = usageSnapshot.value?.exchangeRate ?? USD_TO_CNY_FALLBACK_RATE;
return `${t("settings.currencySymbol")}${((value || 0) * rate).toFixed(2)}`;
}

/**
* Ensure the active chat is a fresh session bound to the currently-selected agent.
* Starts a new session when the current chat already has messages, OR when it
Expand Down Expand Up @@ -807,8 +789,7 @@ html.dark .sp-usage-toggle:hover {

.sp-usage-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
grid-template-columns: minmax(0, 1fr);
}

.sp-usage-card {
Expand Down
1 change: 0 additions & 1 deletion desktop/renderer/src/i18n/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ export default {
"sidebar.tagline": "Weave the future through conversation",
"sidebar.usageSnapshotTitle": "Usage",
"sidebar.usageSnapshotViewDetails": "Details",
"sidebar.usageSnapshotSpend": "Spend",
"sidebar.usageSnapshotTokens": "Tokens",
"sidebar.usageSnapshotRequests": "Requests",
"sidebar.usageSnapshotHealth": "Health",
Expand Down
1 change: 0 additions & 1 deletion desktop/renderer/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ export default {
"sidebar.tagline": "透过对话,编织万物未来",
"sidebar.usageSnapshotTitle": "使用情况",
"sidebar.usageSnapshotViewDetails": "查看详情",
"sidebar.usageSnapshotSpend": "花费",
"sidebar.usageSnapshotTokens": "Tokens",
"sidebar.usageSnapshotRequests": "请求",
"sidebar.usageSnapshotHealth": "状态",
Expand Down
Loading