Skip to content

Commit 2f01338

Browse files
authored
feat(ui): add settings profile page with lifetime token stats, streaks, and activity heatmap (#102842)
* feat(ui): add settings profile i18n strings and regenerated locale bundles * feat(ui): add settings profile page with token stats, streak heatmap, and top tools * chore(ui): record intentional OpenClaw brand string in raw-copy baseline
1 parent 2e9e46b commit 2f01338

66 files changed

Lines changed: 2858 additions & 87 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/web/control-ui.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ A **Search** field at the top of the sidebar opens the command palette (⌘K). T
153153
</Accordion>
154154
<Accordion title="Config">
155155
- View/edit `~/.openclaw/openclaw.json` (`config.get`, `config.set`).
156+
- Profile: a settings page showing the default agent's identity with all-time usage stats — lifetime tokens, peak day, longest session, activity streaks, a year-long token heatmap, top tools, and channel highlights (`usage.cost`, `sessions.usage`).
156157
- MCP has a dedicated settings page for configured servers, enablement, OAuth/filter/parallel summaries, common operator commands, and the scoped `mcp` config editor.
157158
- Apply and restart with validation (`config.apply`), then wake the last active session.
158159
- Writes include a base-hash guard to prevent clobbering concurrent edits.

scripts/control-ui-mock-dev.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,114 @@ function buildSearchSessionListCases(
164164
return searchTerms.flatMap((search) => buildSessionListCases(sessions, { search }));
165165
}
166166

167+
function usageCostTotals(totalTokens: number, totalCost = 0) {
168+
return {
169+
input: Math.round(totalTokens * 0.2),
170+
output: Math.round(totalTokens * 0.1),
171+
cacheRead: Math.round(totalTokens * 0.6),
172+
cacheWrite: Math.round(totalTokens * 0.1),
173+
totalTokens,
174+
totalCost,
175+
inputCost: totalCost,
176+
outputCost: 0,
177+
cacheReadCost: 0,
178+
cacheWriteCost: 0,
179+
missingCostEntries: 0,
180+
};
181+
}
182+
183+
// Deterministic year of daily activity so the settings profile heatmap,
184+
// streaks, and stat strip render with a lively fixture in the mock harness.
185+
function buildProfileUsageMocks(baseTime: number) {
186+
const daily: Array<Record<string, unknown>> = [];
187+
let lifetimeTokens = 0;
188+
for (let daysAgo = 364; daysAgo >= 0; daysAgo -= 1) {
189+
const date = new Date(baseTime - daysAgo * 24 * 60 * 60 * 1000);
190+
const iso = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
191+
const weekendDamper = date.getDay() === 0 || date.getDay() === 6 ? 0.3 : 1;
192+
const quietDay = daysAgo % 19 === 4 ? 0 : 1;
193+
const wave = (Math.sin(daysAgo / 6) + 1.4) * 1_400_000_000;
194+
const spike = daysAgo % 47 === 0 ? 6_000_000_000 : 0;
195+
const tokens = Math.round((wave + spike) * weekendDamper * quietDay);
196+
lifetimeTokens += tokens;
197+
daily.push({ date: iso, ...usageCostTotals(tokens, tokens / 1e9) });
198+
}
199+
return {
200+
cost: {
201+
updatedAt: baseTime,
202+
days: daily.length,
203+
daily,
204+
totals: usageCostTotals(lifetimeTokens, lifetimeTokens / 1e9),
205+
},
206+
sessions: {
207+
updatedAt: baseTime,
208+
startDate: daily[0]?.date,
209+
endDate: daily[daily.length - 1]?.date,
210+
sessions: [
211+
{
212+
key: "agent:openclaw-mock:marathon",
213+
label: "Release night marathon",
214+
usage: { ...usageCostTotals(4_000_000_000), durationMs: (59 * 60 + 4) * 60 * 1000 },
215+
},
216+
{
217+
key: "agent:openclaw-mock:daily",
218+
label: "Daily driver",
219+
usage: { ...usageCostTotals(900_000_000), durationMs: 3 * 60 * 60 * 1000 },
220+
},
221+
],
222+
totals: usageCostTotals(lifetimeTokens, lifetimeTokens / 1e9),
223+
aggregates: {
224+
sessionCount: 48_212,
225+
longestSessionDurationMs: (59 * 60 + 4) * 60 * 1000,
226+
messages: {
227+
total: 2_787_815,
228+
user: 1_400_000,
229+
assistant: 1_387_815,
230+
toolCalls: 42_380,
231+
toolResults: 42_380,
232+
errors: 128,
233+
},
234+
tools: {
235+
totalCalls: 42_380,
236+
uniqueTools: 205,
237+
tools: [
238+
{ name: "exec", count: 6_418 },
239+
{ name: "browser", count: 5_256 },
240+
{ name: "message", count: 4_708 },
241+
{ name: "read", count: 4_489 },
242+
{ name: "sessions_list", count: 3_066 },
243+
],
244+
},
245+
byModel: [
246+
{
247+
provider: "anthropic",
248+
model: "claude-sonnet-4-6",
249+
count: 9_000,
250+
totals: usageCostTotals(Math.round(lifetimeTokens * 0.7)),
251+
},
252+
{
253+
provider: "openai",
254+
model: "gpt-5.5",
255+
count: 4_000,
256+
totals: usageCostTotals(Math.round(lifetimeTokens * 0.3)),
257+
},
258+
],
259+
byProvider: [],
260+
byAgent: [
261+
{ agentId: "openclaw-mock", totals: usageCostTotals(Math.round(lifetimeTokens * 0.8)) },
262+
{ agentId: "alpha", totals: usageCostTotals(Math.round(lifetimeTokens * 0.2)) },
263+
],
264+
byChannel: [
265+
{ channel: "whatsapp", totals: usageCostTotals(Math.round(lifetimeTokens * 0.5)) },
266+
{ channel: "telegram", totals: usageCostTotals(Math.round(lifetimeTokens * 0.3)) },
267+
{ channel: "discord", totals: usageCostTotals(Math.round(lifetimeTokens * 0.2)) },
268+
],
269+
daily: [],
270+
},
271+
},
272+
};
273+
}
274+
167275
function chatHistoryMessage(role: "assistant" | "user", text: string, timestamp: number) {
168276
return {
169277
content: [{ text, type: "text" }],
@@ -441,12 +549,17 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
441549
model: "claude-sonnet-4-6",
442550
modelProvider: "anthropic",
443551
});
552+
// Profile fixtures track the real clock so streaks and the trailing-year
553+
// heatmap stay filled no matter when the mock harness runs.
554+
const profileUsage = buildProfileUsageMocks(Date.now());
444555
return {
445556
assistantAgentId: "openclaw-mock",
446557
assistantName: "OpenClaw mock",
447558
defaultAgentId: "openclaw-mock",
448559
historyMessages: buildScrollableChatHistory(baseTime),
449560
methodResponses: {
561+
"usage.cost": profileUsage.cost,
562+
"sessions.usage": profileUsage.sessions,
450563
"device.pair.list": { paired: [], pending: [] },
451564
"device.pair.setupCode": {
452565
auth: "token",

src/gateway/server-methods/usage.sessions-usage.test.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -768,25 +768,45 @@ describe("sessions.usage", () => {
768768
{ sessionId: "s-a", sessionFile: "/tmp/agents/main/sessions/s-a.jsonl", mtime: 300 },
769769
{ sessionId: "s-b", sessionFile: "/tmp/agents/main/sessions/s-b.jsonl", mtime: 200 },
770770
{ sessionId: "s-c", sessionFile: "/tmp/agents/main/sessions/s-c.jsonl", mtime: 100 },
771+
// Discovered because its mtime is past range start, but all of its
772+
// activity got filtered out of the requested window.
773+
{ sessionId: "s-late", sessionFile: "/tmp/agents/main/sessions/s-late.jsonl", mtime: 50 },
771774
])
772775
.mockResolvedValueOnce([]); // second agent (opus) — no extra sessions
773776

774777
const buildUsage = (sessionId?: string) => {
775-
const cost = sessionId === "s-a" ? 0.08 : sessionId === "s-b" ? 0.04 : 0.02;
776-
const tokens = sessionId === "s-a" ? 15 : sessionId === "s-b" ? 10 : 5;
777-
return {
778-
input: tokens,
778+
const emptyUsage = {
779+
input: 0,
779780
output: 0,
780781
cacheRead: 0,
781782
cacheWrite: 0,
782-
totalTokens: tokens,
783-
totalCost: cost,
783+
totalTokens: 0,
784+
totalCost: 0,
784785
inputCost: 0,
785786
outputCost: 0,
786787
cacheReadCost: 0,
787788
cacheWriteCost: 0,
788789
missingCostEntries: 0,
789790
};
791+
if (sessionId === "s-late") {
792+
// Range-filtered summary with no in-range entries: zero counts, no
793+
// first/last activity. Must not count as an active session.
794+
return emptyUsage;
795+
}
796+
const cost = sessionId === "s-a" ? 0.08 : sessionId === "s-b" ? 0.04 : 0.02;
797+
const tokens = sessionId === "s-a" ? 15 : sessionId === "s-b" ? 10 : 5;
798+
// Longest span lives on the oldest active session (s-c), which the limit
799+
// hides from the page, so the aggregate must not depend on visible rows.
800+
// durationMs is derived from first/last activity during summary merge.
801+
const lastActivity = sessionId === "s-c" ? 90_000 : 5_000;
802+
return {
803+
...emptyUsage,
804+
input: tokens,
805+
totalTokens: tokens,
806+
totalCost: cost,
807+
firstActivity: 0,
808+
lastActivity,
809+
};
790810
};
791811
vi.mocked(loadSessionCostSummariesFromCache).mockImplementation(async ({ sessions }) => {
792812
return {
@@ -811,6 +831,7 @@ describe("sessions.usage", () => {
811831
const result = mockArg(respond, 0, 1) as {
812832
sessions: Array<{ key: string }>;
813833
totals: { totalCost: number; totalTokens: number };
834+
aggregates: { sessionCount?: number; longestSessionDurationMs?: number };
814835
};
815836

816837
// Only the most-recent session (s-a, mtime=300) appears in the page
@@ -823,5 +844,10 @@ describe("sessions.usage", () => {
823844
// But aggregate totals must include all 3 sessions (0.08 + 0.04 + 0.02 = 0.14)
824845
expect(result.totals.totalCost).toBeCloseTo(0.14);
825846
expect(result.totals.totalTokens).toBe(30);
847+
// Aggregate session stats also cover hidden rows: the longest duration
848+
// belongs to s-c, which the page dropped. s-late was discovered but has no
849+
// in-range activity, so it stays out of the count.
850+
expect(result.aggregates.sessionCount).toBe(3);
851+
expect(result.aggregates.longestSessionDurationMs).toBe(90_000);
826852
});
827853
});

src/gateway/server-methods/usage.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,12 +1309,23 @@ export const usageHandlers: GatewayRequestHandlers = {
13091309
}
13101310
}
13111311

1312+
// Track session-level aggregates across every matched session, so profile
1313+
// stats stay correct when the row list is truncated by `limit`.
1314+
let longestSessionDurationMs = 0;
1315+
let activeSessionCount = 0;
1316+
13121317
for (const [entryIndex, merged] of mergedEntries.entries()) {
13131318
const agentId = merged.agentId;
13141319
const usage = usageByEntryIndex[entryIndex];
13151320

13161321
if (usage) {
13171322
addCostUsageTotals(aggregateTotals, usage);
1323+
longestSessionDurationMs = Math.max(longestSessionDurationMs, usage.durationMs ?? 0);
1324+
// Discovery admits transcripts modified after endMs (they can still hold
1325+
// in-range activity), so count only sessions whose filtered usage does.
1326+
if (usage.firstActivity !== undefined || (usage.messageCounts?.total ?? 0) > 0) {
1327+
activeSessionCount += 1;
1328+
}
13181329
}
13191330

13201331
const channel = merged.storeEntry?.channel ?? merged.storeEntry?.origin?.provider;
@@ -1471,6 +1482,8 @@ export const usageHandlers: GatewayRequestHandlers = {
14711482
});
14721483

14731484
const aggregates: SessionsUsageAggregates = {
1485+
sessionCount: activeSessionCount,
1486+
...(longestSessionDurationMs > 0 ? { longestSessionDurationMs } : {}),
14741487
messages: aggregateMessages,
14751488
tools: {
14761489
totalCalls: Array.from(toolAggregateMap.values()).reduce((sum, count) => sum + count, 0),

src/shared/usage-types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ export type SessionUsageEntry = {
5353

5454
/** Cross-session aggregate buckets returned alongside usage rows. */
5555
export type SessionsUsageAggregates = {
56+
/** Sessions with activity in the requested range, before the row `limit` cap. */
57+
sessionCount?: number;
58+
/** Longest single-row duration across every matched session, not just returned rows. */
59+
longestSessionDurationMs?: number;
5660
messages: SessionMessageCounts;
5761
tools: SessionToolUsage;
5862
byModel: SessionModelUsage[];

ui/src/app-navigation.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const ALL_ROUTES: RouteId[] = Array.from(
2626

2727
const SETTINGS_ROUTE_PATHS = [
2828
{ routeId: "config", path: "/settings/general", alias: "/config" },
29+
{ routeId: "profile", path: "/settings/profile", alias: "/profile" },
2930
{ routeId: "channels", path: "/settings/channels", alias: "/channels" },
3031
{
3132
routeId: "communications",
@@ -71,6 +72,7 @@ describe("navigationIconForRoute", () => {
7172
nodes: "monitor",
7273
dreams: "moon",
7374
config: "settings",
75+
profile: "lobster",
7476
communications: "send",
7577
appearance: "spark",
7678
automation: "terminal",
@@ -111,6 +113,7 @@ describe("titleForRoute", () => {
111113
nodes: "Nodes",
112114
dreams: "Dreaming",
113115
config: "Settings",
116+
profile: "Profile",
114117
communications: "Communications",
115118
appearance: "Appearance",
116119
automation: "Automation",
@@ -145,6 +148,7 @@ describe("subtitleForRoute", () => {
145148
nodes: "Paired devices and commands.",
146149
dreams: "Memory dreaming, consolidation, and reflection.",
147150
config: "Edit openclaw.json.",
151+
profile: "Your agent's stats, streaks, and life in the reef.",
148152
communications: "Channels, messages, and audio settings.",
149153
appearance: "Theme, UI, and setup wizard settings.",
150154
automation: "Commands, hooks, cron, and plugins.",
@@ -336,6 +340,7 @@ describe("SIDEBAR_NAV_ROUTES", () => {
336340
it("keeps detailed settings slices routed but out of the customizable sidebar", () => {
337341
expect(SIDEBAR_NAV_ROUTES).not.toContain("config");
338342
expect(SETTINGS_NAVIGATION_ROUTES).toEqual([
343+
"profile",
339344
"config",
340345
"appearance",
341346
"channels",

ui/src/app-navigation.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ type SettingsNavigationGroup = {
7070

7171
// Grouping feeds the full-page settings sidebar (settings-sidebar.ts).
7272
export const SETTINGS_NAVIGATION_GROUPS = [
73-
{ labelKey: null, routes: ["config", "appearance"] },
73+
{ labelKey: null, routes: ["profile", "config", "appearance"] },
7474
{ labelKey: "nav.settingsGroupConnections", routes: ["channels", "communications"] },
7575
{ labelKey: "nav.settingsGroupAgents", routes: ["ai-agents", "automation", "mcp"] },
7676
{
@@ -99,6 +99,7 @@ const NAVIGATION_ICONS: NavigationItem = {
9999
nodes: "monitor",
100100
chat: "messageSquare",
101101
config: "settings",
102+
profile: "lobster",
102103
communications: "send",
103104
appearance: "spark",
104105
automation: "terminal",
@@ -187,6 +188,7 @@ const NAVIGATION_COPY: Record<NavigationRouteId, { titleKey: string; subtitleKey
187188
nodes: { titleKey: "tabs.nodes", subtitleKey: "subtitles.nodes" },
188189
chat: { titleKey: "tabs.chat", subtitleKey: "subtitles.chat" },
189190
config: { titleKey: "nav.settings", subtitleKey: "subtitles.config" },
191+
profile: { titleKey: "tabs.profile", subtitleKey: "subtitles.profile" },
190192
communications: {
191193
titleKey: "tabs.communications",
192194
subtitleKey: "subtitles.communications",

ui/src/app-route-paths.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const APP_ROUTE_DEFINITIONS = {
88
agents: { path: "/agents" },
99
channels: { path: "/settings/channels", aliases: ["/channels"] },
1010
config: { path: "/settings/general", aliases: ["/config"] },
11+
profile: { path: "/settings/profile", aliases: ["/profile"] },
1112
communications: { path: "/settings/communications", aliases: ["/communications"] },
1213
appearance: { path: "/settings/appearance", aliases: ["/appearance"] },
1314
automation: { path: "/settings/automation", aliases: ["/automation"] },

ui/src/app-routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { page as logsPage } from "./pages/logs/route.ts";
1515
import { page as nodesPage } from "./pages/nodes/route.ts";
1616
import { page as overviewPage } from "./pages/overview/route.ts";
1717
import { page as pluginPage } from "./pages/plugin/route.ts";
18+
import { page as profilePage } from "./pages/profile/route.ts";
1819
import { page as sessionsPage } from "./pages/sessions/route.ts";
1920
import { page as skillWorkshopPage } from "./pages/skill-workshop/route.ts";
2021
import { page as skillsPage } from "./pages/skills/route.ts";
@@ -42,6 +43,7 @@ const APP_ROUTE_TREE = [
4243
agentsPage,
4344
channelsPage,
4445
...configPages,
46+
profilePage,
4547
workboardPage,
4648
worktreesPage,
4749
instancesPage,

0 commit comments

Comments
 (0)