Web UI: add token usage dashboard (#10072)

* feat(ui): Token Usage dashboard with session analytics

Adds a comprehensive Token Usage view to the dashboard:

Backend:
- Extended session-cost-usage.ts with per-session daily breakdown
- Added date range filtering (startMs/endMs) to API endpoints
- New sessions.usage, sessions.usage.timeseries, sessions.usage.logs endpoints
- Cost breakdown by token type (input/output/cache read/write)

Frontend:
- Two-column layout: Daily chart + breakdown | Sessions list
- Interactive daily bar chart with click-to-filter and shift-click range select
- Session detail panel with usage timeline, conversation logs, context weight
- Filter chips for active day/session selections
- Toggle between tokens/cost view modes (default: cost)
- Responsive design for smaller screens

UX improvements:
- 21-day default date range
- Debounced date input (400ms)
- Session list shows filtered totals when days selected
- Context weight breakdown shows skills, tools, files contribution

* fix(ui): restore gatewayUrl validation and syncUrlWithSessionKey signature

- Restore normalizeGatewayUrl() to validate ws:/wss: protocol
- Restore isTopLevelWindow() guard for iframe security
- Revert syncUrlWithSessionKey signature (host param was unused)

* feat(ui): Token Usage dashboard with session analytics

Adds a comprehensive Token Usage view to the dashboard:

Backend:
- Extended session-cost-usage.ts with per-session daily breakdown
- Added date range filtering (startMs/endMs) to API endpoints
- New sessions.usage, sessions.usage.timeseries, sessions.usage.logs endpoints
- Cost breakdown by token type (input/output/cache read/write)

Frontend:
- Two-column layout: Daily chart + breakdown | Sessions list
- Interactive daily bar chart with click-to-filter and shift-click range select
- Session detail panel with usage timeline, conversation logs, context weight
- Filter chips for active day/session selections
- Toggle between tokens/cost view modes (default: cost)
- Responsive design for smaller screens

UX improvements:
- 21-day default date range
- Debounced date input (400ms)
- Session list shows filtered totals when days selected
- Context weight breakdown shows skills, tools, files contribution

* fix: usage dashboard data + cost handling (#8462) (thanks @mcinteerj)

* Usage: enrich metrics dashboard

* Usage: add latency + model trends

* Gateway: improve usage log parsing

* UI: add usage query helpers

* UI: client-side usage filter + debounce

* Build: harden write-cli-compat timing

* UI: add conversation log filters

* UI: fix usage dashboard lint + state

* Web UI: default usage dates to local day

* Protocol: sync session usage params (#8462) (thanks @mcinteerj, @TakHoffman)

---------

Co-authored-by: Jake McInteer <mcinteerj@gmail.com>
This commit is contained in:
Tak Hoffman
2026-02-05 22:35:46 -06:00
committed by GitHub
parent b40da2cb7a
commit 8a352c8f9d
28 changed files with 8663 additions and 387 deletions

View File

@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
- Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz.
- Onboarding: add xAI (Grok) auth choice and provider defaults. (#9885) Thanks @grp06.
- Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov.
- Web UI: add Token Usage dashboard with session analytics. (#8462) Thanks @mcinteerj.
- Docs: mirror the landing page revamp for zh-CN (features, quickstart, docs directory, network model, credits). (#8994) Thanks @joshp123.
- Docs: strengthen secure DM mode guidance for multi-user inboxes with an explicit warning and example. (#9377) Thanks @Shrinija17.
- Docs: document `activeHours` heartbeat field with timezone resolution chain and example. (#9366) Thanks @unisone.
@@ -53,6 +54,7 @@ Docs: https://docs.openclaw.ai
- Web UI: resolve header logo path when `gateway.controlUi.basePath` is set. (#7178) Thanks @Yeom-JinHo.
- Web UI: apply button styling to the new-messages indicator.
- Onboarding: infer auth choice from non-interactive API key flags. (#8484) Thanks @f-trycua.
- Usage: include estimated cost when breakdown is missing and keep `usage.cost` days support. (#8462) Thanks @mcinteerj.
- Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin.
- Security: redact channel credentials (tokens, passwords, API keys, secrets) from gateway config APIs and preserve secrets during Control UI round-trips. (#9858) Thanks @abdelsfane.
- Discord: treat allowlisted senders as owner for system-prompt identity hints while keeping channel topics untrusted.

View File

@@ -1119,6 +1119,35 @@ public struct SessionsCompactParams: Codable, Sendable {
}
}
public struct SessionsUsageParams: Codable, Sendable {
public let key: String?
public let startdate: String?
public let enddate: String?
public let limit: Int?
public let includecontextweight: Bool?
public init(
key: String?,
startdate: String?,
enddate: String?,
limit: Int?,
includecontextweight: Bool?
) {
self.key = key
self.startdate = startdate
self.enddate = enddate
self.limit = limit
self.includecontextweight = includecontextweight
}
private enum CodingKeys: String, CodingKey {
case key
case startdate = "startDate"
case enddate = "endDate"
case limit
case includecontextweight = "includeContextWeight"
}
}
public struct ConfigGetParams: Codable, Sendable {
}

View File

@@ -1119,6 +1119,35 @@ public struct SessionsCompactParams: Codable, Sendable {
}
}
public struct SessionsUsageParams: Codable, Sendable {
public let key: String?
public let startdate: String?
public let enddate: String?
public let limit: Int?
public let includecontextweight: Bool?
public init(
key: String?,
startdate: String?,
enddate: String?,
limit: Int?,
includecontextweight: Bool?
) {
self.key = key
self.startdate = startdate
self.enddate = enddate
self.limit = limit
self.includecontextweight = includecontextweight
}
private enum CodingKeys: String, CodingKey {
case key
case startdate = "startDate"
case enddate = "endDate"
case limit
case includecontextweight = "includeContextWeight"
}
}
public struct ConfigGetParams: Codable, Sendable {
}

View File

@@ -6,9 +6,18 @@ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const distDir = path.join(rootDir, "dist");
const cliDir = path.join(distDir, "cli");
const candidates = fs
.readdirSync(distDir)
.filter((entry) => entry.startsWith("daemon-cli-") && entry.endsWith(".js"));
const findCandidates = () =>
fs
.readdirSync(distDir)
.filter((entry) => entry.startsWith("daemon-cli-") && entry.endsWith(".js"));
// In rare cases, build output can land slightly after this script starts (depending on FS timing).
// Retry briefly to avoid flaky builds.
let candidates = findCandidates();
for (let i = 0; i < 10 && candidates.length === 0; i++) {
await new Promise((resolve) => setTimeout(resolve, 50));
candidates = findCandidates();
}
if (candidates.length === 0) {
throw new Error("No daemon-cli bundle found in dist; cannot write legacy CLI shim.");

View File

@@ -161,6 +161,8 @@ import {
SessionsResetParamsSchema,
type SessionsResolveParams,
SessionsResolveParamsSchema,
type SessionsUsageParams,
SessionsUsageParamsSchema,
type ShutdownEvent,
ShutdownEventSchema,
type SkillsBinsParams,
@@ -271,6 +273,8 @@ export const validateSessionsDeleteParams = ajv.compile<SessionsDeleteParams>(
export const validateSessionsCompactParams = ajv.compile<SessionsCompactParams>(
SessionsCompactParamsSchema,
);
export const validateSessionsUsageParams =
ajv.compile<SessionsUsageParams>(SessionsUsageParamsSchema);
export const validateConfigGetParams = ajv.compile<ConfigGetParams>(ConfigGetParamsSchema);
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(ConfigSetParamsSchema);
export const validateConfigApplyParams = ajv.compile<ConfigApplyParams>(ConfigApplyParamsSchema);
@@ -412,6 +416,7 @@ export {
SessionsResetParamsSchema,
SessionsDeleteParamsSchema,
SessionsCompactParamsSchema,
SessionsUsageParamsSchema,
ConfigGetParamsSchema,
ConfigSetParamsSchema,
ConfigApplyParamsSchema,
@@ -541,6 +546,7 @@ export type {
SessionsResetParams,
SessionsDeleteParams,
SessionsCompactParams,
SessionsUsageParams,
CronJob,
CronListParams,
CronStatusParams,

View File

@@ -117,6 +117,7 @@ import {
SessionsPreviewParamsSchema,
SessionsResetParamsSchema,
SessionsResolveParamsSchema,
SessionsUsageParamsSchema,
} from "./sessions.js";
import { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js";
import {
@@ -168,6 +169,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
SessionsResetParams: SessionsResetParamsSchema,
SessionsDeleteParams: SessionsDeleteParamsSchema,
SessionsCompactParams: SessionsCompactParamsSchema,
SessionsUsageParams: SessionsUsageParamsSchema,
ConfigGetParams: ConfigGetParamsSchema,
ConfigSetParams: ConfigSetParamsSchema,
ConfigApplyParams: ConfigApplyParamsSchema,

View File

@@ -101,3 +101,19 @@ export const SessionsCompactParamsSchema = Type.Object(
},
{ additionalProperties: false },
);
export const SessionsUsageParamsSchema = Type.Object(
{
/** Specific session key to analyze; if omitted returns all sessions. */
key: Type.Optional(NonEmptyString),
/** Start date for range filter (YYYY-MM-DD). */
startDate: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })),
/** End date for range filter (YYYY-MM-DD). */
endDate: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })),
/** Maximum sessions to return (default 50). */
limit: Type.Optional(Type.Integer({ minimum: 1 })),
/** Include context weight breakdown (systemPromptReport). */
includeContextWeight: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);

View File

@@ -110,6 +110,7 @@ import type {
SessionsPreviewParamsSchema,
SessionsResetParamsSchema,
SessionsResolveParamsSchema,
SessionsUsageParamsSchema,
} from "./sessions.js";
import type { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js";
import type {
@@ -157,6 +158,7 @@ export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>;
export type SessionsUsageParams = Static<typeof SessionsUsageParamsSchema>;
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
export type ConfigApplyParams = Static<typeof ConfigApplyParamsSchema>;

View File

@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../infra/session-cost-usage.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/session-cost-usage.js")>(
"../../infra/session-cost-usage.js",
);
return {
...actual,
loadCostUsageSummary: vi.fn(async () => ({
updatedAt: Date.now(),
startDate: "2026-02-01",
endDate: "2026-02-02",
daily: [],
totals: { totalTokens: 1, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0 },
})),
};
});
import { loadCostUsageSummary } from "../../infra/session-cost-usage.js";
import { __test } from "./usage.js";
describe("gateway usage helpers", () => {
beforeEach(() => {
__test.costUsageCache.clear();
vi.useRealTimers();
vi.clearAllMocks();
});
it("parseDateToMs accepts YYYY-MM-DD and rejects invalid input", () => {
expect(__test.parseDateToMs("2026-02-05")).toBe(Date.UTC(2026, 1, 5));
expect(__test.parseDateToMs(" 2026-02-05 ")).toBe(Date.UTC(2026, 1, 5));
expect(__test.parseDateToMs("2026-2-5")).toBeUndefined();
expect(__test.parseDateToMs("nope")).toBeUndefined();
expect(__test.parseDateToMs(undefined)).toBeUndefined();
});
it("parseDays coerces strings/numbers to integers", () => {
expect(__test.parseDays(7.9)).toBe(7);
expect(__test.parseDays("30")).toBe(30);
expect(__test.parseDays("")).toBeUndefined();
expect(__test.parseDays("nope")).toBeUndefined();
});
it("parseDateRange uses explicit start/end (inclusive end of day)", () => {
const range = __test.parseDateRange({ startDate: "2026-02-01", endDate: "2026-02-02" });
expect(range.startMs).toBe(Date.UTC(2026, 1, 1));
expect(range.endMs).toBe(Date.UTC(2026, 1, 2) + 24 * 60 * 60 * 1000 - 1);
});
it("parseDateRange clamps days to at least 1 and defaults to 30 days", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-05T12:34:56.000Z"));
const oneDay = __test.parseDateRange({ days: 0 });
expect(oneDay.endMs).toBe(Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1);
expect(oneDay.startMs).toBe(Date.UTC(2026, 1, 5));
const def = __test.parseDateRange({});
expect(def.endMs).toBe(Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1);
expect(def.startMs).toBe(Date.UTC(2026, 1, 5) - 29 * 24 * 60 * 60 * 1000);
});
it("loadCostUsageSummaryCached caches within TTL", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-05T00:00:00.000Z"));
const config = {} as unknown as ReturnType<import("../../config/config.js").loadConfig>;
const a = await __test.loadCostUsageSummaryCached({
startMs: 1,
endMs: 2,
config,
});
const b = await __test.loadCostUsageSummaryCached({
startMs: 1,
endMs: 2,
config,
});
expect(a.totals.totalTokens).toBe(1);
expect(b.totals.totalTokens).toBe(1);
expect(vi.mocked(loadCostUsageSummary)).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,20 +1,68 @@
import type { CostUsageSummary } from "../../infra/session-cost-usage.js";
import fs from "node:fs";
import type { SessionEntry, SessionSystemPromptReport } from "../../config/sessions/types.js";
import type {
CostUsageSummary,
SessionCostSummary,
SessionDailyLatency,
SessionDailyModelUsage,
SessionMessageCounts,
SessionLatencyStats,
SessionModelUsage,
SessionToolUsage,
} from "../../infra/session-cost-usage.js";
import type { GatewayRequestHandlers } from "./types.js";
import { loadConfig } from "../../config/config.js";
import { resolveSessionFilePath } from "../../config/sessions/paths.js";
import { loadProviderUsageSummary } from "../../infra/provider-usage.js";
import { loadCostUsageSummary } from "../../infra/session-cost-usage.js";
import {
loadCostUsageSummary,
loadSessionCostSummary,
loadSessionUsageTimeSeries,
discoverAllSessions,
} from "../../infra/session-cost-usage.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateSessionsUsageParams,
} from "../protocol/index.js";
import { loadCombinedSessionStoreForGateway, loadSessionEntry } from "../session-utils.js";
const COST_USAGE_CACHE_TTL_MS = 30_000;
type DateRange = { startMs: number; endMs: number };
type CostUsageCacheEntry = {
summary?: CostUsageSummary;
updatedAt?: number;
inFlight?: Promise<CostUsageSummary>;
};
const costUsageCache = new Map<number, CostUsageCacheEntry>();
const costUsageCache = new Map<string, CostUsageCacheEntry>();
const parseDays = (raw: unknown): number => {
/**
* Parse a date string (YYYY-MM-DD) to start of day timestamp in UTC.
* Returns undefined if invalid.
*/
const parseDateToMs = (raw: unknown): number | undefined => {
if (typeof raw !== "string" || !raw.trim()) {
return undefined;
}
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw.trim());
if (!match) {
return undefined;
}
const [, year, month, day] = match;
// Use UTC to ensure consistent behavior across timezones
const ms = Date.UTC(parseInt(year), parseInt(month) - 1, parseInt(day));
if (Number.isNaN(ms)) {
return undefined;
}
return ms;
};
const parseDays = (raw: unknown): number | undefined => {
if (typeof raw === "number" && Number.isFinite(raw)) {
return Math.floor(raw);
}
@@ -24,16 +72,51 @@ const parseDays = (raw: unknown): number => {
return Math.floor(parsed);
}
}
return 30;
return undefined;
};
/**
* Get date range from params (startDate/endDate or days).
* Falls back to last 30 days if not provided.
*/
const parseDateRange = (params: {
startDate?: unknown;
endDate?: unknown;
days?: unknown;
}): DateRange => {
const now = new Date();
// Use UTC for consistent date handling
const todayStartMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const todayEndMs = todayStartMs + 24 * 60 * 60 * 1000 - 1;
const startMs = parseDateToMs(params.startDate);
const endMs = parseDateToMs(params.endDate);
if (startMs !== undefined && endMs !== undefined) {
// endMs should be end of day
return { startMs, endMs: endMs + 24 * 60 * 60 * 1000 - 1 };
}
const days = parseDays(params.days);
if (days !== undefined) {
const clampedDays = Math.max(1, days);
const start = todayStartMs - (clampedDays - 1) * 24 * 60 * 60 * 1000;
return { startMs: start, endMs: todayEndMs };
}
// Default to last 30 days
const defaultStartMs = todayStartMs - 29 * 24 * 60 * 60 * 1000;
return { startMs: defaultStartMs, endMs: todayEndMs };
};
async function loadCostUsageSummaryCached(params: {
days: number;
startMs: number;
endMs: number;
config: ReturnType<typeof loadConfig>;
}): Promise<CostUsageSummary> {
const days = Math.max(1, params.days);
const cacheKey = `${params.startMs}-${params.endMs}`;
const now = Date.now();
const cached = costUsageCache.get(days);
const cached = costUsageCache.get(cacheKey);
if (cached?.summary && cached.updatedAt && now - cached.updatedAt < COST_USAGE_CACHE_TTL_MS) {
return cached.summary;
}
@@ -46,9 +129,13 @@ async function loadCostUsageSummaryCached(params: {
}
const entry: CostUsageCacheEntry = cached ?? {};
const inFlight = loadCostUsageSummary({ days, config: params.config })
const inFlight = loadCostUsageSummary({
startMs: params.startMs,
endMs: params.endMs,
config: params.config,
})
.then((summary) => {
costUsageCache.set(days, { summary, updatedAt: Date.now() });
costUsageCache.set(cacheKey, { summary, updatedAt: Date.now() });
return summary;
})
.catch((err) => {
@@ -58,15 +145,15 @@ async function loadCostUsageSummaryCached(params: {
throw err;
})
.finally(() => {
const current = costUsageCache.get(days);
const current = costUsageCache.get(cacheKey);
if (current?.inFlight === inFlight) {
current.inFlight = undefined;
costUsageCache.set(days, current);
costUsageCache.set(cacheKey, current);
}
});
entry.inFlight = inFlight;
costUsageCache.set(days, entry);
costUsageCache.set(cacheKey, entry);
if (entry.summary) {
return entry.summary;
@@ -74,6 +161,70 @@ async function loadCostUsageSummaryCached(params: {
return await inFlight;
}
// Exposed for unit tests (kept as a single export to avoid widening the public API surface).
export const __test = {
parseDateToMs,
parseDays,
parseDateRange,
loadCostUsageSummaryCached,
costUsageCache,
};
export type SessionUsageEntry = {
key: string;
label?: string;
sessionId?: string;
updatedAt?: number;
agentId?: string;
channel?: string;
chatType?: string;
origin?: {
label?: string;
provider?: string;
surface?: string;
chatType?: string;
from?: string;
to?: string;
accountId?: string;
threadId?: string | number;
};
modelOverride?: string;
providerOverride?: string;
modelProvider?: string;
model?: string;
usage: SessionCostSummary | null;
contextWeight?: SessionSystemPromptReport | null;
};
export type SessionsUsageAggregates = {
messages: SessionMessageCounts;
tools: SessionToolUsage;
byModel: SessionModelUsage[];
byProvider: SessionModelUsage[];
byAgent: Array<{ agentId: string; totals: CostUsageSummary["totals"] }>;
byChannel: Array<{ channel: string; totals: CostUsageSummary["totals"] }>;
latency?: SessionLatencyStats;
dailyLatency?: SessionDailyLatency[];
modelDaily?: SessionDailyModelUsage[];
daily: Array<{
date: string;
tokens: number;
cost: number;
messages: number;
toolCalls: number;
errors: number;
}>;
};
export type SessionsUsageResult = {
updatedAt: number;
startDate: string;
endDate: string;
sessions: SessionUsageEntry[];
totals: CostUsageSummary["totals"];
aggregates: SessionsUsageAggregates;
};
export const usageHandlers: GatewayRequestHandlers = {
"usage.status": async ({ respond }) => {
const summary = await loadProviderUsageSummary();
@@ -81,8 +232,535 @@ export const usageHandlers: GatewayRequestHandlers = {
},
"usage.cost": async ({ respond, params }) => {
const config = loadConfig();
const days = parseDays(params?.days);
const summary = await loadCostUsageSummaryCached({ days, config });
const { startMs, endMs } = parseDateRange({
startDate: params?.startDate,
endDate: params?.endDate,
days: params?.days,
});
const summary = await loadCostUsageSummaryCached({ startMs, endMs, config });
respond(true, summary, undefined);
},
"sessions.usage": async ({ respond, params }) => {
if (!validateSessionsUsageParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid sessions.usage params: ${formatValidationErrors(validateSessionsUsageParams.errors)}`,
),
);
return;
}
const p = params;
const config = loadConfig();
const { startMs, endMs } = parseDateRange({
startDate: p.startDate,
endDate: p.endDate,
});
const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? p.limit : 50;
const includeContextWeight = p.includeContextWeight ?? false;
const specificKey = typeof p.key === "string" ? p.key.trim() : null;
// Load session store for named sessions
const { store } = loadCombinedSessionStoreForGateway(config);
const now = Date.now();
// Merge discovered sessions with store entries
type MergedEntry = {
key: string;
sessionId: string;
sessionFile: string;
label?: string;
updatedAt: number;
storeEntry?: SessionEntry;
firstUserMessage?: string;
};
const mergedEntries: MergedEntry[] = [];
// Optimization: If a specific key is requested, skip full directory scan
if (specificKey) {
// Check if it's a named session in the store
const storeEntry = store[specificKey];
let sessionId = storeEntry?.sessionId ?? specificKey;
// Resolve the session file path
const sessionFile = resolveSessionFilePath(sessionId, storeEntry);
try {
const stats = fs.statSync(sessionFile);
if (stats.isFile()) {
mergedEntries.push({
key: specificKey,
sessionId,
sessionFile,
label: storeEntry?.label,
updatedAt: storeEntry?.updatedAt ?? stats.mtimeMs,
storeEntry,
});
}
} catch {
// File doesn't exist - no results for this key
}
} else {
// Full discovery for list view
const discoveredSessions = await discoverAllSessions({
startMs,
endMs,
});
// Build a map of sessionId -> store entry for quick lookup
const storeBySessionId = new Map<string, { key: string; entry: SessionEntry }>();
for (const [key, entry] of Object.entries(store)) {
if (entry?.sessionId) {
storeBySessionId.set(entry.sessionId, { key, entry });
}
}
for (const discovered of discoveredSessions) {
const storeMatch = storeBySessionId.get(discovered.sessionId);
if (storeMatch) {
// Named session from store
mergedEntries.push({
key: storeMatch.key,
sessionId: discovered.sessionId,
sessionFile: discovered.sessionFile,
label: storeMatch.entry.label,
updatedAt: storeMatch.entry.updatedAt ?? discovered.mtime,
storeEntry: storeMatch.entry,
});
} else {
// Unnamed session - use session ID as key, no label
mergedEntries.push({
key: discovered.sessionId,
sessionId: discovered.sessionId,
sessionFile: discovered.sessionFile,
label: undefined, // No label for unnamed sessions
updatedAt: discovered.mtime,
});
}
}
}
// Sort by most recent first
mergedEntries.sort((a, b) => b.updatedAt - a.updatedAt);
// Apply limit
const limitedEntries = mergedEntries.slice(0, limit);
// Load usage for each session
const sessions: SessionUsageEntry[] = [];
const aggregateTotals = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
totalCost: 0,
inputCost: 0,
outputCost: 0,
cacheReadCost: 0,
cacheWriteCost: 0,
missingCostEntries: 0,
};
const aggregateMessages: SessionMessageCounts = {
total: 0,
user: 0,
assistant: 0,
toolCalls: 0,
toolResults: 0,
errors: 0,
};
const toolAggregateMap = new Map<string, number>();
const byModelMap = new Map<string, SessionModelUsage>();
const byProviderMap = new Map<string, SessionModelUsage>();
const byAgentMap = new Map<string, CostUsageSummary["totals"]>();
const byChannelMap = new Map<string, CostUsageSummary["totals"]>();
const dailyAggregateMap = new Map<
string,
{
date: string;
tokens: number;
cost: number;
messages: number;
toolCalls: number;
errors: number;
}
>();
const latencyTotals = {
count: 0,
sum: 0,
min: Number.POSITIVE_INFINITY,
max: 0,
p95Max: 0,
};
const dailyLatencyMap = new Map<
string,
{ date: string; count: number; sum: number; min: number; max: number; p95Max: number }
>();
const modelDailyMap = new Map<string, SessionDailyModelUsage>();
const emptyTotals = (): CostUsageSummary["totals"] => ({
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
totalCost: 0,
inputCost: 0,
outputCost: 0,
cacheReadCost: 0,
cacheWriteCost: 0,
missingCostEntries: 0,
});
const mergeTotals = (
target: CostUsageSummary["totals"],
source: CostUsageSummary["totals"],
) => {
target.input += source.input;
target.output += source.output;
target.cacheRead += source.cacheRead;
target.cacheWrite += source.cacheWrite;
target.totalTokens += source.totalTokens;
target.totalCost += source.totalCost;
target.inputCost += source.inputCost;
target.outputCost += source.outputCost;
target.cacheReadCost += source.cacheReadCost;
target.cacheWriteCost += source.cacheWriteCost;
target.missingCostEntries += source.missingCostEntries;
};
for (const merged of limitedEntries) {
const usage = await loadSessionCostSummary({
sessionId: merged.sessionId,
sessionEntry: merged.storeEntry,
sessionFile: merged.sessionFile,
config,
startMs,
endMs,
});
if (usage) {
aggregateTotals.input += usage.input;
aggregateTotals.output += usage.output;
aggregateTotals.cacheRead += usage.cacheRead;
aggregateTotals.cacheWrite += usage.cacheWrite;
aggregateTotals.totalTokens += usage.totalTokens;
aggregateTotals.totalCost += usage.totalCost;
aggregateTotals.inputCost += usage.inputCost;
aggregateTotals.outputCost += usage.outputCost;
aggregateTotals.cacheReadCost += usage.cacheReadCost;
aggregateTotals.cacheWriteCost += usage.cacheWriteCost;
aggregateTotals.missingCostEntries += usage.missingCostEntries;
}
const agentId = parseAgentSessionKey(merged.key)?.agentId;
const channel = merged.storeEntry?.channel ?? merged.storeEntry?.origin?.provider;
const chatType = merged.storeEntry?.chatType ?? merged.storeEntry?.origin?.chatType;
if (usage) {
if (usage.messageCounts) {
aggregateMessages.total += usage.messageCounts.total;
aggregateMessages.user += usage.messageCounts.user;
aggregateMessages.assistant += usage.messageCounts.assistant;
aggregateMessages.toolCalls += usage.messageCounts.toolCalls;
aggregateMessages.toolResults += usage.messageCounts.toolResults;
aggregateMessages.errors += usage.messageCounts.errors;
}
if (usage.toolUsage) {
for (const tool of usage.toolUsage.tools) {
toolAggregateMap.set(tool.name, (toolAggregateMap.get(tool.name) ?? 0) + tool.count);
}
}
if (usage.modelUsage) {
for (const entry of usage.modelUsage) {
const modelKey = `${entry.provider ?? "unknown"}::${entry.model ?? "unknown"}`;
const modelExisting =
byModelMap.get(modelKey) ??
({
provider: entry.provider,
model: entry.model,
count: 0,
totals: emptyTotals(),
} as SessionModelUsage);
modelExisting.count += entry.count;
mergeTotals(modelExisting.totals, entry.totals);
byModelMap.set(modelKey, modelExisting);
const providerKey = entry.provider ?? "unknown";
const providerExisting =
byProviderMap.get(providerKey) ??
({
provider: entry.provider,
model: undefined,
count: 0,
totals: emptyTotals(),
} as SessionModelUsage);
providerExisting.count += entry.count;
mergeTotals(providerExisting.totals, entry.totals);
byProviderMap.set(providerKey, providerExisting);
}
}
if (usage.latency) {
const { count, avgMs, minMs, maxMs, p95Ms } = usage.latency;
if (count > 0) {
latencyTotals.count += count;
latencyTotals.sum += avgMs * count;
latencyTotals.min = Math.min(latencyTotals.min, minMs);
latencyTotals.max = Math.max(latencyTotals.max, maxMs);
latencyTotals.p95Max = Math.max(latencyTotals.p95Max, p95Ms);
}
}
if (usage.dailyLatency) {
for (const day of usage.dailyLatency) {
const existing = dailyLatencyMap.get(day.date) ?? {
date: day.date,
count: 0,
sum: 0,
min: Number.POSITIVE_INFINITY,
max: 0,
p95Max: 0,
};
existing.count += day.count;
existing.sum += day.avgMs * day.count;
existing.min = Math.min(existing.min, day.minMs);
existing.max = Math.max(existing.max, day.maxMs);
existing.p95Max = Math.max(existing.p95Max, day.p95Ms);
dailyLatencyMap.set(day.date, existing);
}
}
if (usage.dailyModelUsage) {
for (const entry of usage.dailyModelUsage) {
const key = `${entry.date}::${entry.provider ?? "unknown"}::${entry.model ?? "unknown"}`;
const existing =
modelDailyMap.get(key) ??
({
date: entry.date,
provider: entry.provider,
model: entry.model,
tokens: 0,
cost: 0,
count: 0,
} as SessionDailyModelUsage);
existing.tokens += entry.tokens;
existing.cost += entry.cost;
existing.count += entry.count;
modelDailyMap.set(key, existing);
}
}
if (agentId) {
const agentTotals = byAgentMap.get(agentId) ?? emptyTotals();
mergeTotals(agentTotals, usage);
byAgentMap.set(agentId, agentTotals);
}
if (channel) {
const channelTotals = byChannelMap.get(channel) ?? emptyTotals();
mergeTotals(channelTotals, usage);
byChannelMap.set(channel, channelTotals);
}
if (usage.dailyBreakdown) {
for (const day of usage.dailyBreakdown) {
const daily = dailyAggregateMap.get(day.date) ?? {
date: day.date,
tokens: 0,
cost: 0,
messages: 0,
toolCalls: 0,
errors: 0,
};
daily.tokens += day.tokens;
daily.cost += day.cost;
dailyAggregateMap.set(day.date, daily);
}
}
if (usage.dailyMessageCounts) {
for (const day of usage.dailyMessageCounts) {
const daily = dailyAggregateMap.get(day.date) ?? {
date: day.date,
tokens: 0,
cost: 0,
messages: 0,
toolCalls: 0,
errors: 0,
};
daily.messages += day.total;
daily.toolCalls += day.toolCalls;
daily.errors += day.errors;
dailyAggregateMap.set(day.date, daily);
}
}
}
sessions.push({
key: merged.key,
label: merged.label,
sessionId: merged.sessionId,
updatedAt: merged.updatedAt,
agentId,
channel,
chatType,
origin: merged.storeEntry?.origin,
modelOverride: merged.storeEntry?.modelOverride,
providerOverride: merged.storeEntry?.providerOverride,
modelProvider: merged.storeEntry?.modelProvider,
model: merged.storeEntry?.model,
usage,
contextWeight: includeContextWeight
? (merged.storeEntry?.systemPromptReport ?? null)
: undefined,
});
}
// Format dates back to YYYY-MM-DD strings
const formatDateStr = (ms: number) => {
const d = new Date(ms);
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
};
const aggregates: SessionsUsageAggregates = {
messages: aggregateMessages,
tools: {
totalCalls: Array.from(toolAggregateMap.values()).reduce((sum, count) => sum + count, 0),
uniqueTools: toolAggregateMap.size,
tools: Array.from(toolAggregateMap.entries())
.map(([name, count]) => ({ name, count }))
.toSorted((a, b) => b.count - a.count),
},
byModel: Array.from(byModelMap.values()).toSorted((a, b) => {
const costDiff = b.totals.totalCost - a.totals.totalCost;
if (costDiff !== 0) {
return costDiff;
}
return b.totals.totalTokens - a.totals.totalTokens;
}),
byProvider: Array.from(byProviderMap.values()).toSorted((a, b) => {
const costDiff = b.totals.totalCost - a.totals.totalCost;
if (costDiff !== 0) {
return costDiff;
}
return b.totals.totalTokens - a.totals.totalTokens;
}),
byAgent: Array.from(byAgentMap.entries())
.map(([id, totals]) => ({ agentId: id, totals }))
.toSorted((a, b) => b.totals.totalCost - a.totals.totalCost),
byChannel: Array.from(byChannelMap.entries())
.map(([name, totals]) => ({ channel: name, totals }))
.toSorted((a, b) => b.totals.totalCost - a.totals.totalCost),
latency:
latencyTotals.count > 0
? {
count: latencyTotals.count,
avgMs: latencyTotals.sum / latencyTotals.count,
minMs: latencyTotals.min === Number.POSITIVE_INFINITY ? 0 : latencyTotals.min,
maxMs: latencyTotals.max,
p95Ms: latencyTotals.p95Max,
}
: undefined,
dailyLatency: Array.from(dailyLatencyMap.values())
.map((entry) => ({
date: entry.date,
count: entry.count,
avgMs: entry.count ? entry.sum / entry.count : 0,
minMs: entry.min === Number.POSITIVE_INFINITY ? 0 : entry.min,
maxMs: entry.max,
p95Ms: entry.p95Max,
}))
.toSorted((a, b) => a.date.localeCompare(b.date)),
modelDaily: Array.from(modelDailyMap.values()).toSorted(
(a, b) => a.date.localeCompare(b.date) || b.cost - a.cost,
),
daily: Array.from(dailyAggregateMap.values()).toSorted((a, b) =>
a.date.localeCompare(b.date),
),
};
const result: SessionsUsageResult = {
updatedAt: now,
startDate: formatDateStr(startMs),
endDate: formatDateStr(endMs),
sessions,
totals: aggregateTotals,
aggregates,
};
respond(true, result, undefined);
},
"sessions.usage.timeseries": async ({ respond, params }) => {
const key = typeof params?.key === "string" ? params.key.trim() : null;
if (!key) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "key is required for timeseries"),
);
return;
}
const config = loadConfig();
const { entry } = loadSessionEntry(key);
// For discovered sessions (not in store), try using key as sessionId directly
const sessionId = entry?.sessionId ?? key;
const sessionFile = entry?.sessionFile ?? resolveSessionFilePath(key);
const timeseries = await loadSessionUsageTimeSeries({
sessionId,
sessionEntry: entry,
sessionFile,
config,
maxPoints: 200,
});
if (!timeseries) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `No transcript found for session: ${key}`),
);
return;
}
respond(true, timeseries, undefined);
},
"sessions.usage.logs": async ({ respond, params }) => {
const key = typeof params?.key === "string" ? params.key.trim() : null;
if (!key) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "key is required for logs"));
return;
}
const limit =
typeof params?.limit === "number" && Number.isFinite(params.limit)
? Math.min(params.limit, 1000)
: 200;
const config = loadConfig();
const { entry } = loadSessionEntry(key);
// For discovered sessions (not in store), try using key as sessionId directly
const sessionId = entry?.sessionId ?? key;
const sessionFile = entry?.sessionFile ?? resolveSessionFilePath(key);
const { loadSessionLogs } = await import("../../infra/session-cost-usage.js");
const logs = await loadSessionLogs({
sessionId,
sessionEntry: entry,
sessionFile,
config,
limit,
});
respond(true, { logs: logs ?? [] }, undefined);
},
};

View File

@@ -383,6 +383,43 @@ describe("readSessionPreviewItemsFromTranscript", () => {
expect(result[1]?.text).toContain("call weather");
});
test("detects tool calls from tool_use/tool_call blocks and toolName field", () => {
const sessionId = "preview-session-tools";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "assistant", content: "Hi" } }),
JSON.stringify({
message: {
role: "assistant",
toolName: "camera",
content: [
{ type: "tool_use", name: "read" },
{ type: "tool_call", name: "write" },
],
},
}),
JSON.stringify({ message: { role: "assistant", content: "Done" } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readSessionPreviewItemsFromTranscript(
sessionId,
storePath,
undefined,
undefined,
3,
120,
);
expect(result.map((item) => item.role)).toEqual(["assistant", "tool", "assistant"]);
expect(result[1]?.text).toContain("call");
expect(result[1]?.text).toContain("camera");
expect(result[1]?.text).toContain("read");
// Preview text may not list every tool name; it should at least hint there were multiple calls.
expect(result[1]?.text).toMatch(/\+\d+/);
});
test("truncates preview text to max chars", () => {
const sessionId = "preview-truncate";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import type { SessionPreviewItem } from "./session-utils.types.js";
import { resolveSessionTranscriptPath } from "../config/sessions.js";
import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js";
import { stripEnvelope } from "./chat-sanitize.js";
export function readSessionMessages(
@@ -292,35 +293,11 @@ function extractPreviewText(message: TranscriptPreviewMessage): string | null {
}
function isToolCall(message: TranscriptPreviewMessage): boolean {
if (message.toolName || message.tool_name) {
return true;
}
if (!Array.isArray(message.content)) {
return false;
}
return message.content.some((entry) => {
if (entry?.name) {
return true;
}
const raw = typeof entry?.type === "string" ? entry.type.toLowerCase() : "";
return raw === "toolcall" || raw === "tool_call";
});
return hasToolCall(message as Record<string, unknown>);
}
function extractToolNames(message: TranscriptPreviewMessage): string[] {
const names: string[] = [];
if (Array.isArray(message.content)) {
for (const entry of message.content) {
if (typeof entry?.name === "string" && entry.name.trim()) {
names.push(entry.name.trim());
}
}
}
const toolName = typeof message.toolName === "string" ? message.toolName : message.tool_name;
if (typeof toolName === "string" && toolName.trim()) {
names.push(toolName.trim());
}
return names;
return extractToolCallNames(message as Record<string, unknown>);
}
function extractMediaSummary(message: TranscriptPreviewMessage): string | null {

View File

@@ -3,7 +3,11 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { loadCostUsageSummary, loadSessionCostSummary } from "./session-cost-usage.js";
import {
discoverAllSessions,
loadCostUsageSummary,
loadSessionCostSummary,
} from "./session-cost-usage.js";
describe("session cost usage", () => {
it("aggregates daily totals with log cost and pricing fallback", async () => {
@@ -140,4 +144,100 @@ describe("session cost usage", () => {
expect(summary?.totalTokens).toBe(30);
expect(summary?.lastActivity).toBeGreaterThan(0);
});
it("captures message counts, tool usage, and model usage", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cost-session-meta-"));
const sessionFile = path.join(root, "session.jsonl");
const start = new Date("2026-02-01T10:00:00.000Z");
const end = new Date("2026-02-01T10:05:00.000Z");
const entries = [
{
type: "message",
timestamp: start.toISOString(),
message: {
role: "user",
content: "Hello",
},
},
{
type: "message",
timestamp: end.toISOString(),
message: {
role: "assistant",
provider: "openai",
model: "gpt-5.2",
stopReason: "error",
content: [
{ type: "text", text: "Checking" },
{ type: "tool_use", name: "weather" },
{ type: "tool_result", is_error: true },
],
usage: {
input: 12,
output: 18,
totalTokens: 30,
cost: { total: 0.02 },
},
},
},
];
await fs.writeFile(
sessionFile,
entries.map((entry) => JSON.stringify(entry)).join("\n"),
"utf-8",
);
const summary = await loadSessionCostSummary({ sessionFile });
expect(summary?.messageCounts).toEqual({
total: 2,
user: 1,
assistant: 1,
toolCalls: 1,
toolResults: 1,
errors: 2,
});
expect(summary?.toolUsage?.totalCalls).toBe(1);
expect(summary?.toolUsage?.uniqueTools).toBe(1);
expect(summary?.toolUsage?.tools[0]?.name).toBe("weather");
expect(summary?.modelUsage?.[0]?.provider).toBe("openai");
expect(summary?.modelUsage?.[0]?.model).toBe("gpt-5.2");
expect(summary?.durationMs).toBe(5 * 60 * 1000);
expect(summary?.latency?.count).toBe(1);
expect(summary?.latency?.avgMs).toBe(5 * 60 * 1000);
expect(summary?.latency?.p95Ms).toBe(5 * 60 * 1000);
expect(summary?.dailyLatency?.[0]?.date).toBe("2026-02-01");
expect(summary?.dailyLatency?.[0]?.count).toBe(1);
expect(summary?.dailyModelUsage?.[0]?.date).toBe("2026-02-01");
expect(summary?.dailyModelUsage?.[0]?.model).toBe("gpt-5.2");
});
it("does not exclude sessions with mtime after endMs during discovery", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discover-"));
const sessionsDir = path.join(root, "agents", "main", "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "sess-late.jsonl");
await fs.writeFile(sessionFile, "", "utf-8");
const now = Date.now();
await fs.utimes(sessionFile, now / 1000, now / 1000);
const originalState = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = root;
try {
const sessions = await discoverAllSessions({
startMs: now - 7 * 24 * 60 * 60 * 1000,
endMs: now - 24 * 60 * 60 * 1000,
});
expect(sessions.length).toBe(1);
expect(sessions[0]?.sessionId).toBe("sess-late");
} finally {
if (originalState === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = originalState;
}
}
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { countToolResults, extractToolCallNames, hasToolCall } from "./transcript-tools.js";
describe("transcript-tools", () => {
describe("extractToolCallNames", () => {
it("extracts tool name from message.toolName/tool_name", () => {
expect(extractToolCallNames({ toolName: " weather " })).toEqual(["weather"]);
expect(extractToolCallNames({ tool_name: "notes" })).toEqual(["notes"]);
});
it("extracts tool call names from content blocks (tool_use/toolcall/tool_call)", () => {
const names = extractToolCallNames({
content: [
{ type: "text", text: "hi" },
{ type: "tool_use", name: "read" },
{ type: "toolcall", name: "exec" },
{ type: "tool_call", name: "write" },
],
});
expect(new Set(names)).toEqual(new Set(["read", "exec", "write"]));
});
it("normalizes type and trims names; de-dupes", () => {
const names = extractToolCallNames({
content: [
{ type: " TOOL_CALL ", name: " read " },
{ type: "tool_call", name: "read" },
{ type: "tool_call", name: "" },
],
toolName: "read",
});
expect(names).toEqual(["read"]);
});
});
describe("hasToolCall", () => {
it("returns true when tool call names exist", () => {
expect(hasToolCall({ toolName: "weather" })).toBe(true);
expect(hasToolCall({ content: [{ type: "tool_use", name: "read" }] })).toBe(true);
});
it("returns false when no tool calls exist", () => {
expect(hasToolCall({})).toBe(false);
expect(hasToolCall({ content: [{ type: "text", text: "hi" }] })).toBe(false);
});
});
describe("countToolResults", () => {
it("counts tool_result blocks and tool_result_error blocks; tracks errors via is_error", () => {
expect(
countToolResults({
content: [
{ type: "tool_result" },
{ type: "tool_result", is_error: true },
{ type: "tool_result_error" },
{ type: "text", text: "ignore" },
],
}),
).toEqual({ total: 3, errors: 1 });
});
it("handles non-array content", () => {
expect(countToolResults({ content: "nope" })).toEqual({ total: 0, errors: 0 });
});
});
});

View File

@@ -0,0 +1,73 @@
type ToolResultCounts = {
total: number;
errors: number;
};
const TOOL_CALL_TYPES = new Set(["tool_use", "toolcall", "tool_call"]);
const TOOL_RESULT_TYPES = new Set(["tool_result", "tool_result_error"]);
const normalizeType = (value: unknown): string => {
if (typeof value !== "string") {
return "";
}
return value.trim().toLowerCase();
};
export const extractToolCallNames = (message: Record<string, unknown>): string[] => {
const names = new Set<string>();
const toolNameRaw = message.toolName ?? message.tool_name;
if (typeof toolNameRaw === "string" && toolNameRaw.trim()) {
names.add(toolNameRaw.trim());
}
const content = message.content;
if (!Array.isArray(content)) {
return Array.from(names);
}
for (const entry of content) {
if (!entry || typeof entry !== "object") {
continue;
}
const block = entry as Record<string, unknown>;
const type = normalizeType(block.type);
if (!TOOL_CALL_TYPES.has(type)) {
continue;
}
const name = block.name;
if (typeof name === "string" && name.trim()) {
names.add(name.trim());
}
}
return Array.from(names);
};
export const hasToolCall = (message: Record<string, unknown>): boolean =>
extractToolCallNames(message).length > 0;
export const countToolResults = (message: Record<string, unknown>): ToolResultCounts => {
const content = message.content;
if (!Array.isArray(content)) {
return { total: 0, errors: 0 };
}
let total = 0;
let errors = 0;
for (const entry of content) {
if (!entry || typeof entry !== "object") {
continue;
}
const block = entry as Record<string, unknown>;
const type = normalizeType(block.type);
if (!TOOL_RESULT_TYPES.has(type)) {
continue;
}
total += 1;
if (block.is_error === true) {
errors += 1;
}
}
return { total, errors };
};

View File

@@ -105,7 +105,11 @@ export function renderChatControls(state: AppViewState) {
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
syncUrlWithSessionKey(next, true);
syncUrlWithSessionKey(
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
next,
true,
);
void loadChatHistory(state as unknown as ChatState);
}}
>

View File

@@ -1,18 +1,17 @@
import { html, nothing } from "lit";
import type { AppViewState } from "./app-view-state.ts";
import type { UsageState } from "./controllers/usage.ts";
import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
import { ChatHost, refreshChatAvatar } from "./app-chat.ts";
import { refreshChatAvatar } from "./app-chat.ts";
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers.ts";
import { OpenClawApp } from "./app.ts";
import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts";
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts";
import { loadAgentSkills } from "./controllers/agent-skills.ts";
import { loadAgents } from "./controllers/agents.ts";
import { loadChannels } from "./controllers/channels.ts";
import { ChatState, loadChatHistory } from "./controllers/chat.ts";
import { loadChatHistory } from "./controllers/chat.ts";
import {
applyConfig,
ConfigState,
loadConfig,
runUpdate,
saveConfig,
@@ -40,7 +39,7 @@ import {
saveExecApprovals,
updateExecApprovalsFormValue,
} from "./controllers/exec-approvals.ts";
import { loadLogs, LogsState } from "./controllers/logs.ts";
import { loadLogs } from "./controllers/logs.ts";
import { loadNodes } from "./controllers/nodes.ts";
import { loadPresence } from "./controllers/presence.ts";
import { deleteSession, loadSessions, patchSession } from "./controllers/sessions.ts";
@@ -51,9 +50,18 @@ import {
updateSkillEdit,
updateSkillEnabled,
} from "./controllers/skills.ts";
import { loadUsage, loadSessionTimeSeries, loadSessionLogs } from "./controllers/usage.ts";
import { icons } from "./icons.ts";
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
import { ConfigUiHints } from "./types.ts";
// Module-scope debounce for usage date changes (avoids type-unsafe hacks on state object)
let usageDateDebounceTimeout: number | null = null;
const debouncedLoadUsage = (state: UsageState) => {
if (usageDateDebounceTimeout) {
clearTimeout(usageDateDebounceTimeout);
}
usageDateDebounceTimeout = window.setTimeout(() => void loadUsage(state), 400);
};
import { renderAgents } from "./views/agents.ts";
import { renderChannels } from "./views/channels.ts";
import { renderChat } from "./views/chat.ts";
@@ -68,6 +76,7 @@ import { renderNodes } from "./views/nodes.ts";
import { renderOverview } from "./views/overview.ts";
import { renderSessions } from "./views/sessions.ts";
import { renderSkills } from "./views/skills.ts";
import { renderUsage } from "./views/usage.ts";
const AVATAR_DATA_RE = /^data:/i;
const AVATAR_HTTP_RE = /^https?:\/\//i;
@@ -98,36 +107,14 @@ export function renderApp(state: AppViewState) {
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
const logoBase = normalizeBasePath(state.basePath);
const logoHref = logoBase ? `${logoBase}/favicon.svg` : "/favicon.svg";
const configValue =
state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null);
const basePath = normalizeBasePath(state.basePath ?? "");
const resolvedAgentId =
state.agentsSelectedId ??
state.agentsList?.defaultId ??
state.agentsList?.agents?.[0]?.id ??
null;
const ensureAgentListEntry = (agentId: string) => {
const snapshot = (state.configForm ??
(state.configSnapshot?.config as Record<string, unknown> | null)) as {
agents?: { list?: unknown[] };
} | null;
const listRaw = snapshot?.agents?.list;
const list = Array.isArray(listRaw) ? listRaw : [];
let index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
if (index < 0) {
const nextList = [...list, { id: agentId }];
updateConfigFormValue(state as unknown as ConfigState, ["agents", "list"], nextList);
index = nextList.length - 1;
}
return index;
};
return html`
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}">
@@ -147,7 +134,7 @@ export function renderApp(state: AppViewState) {
</button>
<div class="brand">
<div class="brand-logo">
<img src="${logoHref}" alt="OpenClaw" />
<img src=${basePath ? `${basePath}/favicon.svg` : "/favicon.svg"} alt="OpenClaw" />
</div>
<div class="brand-text">
<div class="brand-title">OPENCLAW</div>
@@ -212,8 +199,8 @@ export function renderApp(state: AppViewState) {
<main class="content ${isChat ? "content--chat" : ""}">
<section class="content-header">
<div>
<div class="page-title">${titleForTab(state.tab)}</div>
<div class="page-sub">${subtitleForTab(state.tab)}</div>
${state.tab === "usage" ? nothing : html`<div class="page-title">${titleForTab(state.tab)}</div>`}
${state.tab === "usage" ? nothing : html`<div class="page-sub">${subtitleForTab(state.tab)}</div>`}
</div>
<div class="page-meta">
${state.lastError ? html`<div class="pill danger">${state.lastError}</div>` : nothing}
@@ -239,7 +226,7 @@ export function renderApp(state: AppViewState) {
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
(state as unknown as OpenClawApp).resetToolStream();
state.resetToolStream();
state.applySettings({
...state.settings,
sessionKey: next,
@@ -268,7 +255,7 @@ export function renderApp(state: AppViewState) {
configSchema: state.configSchema,
configSchemaLoading: state.configSchemaLoading,
configForm: state.configForm,
configUiHints: state.configUiHints as ConfigUiHints,
configUiHints: state.configUiHints,
configSaving: state.configSaving,
configFormDirty: state.configFormDirty,
nostrProfileFormState: state.nostrProfileFormState,
@@ -277,8 +264,7 @@ export function renderApp(state: AppViewState) {
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppWait: () => state.handleWhatsAppWait(),
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
onConfigPatch: (path, value) =>
updateConfigFormValue(state as unknown as ConfigState, path, value),
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
onConfigSave: () => state.handleChannelConfigSave(),
onConfigReload: () => state.handleChannelConfigReload(),
onNostrProfileEdit: (accountId, profile) =>
@@ -329,6 +315,269 @@ export function renderApp(state: AppViewState) {
: nothing
}
${
state.tab === "usage"
? renderUsage({
loading: state.usageLoading,
error: state.usageError,
startDate: state.usageStartDate,
endDate: state.usageEndDate,
sessions: state.usageResult?.sessions ?? [],
sessionsLimitReached: (state.usageResult?.sessions?.length ?? 0) >= 1000,
totals: state.usageResult?.totals ?? null,
aggregates: state.usageResult?.aggregates ?? null,
costDaily: state.usageCostSummary?.daily ?? [],
selectedSessions: state.usageSelectedSessions,
selectedDays: state.usageSelectedDays,
selectedHours: state.usageSelectedHours,
chartMode: state.usageChartMode,
dailyChartMode: state.usageDailyChartMode,
timeSeriesMode: state.usageTimeSeriesMode,
timeSeriesBreakdownMode: state.usageTimeSeriesBreakdownMode,
timeSeries: state.usageTimeSeries,
timeSeriesLoading: state.usageTimeSeriesLoading,
sessionLogs: state.usageSessionLogs,
sessionLogsLoading: state.usageSessionLogsLoading,
sessionLogsExpanded: state.usageSessionLogsExpanded,
logFilterRoles: state.usageLogFilterRoles,
logFilterTools: state.usageLogFilterTools,
logFilterHasTools: state.usageLogFilterHasTools,
logFilterQuery: state.usageLogFilterQuery,
query: state.usageQuery,
queryDraft: state.usageQueryDraft,
sessionSort: state.usageSessionSort,
sessionSortDir: state.usageSessionSortDir,
recentSessions: state.usageRecentSessions,
sessionsTab: state.usageSessionsTab,
visibleColumns:
state.usageVisibleColumns as import("./views/usage.ts").UsageColumnId[],
timeZone: state.usageTimeZone,
contextExpanded: state.usageContextExpanded,
headerPinned: state.usageHeaderPinned,
onStartDateChange: (date) => {
state.usageStartDate = date;
state.usageSelectedDays = [];
state.usageSelectedHours = [];
state.usageSelectedSessions = [];
debouncedLoadUsage(state);
},
onEndDateChange: (date) => {
state.usageEndDate = date;
state.usageSelectedDays = [];
state.usageSelectedHours = [];
state.usageSelectedSessions = [];
debouncedLoadUsage(state);
},
onRefresh: () => loadUsage(state),
onTimeZoneChange: (zone) => {
state.usageTimeZone = zone;
},
onToggleContextExpanded: () => {
state.usageContextExpanded = !state.usageContextExpanded;
},
onToggleSessionLogsExpanded: () => {
state.usageSessionLogsExpanded = !state.usageSessionLogsExpanded;
},
onLogFilterRolesChange: (next) => {
state.usageLogFilterRoles = next;
},
onLogFilterToolsChange: (next) => {
state.usageLogFilterTools = next;
},
onLogFilterHasToolsChange: (next) => {
state.usageLogFilterHasTools = next;
},
onLogFilterQueryChange: (next) => {
state.usageLogFilterQuery = next;
},
onLogFilterClear: () => {
state.usageLogFilterRoles = [];
state.usageLogFilterTools = [];
state.usageLogFilterHasTools = false;
state.usageLogFilterQuery = "";
},
onToggleHeaderPinned: () => {
state.usageHeaderPinned = !state.usageHeaderPinned;
},
onSelectHour: (hour, shiftKey) => {
if (shiftKey && state.usageSelectedHours.length > 0) {
const allHours = Array.from({ length: 24 }, (_, i) => i);
const lastSelected =
state.usageSelectedHours[state.usageSelectedHours.length - 1];
const lastIdx = allHours.indexOf(lastSelected);
const thisIdx = allHours.indexOf(hour);
if (lastIdx !== -1 && thisIdx !== -1) {
const [start, end] =
lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx];
const range = allHours.slice(start, end + 1);
state.usageSelectedHours = [
...new Set([...state.usageSelectedHours, ...range]),
];
}
} else {
if (state.usageSelectedHours.includes(hour)) {
state.usageSelectedHours = state.usageSelectedHours.filter((h) => h !== hour);
} else {
state.usageSelectedHours = [...state.usageSelectedHours, hour];
}
}
},
onQueryDraftChange: (query) => {
state.usageQueryDraft = query;
if (state.usageQueryDebounceTimer) {
window.clearTimeout(state.usageQueryDebounceTimer);
}
state.usageQueryDebounceTimer = window.setTimeout(() => {
state.usageQuery = state.usageQueryDraft;
state.usageQueryDebounceTimer = null;
}, 250);
},
onApplyQuery: () => {
if (state.usageQueryDebounceTimer) {
window.clearTimeout(state.usageQueryDebounceTimer);
state.usageQueryDebounceTimer = null;
}
state.usageQuery = state.usageQueryDraft;
},
onClearQuery: () => {
if (state.usageQueryDebounceTimer) {
window.clearTimeout(state.usageQueryDebounceTimer);
state.usageQueryDebounceTimer = null;
}
state.usageQueryDraft = "";
state.usageQuery = "";
},
onSessionSortChange: (sort) => {
state.usageSessionSort = sort;
},
onSessionSortDirChange: (dir) => {
state.usageSessionSortDir = dir;
},
onSessionsTabChange: (tab) => {
state.usageSessionsTab = tab;
},
onToggleColumn: (column) => {
if (state.usageVisibleColumns.includes(column)) {
state.usageVisibleColumns = state.usageVisibleColumns.filter(
(entry) => entry !== column,
);
} else {
state.usageVisibleColumns = [...state.usageVisibleColumns, column];
}
},
onSelectSession: (key, shiftKey) => {
state.usageTimeSeries = null;
state.usageSessionLogs = null;
state.usageRecentSessions = [
key,
...state.usageRecentSessions.filter((entry) => entry !== key),
].slice(0, 8);
if (shiftKey && state.usageSelectedSessions.length > 0) {
// Shift-click: select range from last selected to this session
// Sort sessions same way as displayed (by tokens or cost descending)
const isTokenMode = state.usageChartMode === "tokens";
const sortedSessions = [...(state.usageResult?.sessions ?? [])].toSorted(
(a, b) => {
const valA = isTokenMode
? (a.usage?.totalTokens ?? 0)
: (a.usage?.totalCost ?? 0);
const valB = isTokenMode
? (b.usage?.totalTokens ?? 0)
: (b.usage?.totalCost ?? 0);
return valB - valA;
},
);
const allKeys = sortedSessions.map((s) => s.key);
const lastSelected =
state.usageSelectedSessions[state.usageSelectedSessions.length - 1];
const lastIdx = allKeys.indexOf(lastSelected);
const thisIdx = allKeys.indexOf(key);
if (lastIdx !== -1 && thisIdx !== -1) {
const [start, end] =
lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx];
const range = allKeys.slice(start, end + 1);
const newSelection = [...new Set([...state.usageSelectedSessions, ...range])];
state.usageSelectedSessions = newSelection;
}
} else {
// Regular click: focus a single session (so details always open).
// Click the focused session again to clear selection.
if (
state.usageSelectedSessions.length === 1 &&
state.usageSelectedSessions[0] === key
) {
state.usageSelectedSessions = [];
} else {
state.usageSelectedSessions = [key];
}
}
// Load timeseries/logs only if exactly one session selected
if (state.usageSelectedSessions.length === 1) {
void loadSessionTimeSeries(state, state.usageSelectedSessions[0]);
void loadSessionLogs(state, state.usageSelectedSessions[0]);
}
},
onSelectDay: (day, shiftKey) => {
if (shiftKey && state.usageSelectedDays.length > 0) {
// Shift-click: select range from last selected to this day
const allDays = (state.usageCostSummary?.daily ?? []).map((d) => d.date);
const lastSelected =
state.usageSelectedDays[state.usageSelectedDays.length - 1];
const lastIdx = allDays.indexOf(lastSelected);
const thisIdx = allDays.indexOf(day);
if (lastIdx !== -1 && thisIdx !== -1) {
const [start, end] =
lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx];
const range = allDays.slice(start, end + 1);
// Merge with existing selection
const newSelection = [...new Set([...state.usageSelectedDays, ...range])];
state.usageSelectedDays = newSelection;
}
} else {
// Regular click: toggle single day
if (state.usageSelectedDays.includes(day)) {
state.usageSelectedDays = state.usageSelectedDays.filter((d) => d !== day);
} else {
state.usageSelectedDays = [day];
}
}
},
onChartModeChange: (mode) => {
state.usageChartMode = mode;
},
onDailyChartModeChange: (mode) => {
state.usageDailyChartMode = mode;
},
onTimeSeriesModeChange: (mode) => {
state.usageTimeSeriesMode = mode;
},
onTimeSeriesBreakdownChange: (mode) => {
state.usageTimeSeriesBreakdownMode = mode;
},
onClearDays: () => {
state.usageSelectedDays = [];
},
onClearHours: () => {
state.usageSelectedHours = [];
},
onClearSessions: () => {
state.usageSelectedSessions = [];
state.usageTimeSeries = null;
state.usageSessionLogs = null;
},
onClearFilters: () => {
state.usageSelectedDays = [];
state.usageSelectedHours = [];
state.usageSelectedSessions = [];
state.usageTimeSeries = null;
state.usageSessionLogs = null;
},
})
: nothing
}
${
state.tab === "cron"
? renderCron({
@@ -444,17 +693,7 @@ export function renderApp(state: AppViewState) {
void state.loadCron();
}
},
onLoadFiles: (agentId) => {
void (async () => {
await loadAgentFiles(state, agentId);
if (state.agentFileActive) {
await loadAgentFileContent(state, agentId, state.agentFileActive, {
force: true,
preserveDraft: true,
});
}
})();
},
onLoadFiles: (agentId) => loadAgentFiles(state, agentId),
onSelectFile: (name) => {
state.agentFileActive = name;
if (!resolvedAgentId) {
@@ -497,19 +736,12 @@ export function renderApp(state: AppViewState) {
}
const basePath = ["agents", "list", index, "tools"];
if (profile) {
updateConfigFormValue(
state as unknown as ConfigState,
[...basePath, "profile"],
profile,
);
updateConfigFormValue(state, [...basePath, "profile"], profile);
} else {
removeConfigFormValue(state as unknown as ConfigState, [
...basePath,
"profile",
]);
removeConfigFormValue(state, [...basePath, "profile"]);
}
if (clearAllow) {
removeConfigFormValue(state as unknown as ConfigState, [...basePath, "allow"]);
removeConfigFormValue(state, [...basePath, "allow"]);
}
},
onToolsOverridesChange: (agentId, alsoAllow, deny) => {
@@ -532,29 +764,18 @@ export function renderApp(state: AppViewState) {
}
const basePath = ["agents", "list", index, "tools"];
if (alsoAllow.length > 0) {
updateConfigFormValue(
state as unknown as ConfigState,
[...basePath, "alsoAllow"],
alsoAllow,
);
updateConfigFormValue(state, [...basePath, "alsoAllow"], alsoAllow);
} else {
removeConfigFormValue(state as unknown as ConfigState, [
...basePath,
"alsoAllow",
]);
removeConfigFormValue(state, [...basePath, "alsoAllow"]);
}
if (deny.length > 0) {
updateConfigFormValue(
state as unknown as ConfigState,
[...basePath, "deny"],
deny,
);
updateConfigFormValue(state, [...basePath, "deny"], deny);
} else {
removeConfigFormValue(state as unknown as ConfigState, [...basePath, "deny"]);
removeConfigFormValue(state, [...basePath, "deny"]);
}
},
onConfigReload: () => loadConfig(state as unknown as ConfigState),
onConfigSave: () => saveConfig(state as unknown as ConfigState),
onConfigReload: () => loadConfig(state),
onConfigSave: () => saveConfig(state),
onChannelsRefresh: () => loadChannels(state, false),
onCronRefresh: () => state.loadCron(),
onSkillsFilterChange: (next) => (state.skillsFilter = next),
@@ -599,11 +820,7 @@ export function renderApp(state: AppViewState) {
} else {
next.delete(normalizedSkill);
}
updateConfigFormValue(
state as unknown as ConfigState,
["agents", "list", index, "skills"],
[...next],
);
updateConfigFormValue(state, ["agents", "list", index, "skills"], [...next]);
},
onAgentSkillsClear: (agentId) => {
if (!configValue) {
@@ -623,12 +840,7 @@ export function renderApp(state: AppViewState) {
if (index < 0) {
return;
}
removeConfigFormValue(state as unknown as ConfigState, [
"agents",
"list",
index,
"skills",
]);
removeConfigFormValue(state, ["agents", "list", index, "skills"]);
},
onAgentSkillsDisableAll: (agentId) => {
if (!configValue) {
@@ -648,58 +860,32 @@ export function renderApp(state: AppViewState) {
if (index < 0) {
return;
}
updateConfigFormValue(
state as unknown as ConfigState,
["agents", "list", index, "skills"],
[],
);
updateConfigFormValue(state, ["agents", "list", index, "skills"], []);
},
onModelChange: (agentId, modelId) => {
if (!configValue) {
return;
}
const defaultId = state.agentsList?.defaultId ?? null;
if (defaultId && agentId === defaultId) {
const basePath = ["agents", "defaults", "model"];
const defaults =
(configValue as { agents?: { defaults?: { model?: unknown } } }).agents
?.defaults ?? {};
const existing = defaults.model;
if (!modelId) {
removeConfigFormValue(state as unknown as ConfigState, basePath);
return;
}
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
const fallbacks = (existing as { fallbacks?: unknown }).fallbacks;
const next = {
primary: modelId,
...(Array.isArray(fallbacks) ? { fallbacks } : {}),
};
updateConfigFormValue(state as unknown as ConfigState, basePath, next);
} else {
updateConfigFormValue(state as unknown as ConfigState, basePath, {
primary: modelId,
});
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
if (index < 0) {
return;
}
const index = ensureAgentListEntry(agentId);
const basePath = ["agents", "list", index, "model"];
if (!modelId) {
removeConfigFormValue(state as unknown as ConfigState, basePath);
removeConfigFormValue(state, basePath);
return;
}
const list = (
(state.configForm ??
(state.configSnapshot?.config as Record<string, unknown> | null)) as {
agents?: { list?: unknown[] };
}
)?.agents?.list;
const entry =
Array.isArray(list) && list[index]
? (list[index] as { model?: unknown })
: null;
const entry = list[index] as { model?: unknown };
const existing = entry?.model;
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
const fallbacks = (existing as { fallbacks?: unknown }).fallbacks;
@@ -707,70 +893,33 @@ export function renderApp(state: AppViewState) {
primary: modelId,
...(Array.isArray(fallbacks) ? { fallbacks } : {}),
};
updateConfigFormValue(state as unknown as ConfigState, basePath, next);
updateConfigFormValue(state, basePath, next);
} else {
updateConfigFormValue(state as unknown as ConfigState, basePath, modelId);
updateConfigFormValue(state, basePath, modelId);
}
},
onModelFallbacksChange: (agentId, fallbacks) => {
if (!configValue) {
return;
}
const normalized = fallbacks.map((name) => name.trim()).filter(Boolean);
const defaultId = state.agentsList?.defaultId ?? null;
if (defaultId && agentId === defaultId) {
const basePath = ["agents", "defaults", "model"];
const defaults =
(configValue as { agents?: { defaults?: { model?: unknown } } }).agents
?.defaults ?? {};
const existing = defaults.model;
const resolvePrimary = () => {
if (typeof existing === "string") {
return existing.trim() || null;
}
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
const primary = (existing as { primary?: unknown }).primary;
if (typeof primary === "string") {
const trimmed = primary.trim();
return trimmed || null;
}
}
return null;
};
const primary = resolvePrimary();
if (normalized.length === 0) {
if (primary) {
updateConfigFormValue(state as unknown as ConfigState, basePath, {
primary,
});
} else {
removeConfigFormValue(state as unknown as ConfigState, basePath);
}
return;
}
const next = primary
? { primary, fallbacks: normalized }
: { fallbacks: normalized };
updateConfigFormValue(state as unknown as ConfigState, basePath, next);
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
if (index < 0) {
return;
}
const index = ensureAgentListEntry(agentId);
const basePath = ["agents", "list", index, "model"];
const list = (
(state.configForm ??
(state.configSnapshot?.config as Record<string, unknown> | null)) as {
agents?: { list?: unknown[] };
}
)?.agents?.list;
const entry =
Array.isArray(list) && list[index]
? (list[index] as { model?: unknown })
: null;
const existing = entry?.model;
if (!existing) {
return;
}
const entry = list[index] as { model?: unknown };
const normalized = fallbacks.map((name) => name.trim()).filter(Boolean);
const existing = entry.model;
const resolvePrimary = () => {
if (typeof existing === "string") {
return existing.trim() || null;
@@ -787,16 +936,16 @@ export function renderApp(state: AppViewState) {
const primary = resolvePrimary();
if (normalized.length === 0) {
if (primary) {
updateConfigFormValue(state as unknown as ConfigState, basePath, primary);
updateConfigFormValue(state, basePath, primary);
} else {
removeConfigFormValue(state as unknown as ConfigState, basePath);
removeConfigFormValue(state, basePath);
}
return;
}
const next = primary
? { primary, fallbacks: normalized }
: { fallbacks: normalized };
updateConfigFormValue(state as unknown as ConfigState, basePath, next);
updateConfigFormValue(state, basePath, next);
},
})
: nothing
@@ -853,7 +1002,7 @@ export function renderApp(state: AppViewState) {
onDeviceRotate: (deviceId, role, scopes) =>
rotateDeviceToken(state, { deviceId, role, scopes }),
onDeviceRevoke: (deviceId, role) => revokeDeviceToken(state, { deviceId, role }),
onLoadConfig: () => loadConfig(state as unknown as ConfigState),
onLoadConfig: () => loadConfig(state),
onLoadExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
@@ -863,28 +1012,20 @@ export function renderApp(state: AppViewState) {
},
onBindDefault: (nodeId) => {
if (nodeId) {
updateConfigFormValue(
state as unknown as ConfigState,
["tools", "exec", "node"],
nodeId,
);
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
} else {
removeConfigFormValue(state as unknown as ConfigState, [
"tools",
"exec",
"node",
]);
removeConfigFormValue(state, ["tools", "exec", "node"]);
}
},
onBindAgent: (agentIndex, nodeId) => {
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
if (nodeId) {
updateConfigFormValue(state as unknown as ConfigState, basePath, nodeId);
updateConfigFormValue(state, basePath, nodeId);
} else {
removeConfigFormValue(state as unknown as ConfigState, basePath);
removeConfigFormValue(state, basePath);
}
},
onSaveBindings: () => saveConfig(state as unknown as ConfigState),
onSaveBindings: () => saveConfig(state),
onExecApprovalsTargetChange: (kind, nodeId) => {
state.execApprovalsTarget = kind;
state.execApprovalsTargetNodeId = nodeId;
@@ -919,29 +1060,30 @@ export function renderApp(state: AppViewState) {
state.chatMessage = "";
state.chatAttachments = [];
state.chatStream = null;
state.chatStreamStartedAt = null;
state.chatRunId = null;
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
state.chatQueue = [];
(state as unknown as OpenClawApp).resetToolStream();
(state as unknown as OpenClawApp).resetChatScroll();
state.resetToolStream();
state.resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
void loadChatHistory(state as unknown as ChatState);
void refreshChatAvatar(state as unknown as ChatHost);
void loadChatHistory(state);
void refreshChatAvatar(state);
},
thinkingLevel: state.chatThinkingLevel,
showThinking,
loading: state.chatLoading,
sending: state.chatSending,
compactionStatus: state.compactionStatus,
assistantAvatarUrl: chatAvatarUrl,
messages: state.chatMessages,
toolMessages: state.chatToolMessages,
stream: state.chatStream,
streamStartedAt: null,
streamStartedAt: state.chatStreamStartedAt,
draft: state.chatMessage,
queue: state.chatQueue,
connected: state.connected,
@@ -951,10 +1093,8 @@ export function renderApp(state: AppViewState) {
sessions: state.sessionsResult,
focusMode: chatFocus,
onRefresh: () => {
return Promise.all([
loadChatHistory(state as unknown as ChatState),
refreshChatAvatar(state as unknown as ChatHost),
]);
state.resetToolStream();
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
},
onToggleFocusMode: () => {
if (state.onboarding) {
@@ -965,28 +1105,25 @@ export function renderApp(state: AppViewState) {
chatFocusMode: !state.settings.chatFocusMode,
});
},
onChatScroll: (event) => (state as unknown as OpenClawApp).handleChatScroll(event),
onChatScroll: (event) => state.handleChatScroll(event),
onDraftChange: (next) => (state.chatMessage = next),
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => (state as unknown as OpenClawApp).handleSendChat(),
onSend: () => state.handleSendChat(),
canAbort: Boolean(state.chatRunId),
onAbort: () => void (state as unknown as OpenClawApp).handleAbortChat(),
onQueueRemove: (id) => (state as unknown as OpenClawApp).removeQueuedMessage(id),
onNewSession: () =>
(state as unknown as OpenClawApp).handleSendChat("/new", { restoreDraft: true }),
onAbort: () => void state.handleAbortChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id),
onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }),
showNewMessages: state.chatNewMessagesBelow,
onScrollToBottom: () => state.scrollToBottom(),
// Sidebar props for tool output viewing
sidebarOpen: (state as unknown as OpenClawApp).sidebarOpen,
sidebarContent: (state as unknown as OpenClawApp).sidebarContent,
sidebarError: (state as unknown as OpenClawApp).sidebarError,
splitRatio: (state as unknown as OpenClawApp).splitRatio,
onOpenSidebar: (content: string) =>
(state as unknown as OpenClawApp).handleOpenSidebar(content),
onCloseSidebar: () => (state as unknown as OpenClawApp).handleCloseSidebar(),
onSplitRatioChange: (ratio: number) =>
(state as unknown as OpenClawApp).handleSplitRatioChange(ratio),
sidebarOpen: state.sidebarOpen,
sidebarContent: state.sidebarContent,
sidebarError: state.sidebarError,
splitRatio: state.splitRatio,
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
onCloseSidebar: () => state.handleCloseSidebar(),
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
assistantName: state.assistantName,
assistantAvatar: state.assistantAvatar,
})
@@ -1007,31 +1144,28 @@ export function renderApp(state: AppViewState) {
connected: state.connected,
schema: state.configSchema,
schemaLoading: state.configSchemaLoading,
uiHints: state.configUiHints as ConfigUiHints,
uiHints: state.configUiHints,
formMode: state.configFormMode,
formValue: state.configForm,
originalValue: state.configFormOriginal,
searchQuery: (state as unknown as OpenClawApp).configSearchQuery,
activeSection: (state as unknown as OpenClawApp).configActiveSection,
activeSubsection: (state as unknown as OpenClawApp).configActiveSubsection,
searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection,
onRawChange: (next) => {
state.configRaw = next;
},
onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) =>
updateConfigFormValue(state as unknown as OpenClawApp, path, value),
onSearchChange: (query) =>
((state as unknown as OpenClawApp).configSearchQuery = query),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onSearchChange: (query) => (state.configSearchQuery = query),
onSectionChange: (section) => {
(state as unknown as OpenClawApp).configActiveSection = section;
(state as unknown as OpenClawApp).configActiveSubsection = null;
state.configActiveSection = section;
state.configActiveSubsection = null;
},
onSubsectionChange: (section) =>
((state as unknown as OpenClawApp).configActiveSubsection = section),
onReload: () => loadConfig(state as unknown as OpenClawApp),
onSave: () => saveConfig(state as unknown as OpenClawApp),
onApply: () => applyConfig(state as unknown as OpenClawApp),
onUpdate: () => runUpdate(state as unknown as OpenClawApp),
onSubsectionChange: (section) => (state.configActiveSubsection = section),
onReload: () => loadConfig(state),
onSave: () => saveConfig(state),
onApply: () => applyConfig(state),
onUpdate: () => runUpdate(state),
})
: nothing
}
@@ -1073,10 +1207,9 @@ export function renderApp(state: AppViewState) {
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
},
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
onRefresh: () => loadLogs(state as unknown as LogsState, { reset: true }),
onExport: (lines, label) =>
(state as unknown as OpenClawApp).exportLogs(lines, label),
onScroll: (event) => (state as unknown as OpenClawApp).handleLogsScroll(event),
onRefresh: () => loadLogs(state, { reset: true }),
onExport: (lines, label) => state.exportLogs(lines, label),
onScroll: (event) => state.handleLogsScroll(event),
})
: nothing
}

View File

@@ -1,4 +1,5 @@
import type { OpenClawApp } from "./app.ts";
import type { AgentsListResult } from "./types.ts";
import { refreshChat } from "./app-chat.ts";
import {
startLogsPolling,
@@ -35,6 +36,7 @@ import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme.ts";
type SettingsHost = {
settings: UiSettings;
password?: string;
theme: ThemeMode;
themeResolved: ResolvedTheme;
applySessionKey: string;
@@ -46,35 +48,14 @@ type SettingsHost = {
eventLog: unknown[];
eventLogBuffer: unknown[];
basePath: string;
agentsList?: AgentsListResult | null;
agentsSelectedId?: string | null;
agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
themeMedia: MediaQueryList | null;
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
pendingGatewayUrl?: string | null;
};
function isTopLevelWindow(): boolean {
try {
return window.top === window.self;
} catch {
return false;
}
}
function normalizeGatewayUrl(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
return null;
}
return trimmed;
} catch {
return null;
}
}
export function applySettings(host: SettingsHost, next: UiSettings) {
const normalized = {
...next,
@@ -117,6 +98,10 @@ export function applySettingsFromUrl(host: SettingsHost) {
}
if (passwordRaw != null) {
const password = passwordRaw.trim();
if (password) {
(host as { password: string }).password = password;
}
params.delete("password");
shouldCleanUrl = true;
}
@@ -134,8 +119,8 @@ export function applySettingsFromUrl(host: SettingsHost) {
}
if (gatewayUrlRaw != null) {
const gatewayUrl = normalizeGatewayUrl(gatewayUrlRaw);
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl && isTopLevelWindow()) {
const gatewayUrl = gatewayUrlRaw.trim();
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) {
host.pendingGatewayUrl = gatewayUrl;
}
params.delete("gatewayUrl");
@@ -205,24 +190,23 @@ export async function refreshActiveTab(host: SettingsHost) {
await loadSkills(host as unknown as OpenClawApp);
}
if (host.tab === "agents") {
const app = host as unknown as OpenClawApp;
await loadAgents(app);
await loadConfig(app);
const agentIds = app.agentsList?.agents?.map((entry) => entry.id) ?? [];
await loadAgents(host as unknown as OpenClawApp);
await loadConfig(host as unknown as OpenClawApp);
const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? [];
if (agentIds.length > 0) {
void loadAgentIdentities(app, agentIds);
void loadAgentIdentities(host as unknown as OpenClawApp, agentIds);
}
const agentId =
app.agentsSelectedId ?? app.agentsList?.defaultId ?? app.agentsList?.agents?.[0]?.id;
host.agentsSelectedId ?? host.agentsList?.defaultId ?? host.agentsList?.agents?.[0]?.id;
if (agentId) {
void loadAgentIdentity(app, agentId);
if (app.agentsPanel === "skills") {
void loadAgentSkills(app, agentId);
void loadAgentIdentity(host as unknown as OpenClawApp, agentId);
if (host.agentsPanel === "skills") {
void loadAgentSkills(host as unknown as OpenClawApp, agentId);
}
if (app.agentsPanel === "channels") {
void loadChannels(app, false);
if (host.agentsPanel === "channels") {
void loadChannels(host as unknown as OpenClawApp, false);
}
if (app.agentsPanel === "cron") {
if (host.agentsPanel === "cron") {
void loadCron(host);
}
}
@@ -397,7 +381,7 @@ export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) {
}
}
export function syncUrlWithSessionKey(sessionKey: string, replace: boolean) {
export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, replace: boolean) {
if (typeof window === "undefined") {
return;
}

View File

@@ -1,4 +1,5 @@
import type { EventLogEntry } from "./app-events.ts";
import type { CompactionStatus } from "./app-tool-stream.ts";
import type { DevicePairingList } from "./controllers/devices.ts";
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
@@ -14,6 +15,7 @@ import type {
AgentIdentityResult,
ChannelsStatusSnapshot,
ConfigSnapshot,
ConfigUiHints,
CronJob,
CronRunLogEntry,
CronStatus,
@@ -22,12 +24,16 @@ import type {
LogLevel,
NostrProfile,
PresenceEntry,
SessionsUsageResult,
CostUsageSummary,
SessionUsageTimeSeries,
SessionsListResult,
SkillStatusReport,
StatusSummary,
} from "./types.ts";
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types.ts";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
import type { SessionLogEntry } from "./views/usage.ts";
export type AppViewState = {
settings: UiSettings;
@@ -52,13 +58,19 @@ export type AppViewState = {
chatMessages: unknown[];
chatToolMessages: unknown[];
chatStream: string | null;
chatStreamStartedAt: number | null;
chatRunId: string | null;
compactionStatus: CompactionStatus | null;
chatAvatarUrl: string | null;
chatThinkingLevel: string | null;
chatQueue: ChatQueueItem[];
nodesLoading: boolean;
nodes: Array<Record<string, unknown>>;
chatNewMessagesBelow: boolean;
sidebarOpen: boolean;
sidebarContent: string | null;
sidebarError: string | null;
splitRatio: number;
scrollToBottom: () => void;
devicesLoading: boolean;
devicesError: string | null;
@@ -83,13 +95,18 @@ export type AppViewState = {
configSaving: boolean;
configApplying: boolean;
updateRunning: boolean;
applySessionKey: string;
configSnapshot: ConfigSnapshot | null;
configSchema: unknown;
configSchemaVersion: string | null;
configSchemaLoading: boolean;
configUiHints: Record<string, unknown>;
configUiHints: ConfigUiHints;
configForm: Record<string, unknown> | null;
configFormOriginal: Record<string, unknown> | null;
configFormMode: "form" | "raw";
configSearchQuery: string;
configActiveSection: string | null;
configActiveSubsection: string | null;
channelsLoading: boolean;
channelsSnapshot: ChannelsStatusSnapshot | null;
channelsError: string | null;
@@ -131,6 +148,39 @@ export type AppViewState = {
sessionsFilterLimit: string;
sessionsIncludeGlobal: boolean;
sessionsIncludeUnknown: boolean;
usageLoading: boolean;
usageResult: SessionsUsageResult | null;
usageCostSummary: CostUsageSummary | null;
usageError: string | null;
usageStartDate: string;
usageEndDate: string;
usageSelectedSessions: string[];
usageSelectedDays: string[];
usageSelectedHours: number[];
usageChartMode: "tokens" | "cost";
usageDailyChartMode: "total" | "by-type";
usageTimeSeriesMode: "cumulative" | "per-turn";
usageTimeSeriesBreakdownMode: "total" | "by-type";
usageTimeSeries: SessionUsageTimeSeries | null;
usageTimeSeriesLoading: boolean;
usageSessionLogs: SessionLogEntry[] | null;
usageSessionLogsLoading: boolean;
usageSessionLogsExpanded: boolean;
usageQuery: string;
usageQueryDraft: string;
usageQueryDebounceTimer: number | null;
usageSessionSort: "tokens" | "cost" | "recent" | "messages" | "errors";
usageSessionSortDir: "asc" | "desc";
usageRecentSessions: string[];
usageTimeZone: "local" | "utc";
usageContextExpanded: boolean;
usageHeaderPinned: boolean;
usageSessionsTab: "all" | "recent";
usageVisibleColumns: string[];
usageLogFilterRoles: import("./views/usage.js").SessionLogRole[];
usageLogFilterTools: string[];
usageLogFilterHasTools: boolean;
usageLogFilterQuery: string;
cronLoading: boolean;
cronJobs: CronJob[];
cronStatus: CronStatus | null;
@@ -163,7 +213,13 @@ export type AppViewState = {
logsLevelFilters: Record<LogLevel, boolean>;
logsAutoFollow: boolean;
logsTruncated: boolean;
logsCursor: number | null;
logsLastFetchAt: number | null;
logsLimit: number;
logsMaxBytes: number;
logsAtBottom: boolean;
client: GatewayBrowserClient | null;
refreshSessionsAfterChat: Set<string>;
connect: () => void;
setTab: (tab: Tab) => void;
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
@@ -214,13 +270,15 @@ export type AppViewState = {
setPassword: (next: string) => void;
setSessionKey: (next: string) => void;
setChatMessage: (next: string) => void;
handleChatSend: () => Promise<void>;
handleChatAbort: () => Promise<void>;
handleChatSelectQueueItem: (id: string) => void;
handleChatDropQueueItem: (id: string) => void;
handleChatClearQueue: () => void;
handleLogsFilterChange: (next: string) => void;
handleLogsLevelFilterToggle: (level: LogLevel) => void;
handleLogsAutoFollowToggle: (next: boolean) => void;
handleCallDebugMethod: (method: string, params: string) => Promise<void>;
handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise<void>;
handleAbortChat: () => Promise<void>;
removeQueuedMessage: (id: string) => void;
handleChatScroll: (event: Event) => void;
resetToolStream: () => void;
resetChatScroll: () => void;
exportLogs: (lines: string[], label: string) => void;
handleLogsScroll: (event: Event) => void;
handleOpenSidebar: (content: string) => void;
handleCloseSidebar: () => void;
handleSplitRatioChange: (ratio: number) => void;
};

View File

@@ -74,6 +74,7 @@ import {
import {
resetToolStream as resetToolStreamInternal,
type ToolStreamEntry,
type CompactionStatus,
} from "./app-tool-stream.ts";
import { resolveInjectedAssistantIdentity } from "./assistant-identity.ts";
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts";
@@ -130,7 +131,7 @@ export class OpenClawApp extends LitElement {
@state() chatStream: string | null = null;
@state() chatStreamStartedAt: number | null = null;
@state() chatRunId: string | null = null;
@state() compactionStatus: import("./app-tool-stream.ts").CompactionStatus | null = null;
@state() compactionStatus: CompactionStatus | null = null;
@state() chatAvatarUrl: string | null = null;
@state() chatThinkingLevel: string | null = null;
@state() chatQueue: ChatQueueItem[] = [];
@@ -226,6 +227,59 @@ export class OpenClawApp extends LitElement {
@state() sessionsIncludeGlobal = true;
@state() sessionsIncludeUnknown = false;
@state() usageLoading = false;
@state() usageResult: import("./types.js").SessionsUsageResult | null = null;
@state() usageCostSummary: import("./types.js").CostUsageSummary | null = null;
@state() usageError: string | null = null;
@state() usageStartDate = (() => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
})();
@state() usageEndDate = (() => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
})();
@state() usageSelectedSessions: string[] = [];
@state() usageSelectedDays: string[] = [];
@state() usageSelectedHours: number[] = [];
@state() usageChartMode: "tokens" | "cost" = "tokens";
@state() usageDailyChartMode: "total" | "by-type" = "by-type";
@state() usageTimeSeriesMode: "cumulative" | "per-turn" = "per-turn";
@state() usageTimeSeriesBreakdownMode: "total" | "by-type" = "by-type";
@state() usageTimeSeries: import("./types.js").SessionUsageTimeSeries | null = null;
@state() usageTimeSeriesLoading = false;
@state() usageSessionLogs: import("./views/usage.js").SessionLogEntry[] | null = null;
@state() usageSessionLogsLoading = false;
@state() usageSessionLogsExpanded = false;
// Applied query (used to filter the already-loaded sessions list client-side).
@state() usageQuery = "";
// Draft query text (updates immediately as the user types; applied via debounce or "Search").
@state() usageQueryDraft = "";
@state() usageSessionSort: "tokens" | "cost" | "recent" | "messages" | "errors" = "recent";
@state() usageSessionSortDir: "desc" | "asc" = "desc";
@state() usageRecentSessions: string[] = [];
@state() usageTimeZone: "local" | "utc" = "local";
@state() usageContextExpanded = false;
@state() usageHeaderPinned = false;
@state() usageSessionsTab: "all" | "recent" = "all";
@state() usageVisibleColumns: string[] = [
"channel",
"agent",
"provider",
"model",
"messages",
"tools",
"errors",
"duration",
];
@state() usageLogFilterRoles: import("./views/usage.js").SessionLogRole[] = [];
@state() usageLogFilterTools: string[] = [];
@state() usageLogFilterHasTools = false;
@state() usageLogFilterQuery = "";
// Non-reactive (dont trigger renders just for timer bookkeeping).
usageQueryDebounceTimer: number | null = null;
@state() cronLoading = false;
@state() cronJobs: CronJob[] = [];
@state() cronStatus: CronStatus | null = null;

View File

@@ -0,0 +1,107 @@
import type { GatewayBrowserClient } from "../gateway.ts";
import type { SessionsUsageResult, CostUsageSummary, SessionUsageTimeSeries } from "../types.ts";
import type { SessionLogEntry } from "../views/usage.ts";
export type UsageState = {
client: GatewayBrowserClient | null;
connected: boolean;
usageLoading: boolean;
usageResult: SessionsUsageResult | null;
usageCostSummary: CostUsageSummary | null;
usageError: string | null;
usageStartDate: string;
usageEndDate: string;
usageSelectedSessions: string[];
usageSelectedDays: string[];
usageTimeSeries: SessionUsageTimeSeries | null;
usageTimeSeriesLoading: boolean;
usageSessionLogs: SessionLogEntry[] | null;
usageSessionLogsLoading: boolean;
};
export async function loadUsage(
state: UsageState,
overrides?: {
startDate?: string;
endDate?: string;
},
) {
if (!state.client || !state.connected) {
return;
}
if (state.usageLoading) {
return;
}
state.usageLoading = true;
state.usageError = null;
try {
const startDate = overrides?.startDate ?? state.usageStartDate;
const endDate = overrides?.endDate ?? state.usageEndDate;
// Load both endpoints in parallel
const [sessionsRes, costRes] = await Promise.all([
state.client.request("sessions.usage", {
startDate,
endDate,
limit: 1000, // Cap at 1000 sessions
includeContextWeight: true,
}),
state.client.request("usage.cost", { startDate, endDate }),
]);
if (sessionsRes) {
state.usageResult = sessionsRes as SessionsUsageResult;
}
if (costRes) {
state.usageCostSummary = costRes as CostUsageSummary;
}
} catch (err) {
state.usageError = String(err);
} finally {
state.usageLoading = false;
}
}
export async function loadSessionTimeSeries(state: UsageState, sessionKey: string) {
if (!state.client || !state.connected) {
return;
}
if (state.usageTimeSeriesLoading) {
return;
}
state.usageTimeSeriesLoading = true;
state.usageTimeSeries = null;
try {
const res = await state.client.request("sessions.usage.timeseries", { key: sessionKey });
if (res) {
state.usageTimeSeries = res as SessionUsageTimeSeries;
}
} catch {
// Silently fail - time series is optional
state.usageTimeSeries = null;
} finally {
state.usageTimeSeriesLoading = false;
}
}
export async function loadSessionLogs(state: UsageState, sessionKey: string) {
if (!state.client || !state.connected) {
return;
}
if (state.usageSessionLogsLoading) {
return;
}
state.usageSessionLogsLoading = true;
state.usageSessionLogs = null;
try {
const res = await state.client.request("sessions.usage.logs", { key: sessionKey, limit: 500 });
if (res && Array.isArray((res as { logs: SessionLogEntry[] }).logs)) {
state.usageSessionLogs = (res as { logs: SessionLogEntry[] }).logs;
}
} catch {
// Silently fail - logs are optional
state.usageSessionLogs = null;
} finally {
state.usageSessionLogsLoading = false;
}
}

View File

@@ -4,7 +4,7 @@ export const TAB_GROUPS = [
{ label: "Chat", tabs: ["chat"] },
{
label: "Control",
tabs: ["overview", "channels", "instances", "sessions", "cron"],
tabs: ["overview", "channels", "instances", "sessions", "usage", "cron"],
},
{ label: "Agent", tabs: ["agents", "skills", "nodes"] },
{ label: "Settings", tabs: ["config", "debug", "logs"] },
@@ -16,6 +16,7 @@ export type Tab =
| "channels"
| "instances"
| "sessions"
| "usage"
| "cron"
| "skills"
| "nodes"
@@ -30,6 +31,7 @@ const TAB_PATHS: Record<Tab, string> = {
channels: "/channels",
instances: "/instances",
sessions: "/sessions",
usage: "/usage",
cron: "/cron",
skills: "/skills",
nodes: "/nodes",
@@ -134,6 +136,8 @@ export function iconForTab(tab: Tab): IconName {
return "radio";
case "sessions":
return "fileText";
case "usage":
return "barChart";
case "cron":
return "loader";
case "skills":
@@ -163,6 +167,8 @@ export function titleForTab(tab: Tab) {
return "Instances";
case "sessions":
return "Sessions";
case "usage":
return "Usage";
case "cron":
return "Cron Jobs";
case "skills":
@@ -194,6 +200,8 @@ export function subtitleForTab(tab: Tab) {
return "Presence beacons from connected clients and nodes.";
case "sessions":
return "Inspect active sessions and adjust per-session defaults.";
case "usage":
return "";
case "cron":
return "Schedule wakeups and recurring agent runs.";
case "skills":

View File

@@ -302,20 +302,20 @@ export type ConfigSchemaResponse = {
};
export type PresenceEntry = {
deviceFamily?: string | null;
host?: string | null;
instanceId?: string | null;
host?: string | null;
ip?: string | null;
lastInputSeconds?: number | null;
mode?: string | null;
modelIdentifier?: string | null;
version?: string | null;
platform?: string | null;
deviceFamily?: string | null;
modelIdentifier?: string | null;
roles?: string[] | null;
scopes?: string[] | null;
mode?: string | null;
lastInputSeconds?: number | null;
reason?: string | null;
roles?: Array<string | null> | null;
scopes?: Array<string | null> | null;
text?: string | null;
ts?: number | null;
version?: string | null;
};
export type GatewaySessionsDefaults = {
@@ -424,6 +424,223 @@ export type SessionsPatchResult = {
};
};
export type SessionsUsageEntry = {
key: string;
label?: string;
sessionId?: string;
updatedAt?: number;
agentId?: string;
channel?: string;
chatType?: string;
origin?: {
label?: string;
provider?: string;
surface?: string;
chatType?: string;
from?: string;
to?: string;
accountId?: string;
threadId?: string | number;
};
modelOverride?: string;
providerOverride?: string;
modelProvider?: string;
model?: string;
usage: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
totalCost: number;
inputCost?: number;
outputCost?: number;
cacheReadCost?: number;
cacheWriteCost?: number;
missingCostEntries: number;
firstActivity?: number;
lastActivity?: number;
durationMs?: number;
activityDates?: string[]; // YYYY-MM-DD dates when session had activity
dailyBreakdown?: Array<{ date: string; tokens: number; cost: number }>;
dailyMessageCounts?: Array<{
date: string;
total: number;
user: number;
assistant: number;
toolCalls: number;
toolResults: number;
errors: number;
}>;
dailyLatency?: Array<{
date: string;
count: number;
avgMs: number;
p95Ms: number;
minMs: number;
maxMs: number;
}>;
dailyModelUsage?: Array<{
date: string;
provider?: string;
model?: string;
tokens: number;
cost: number;
count: number;
}>;
messageCounts?: {
total: number;
user: number;
assistant: number;
toolCalls: number;
toolResults: number;
errors: number;
};
toolUsage?: {
totalCalls: number;
uniqueTools: number;
tools: Array<{ name: string; count: number }>;
};
modelUsage?: Array<{
provider?: string;
model?: string;
count: number;
totals: SessionsUsageTotals;
}>;
latency?: {
count: number;
avgMs: number;
p95Ms: number;
minMs: number;
maxMs: number;
};
} | null;
contextWeight?: {
systemPrompt: { chars: number; projectContextChars: number; nonProjectContextChars: number };
skills: { promptChars: number; entries: Array<{ name: string; blockChars: number }> };
tools: {
listChars: number;
schemaChars: number;
entries: Array<{ name: string; summaryChars: number; schemaChars: number }>;
};
injectedWorkspaceFiles: Array<{
name: string;
path: string;
rawChars: number;
injectedChars: number;
truncated: boolean;
}>;
} | null;
};
export type SessionsUsageTotals = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
totalCost: number;
inputCost: number;
outputCost: number;
cacheReadCost: number;
cacheWriteCost: number;
missingCostEntries: number;
};
export type SessionsUsageResult = {
updatedAt: number;
startDate: string;
endDate: string;
sessions: SessionsUsageEntry[];
totals: SessionsUsageTotals;
aggregates: {
messages: {
total: number;
user: number;
assistant: number;
toolCalls: number;
toolResults: number;
errors: number;
};
tools: {
totalCalls: number;
uniqueTools: number;
tools: Array<{ name: string; count: number }>;
};
byModel: Array<{
provider?: string;
model?: string;
count: number;
totals: SessionsUsageTotals;
}>;
byProvider: Array<{
provider?: string;
model?: string;
count: number;
totals: SessionsUsageTotals;
}>;
byAgent: Array<{ agentId: string; totals: SessionsUsageTotals }>;
byChannel: Array<{ channel: string; totals: SessionsUsageTotals }>;
latency?: {
count: number;
avgMs: number;
p95Ms: number;
minMs: number;
maxMs: number;
};
dailyLatency?: Array<{
date: string;
count: number;
avgMs: number;
p95Ms: number;
minMs: number;
maxMs: number;
}>;
modelDaily?: Array<{
date: string;
provider?: string;
model?: string;
tokens: number;
cost: number;
count: number;
}>;
daily: Array<{
date: string;
tokens: number;
cost: number;
messages: number;
toolCalls: number;
errors: number;
}>;
};
};
export type CostUsageDailyEntry = SessionsUsageTotals & { date: string };
export type CostUsageSummary = {
updatedAt: number;
days: number;
daily: CostUsageDailyEntry[];
totals: SessionsUsageTotals;
};
export type SessionUsageTimePoint = {
timestamp: number;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
cost: number;
cumulativeTokens: number;
cumulativeCost: number;
};
export type SessionUsageTimeSeries = {
sessionId?: string;
points: SessionUsageTimePoint[];
};
export type CronSchedule =
| { kind: "at"; at: string }
| { kind: "every"; everyMs: number; anchorMs?: number }
@@ -506,10 +723,10 @@ export type SkillStatusEntry = {
name: string;
description: string;
source: string;
bundled?: boolean;
filePath: string;
baseDir: string;
skillKey: string;
bundled?: boolean;
primaryEnv?: string;
emoji?: string;
homepage?: string;

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { extractQueryTerms, filterSessionsByQuery, parseToolSummary } from "./usage-helpers.ts";
describe("usage-helpers", () => {
it("tokenizes query terms including quoted strings", () => {
const terms = extractQueryTerms('agent:main "model:gpt-5.2" has:errors');
expect(terms.map((t) => t.raw)).toEqual(["agent:main", "model:gpt-5.2", "has:errors"]);
});
it("matches key: glob filters against session keys", () => {
const session = {
key: "agent:main:cron:16234bc?token=dev-token",
label: "agent:main:cron:16234bc?token=dev-token",
usage: { totalTokens: 100, totalCost: 0 },
};
const matches = filterSessionsByQuery([session], "key:agent:main:cron*");
expect(matches.sessions).toHaveLength(1);
});
it("supports numeric filters like minTokens/maxTokens", () => {
const a = { key: "a", label: "a", usage: { totalTokens: 100, totalCost: 0 } };
const b = { key: "b", label: "b", usage: { totalTokens: 5, totalCost: 0 } };
expect(filterSessionsByQuery([a, b], "minTokens:10").sessions).toEqual([a]);
expect(filterSessionsByQuery([a, b], "maxTokens:10").sessions).toEqual([b]);
});
it("warns on unknown keys and invalid numbers", () => {
const session = { key: "a", usage: { totalTokens: 10, totalCost: 0 } };
const res = filterSessionsByQuery([session], "wat:1 minTokens:wat");
expect(res.warnings.some((w) => w.includes("Unknown filter"))).toBe(true);
expect(res.warnings.some((w) => w.includes("Invalid number"))).toBe(true);
});
it("parses tool summaries from compact session logs", () => {
const res = parseToolSummary(
"[Tool: read]\n[Tool Result]\n[Tool: exec]\n[Tool: read]\n[Tool Result]",
);
expect(res.summary).toContain("read");
expect(res.summary).toContain("exec");
expect(res.tools[0]?.[0]).toBe("read");
expect(res.tools[0]?.[1]).toBe(2);
});
});

321
ui/src/ui/usage-helpers.ts Normal file
View File

@@ -0,0 +1,321 @@
export type UsageQueryTerm = {
key?: string;
value: string;
raw: string;
};
export type UsageQueryResult<TSession> = {
sessions: TSession[];
warnings: string[];
};
// Minimal shape required for query filtering. The usage view's real session type contains more fields.
export type UsageSessionQueryTarget = {
key: string;
label?: string;
sessionId?: string;
agentId?: string;
channel?: string;
chatType?: string;
modelProvider?: string;
providerOverride?: string;
origin?: { provider?: string };
model?: string;
contextWeight?: unknown;
usage?: {
totalTokens?: number;
totalCost?: number;
messageCounts?: { total?: number; errors?: number };
toolUsage?: { totalCalls?: number; tools?: Array<{ name: string }> };
modelUsage?: Array<{ provider?: string; model?: string }>;
} | null;
};
const QUERY_KEYS = new Set([
"agent",
"channel",
"chat",
"provider",
"model",
"tool",
"label",
"key",
"session",
"id",
"has",
"mintokens",
"maxtokens",
"mincost",
"maxcost",
"minmessages",
"maxmessages",
]);
const normalizeQueryText = (value: string): string => value.trim().toLowerCase();
const globToRegex = (pattern: string): RegExp => {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
return new RegExp(`^${escaped}$`, "i");
};
const parseQueryNumber = (value: string): number | null => {
let raw = value.trim().toLowerCase();
if (!raw) {
return null;
}
if (raw.startsWith("$")) {
raw = raw.slice(1);
}
let multiplier = 1;
if (raw.endsWith("k")) {
multiplier = 1_000;
raw = raw.slice(0, -1);
} else if (raw.endsWith("m")) {
multiplier = 1_000_000;
raw = raw.slice(0, -1);
}
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
return null;
}
return parsed * multiplier;
};
export const extractQueryTerms = (query: string): UsageQueryTerm[] => {
// Tokenize by whitespace, but allow quoted values with spaces.
const rawTokens = query.match(/"[^"]+"|\S+/g) ?? [];
return rawTokens.map((token) => {
const cleaned = token.replace(/^"|"$/g, "");
const idx = cleaned.indexOf(":");
if (idx > 0) {
const key = cleaned.slice(0, idx);
const value = cleaned.slice(idx + 1);
return { key, value, raw: cleaned };
}
return { value: cleaned, raw: cleaned };
});
};
const getSessionText = (session: UsageSessionQueryTarget): string[] => {
const items: Array<string | undefined> = [session.label, session.key, session.sessionId];
return items.filter((item): item is string => Boolean(item)).map((item) => item.toLowerCase());
};
const getSessionProviders = (session: UsageSessionQueryTarget): string[] => {
const providers = new Set<string>();
if (session.modelProvider) {
providers.add(session.modelProvider.toLowerCase());
}
if (session.providerOverride) {
providers.add(session.providerOverride.toLowerCase());
}
if (session.origin?.provider) {
providers.add(session.origin.provider.toLowerCase());
}
for (const entry of session.usage?.modelUsage ?? []) {
if (entry.provider) {
providers.add(entry.provider.toLowerCase());
}
}
return Array.from(providers);
};
const getSessionModels = (session: UsageSessionQueryTarget): string[] => {
const models = new Set<string>();
if (session.model) {
models.add(session.model.toLowerCase());
}
for (const entry of session.usage?.modelUsage ?? []) {
if (entry.model) {
models.add(entry.model.toLowerCase());
}
}
return Array.from(models);
};
const getSessionTools = (session: UsageSessionQueryTarget): string[] =>
(session.usage?.toolUsage?.tools ?? []).map((tool) => tool.name.toLowerCase());
export const matchesUsageQuery = (
session: UsageSessionQueryTarget,
term: UsageQueryTerm,
): boolean => {
const value = normalizeQueryText(term.value ?? "");
if (!value) {
return true;
}
if (!term.key) {
return getSessionText(session).some((text) => text.includes(value));
}
const key = normalizeQueryText(term.key);
switch (key) {
case "agent":
return session.agentId?.toLowerCase().includes(value) ?? false;
case "channel":
return session.channel?.toLowerCase().includes(value) ?? false;
case "chat":
return session.chatType?.toLowerCase().includes(value) ?? false;
case "provider":
return getSessionProviders(session).some((provider) => provider.includes(value));
case "model":
return getSessionModels(session).some((model) => model.includes(value));
case "tool":
return getSessionTools(session).some((tool) => tool.includes(value));
case "label":
return session.label?.toLowerCase().includes(value) ?? false;
case "key":
case "session":
case "id":
if (value.includes("*") || value.includes("?")) {
const regex = globToRegex(value);
return (
regex.test(session.key) || (session.sessionId ? regex.test(session.sessionId) : false)
);
}
return (
session.key.toLowerCase().includes(value) ||
(session.sessionId?.toLowerCase().includes(value) ?? false)
);
case "has":
switch (value) {
case "tools":
return (session.usage?.toolUsage?.totalCalls ?? 0) > 0;
case "errors":
return (session.usage?.messageCounts?.errors ?? 0) > 0;
case "context":
return Boolean(session.contextWeight);
case "usage":
return Boolean(session.usage);
case "model":
return getSessionModels(session).length > 0;
case "provider":
return getSessionProviders(session).length > 0;
default:
return true;
}
case "mintokens": {
const threshold = parseQueryNumber(value);
if (threshold === null) {
return true;
}
return (session.usage?.totalTokens ?? 0) >= threshold;
}
case "maxtokens": {
const threshold = parseQueryNumber(value);
if (threshold === null) {
return true;
}
return (session.usage?.totalTokens ?? 0) <= threshold;
}
case "mincost": {
const threshold = parseQueryNumber(value);
if (threshold === null) {
return true;
}
return (session.usage?.totalCost ?? 0) >= threshold;
}
case "maxcost": {
const threshold = parseQueryNumber(value);
if (threshold === null) {
return true;
}
return (session.usage?.totalCost ?? 0) <= threshold;
}
case "minmessages": {
const threshold = parseQueryNumber(value);
if (threshold === null) {
return true;
}
return (session.usage?.messageCounts?.total ?? 0) >= threshold;
}
case "maxmessages": {
const threshold = parseQueryNumber(value);
if (threshold === null) {
return true;
}
return (session.usage?.messageCounts?.total ?? 0) <= threshold;
}
default:
return true;
}
};
export const filterSessionsByQuery = <TSession extends UsageSessionQueryTarget>(
sessions: TSession[],
query: string,
): UsageQueryResult<TSession> => {
const terms = extractQueryTerms(query);
if (terms.length === 0) {
return { sessions, warnings: [] };
}
const warnings: string[] = [];
for (const term of terms) {
if (!term.key) {
continue;
}
const normalizedKey = normalizeQueryText(term.key);
if (!QUERY_KEYS.has(normalizedKey)) {
warnings.push(`Unknown filter: ${term.key}`);
continue;
}
if (term.value === "") {
warnings.push(`Missing value for ${term.key}`);
}
if (normalizedKey === "has") {
const allowed = new Set(["tools", "errors", "context", "usage", "model", "provider"]);
if (term.value && !allowed.has(normalizeQueryText(term.value))) {
warnings.push(`Unknown has:${term.value}`);
}
}
if (
["mintokens", "maxtokens", "mincost", "maxcost", "minmessages", "maxmessages"].includes(
normalizedKey,
)
) {
if (term.value && parseQueryNumber(term.value) === null) {
warnings.push(`Invalid number for ${term.key}`);
}
}
}
const filtered = sessions.filter((session) =>
terms.every((term) => matchesUsageQuery(session, term)),
);
return { sessions: filtered, warnings };
};
export function parseToolSummary(content: string) {
const lines = content.split("\n");
const toolCounts = new Map<string, number>();
const nonToolLines: string[] = [];
for (const line of lines) {
const match = /^\[Tool:\s*([^\]]+)\]/.exec(line.trim());
if (match) {
const name = match[1];
toolCounts.set(name, (toolCounts.get(name) ?? 0) + 1);
continue;
}
if (line.trim().startsWith("[Tool Result]")) {
continue;
}
nonToolLines.push(line);
}
const sortedTools = Array.from(toolCounts.entries()).toSorted((a, b) => b[1] - a[1]);
const totalCalls = sortedTools.reduce((sum, [, count]) => sum + count, 0);
const summary =
sortedTools.length > 0
? `Tools: ${sortedTools
.map(([name, count]) => `${name}×${count}`)
.join(", ")} (${totalCalls} calls)`
: "";
return {
tools: sortedTools,
summary,
cleanContent: nonToolLines.join("\n").trim(),
};
}

5432
ui/src/ui/views/usage.ts Normal file

File diff suppressed because it is too large Load Diff

9
ui/vitest.node.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
// Node-only tests for pure logic (no Playwright/browser dependency).
export default defineConfig({
test: {
include: ["src/**/*.node.test.ts"],
environment: "node",
},
});