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

@@ -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",
},
});