refactor(ui): dedupe state, views, and usage helpers

This commit is contained in:
Peter Steinberger
2026-03-02 08:52:19 +00:00
parent 00a2456b72
commit e427826fcf
13 changed files with 350 additions and 692 deletions

View File

@@ -149,24 +149,7 @@ export function applySettingsFromUrl(host: SettingsHost) {
}
export function setTab(host: SettingsHost, next: Tab) {
if (host.tab !== next) {
host.tab = next;
}
if (next === "chat") {
host.chatHasAutoScrolled = false;
}
if (next === "logs") {
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
} else {
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
}
if (next === "debug") {
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
} else {
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
}
void refreshActiveTab(host);
syncUrlWithTab(host, next, false);
applyTabSelection(host, next, { refreshPolicy: "always", syncUrl: true });
}
export function setTheme(host: SettingsHost, next: ThemeMode, context?: ThemeTransitionContext) {
@@ -349,6 +332,14 @@ export function onPopState(host: SettingsHost) {
}
export function setTabFromRoute(host: SettingsHost, next: Tab) {
applyTabSelection(host, next, { refreshPolicy: "connected" });
}
function applyTabSelection(
host: SettingsHost,
next: Tab,
options: { refreshPolicy: "always" | "connected"; syncUrl?: boolean },
) {
if (host.tab !== next) {
host.tab = next;
}
@@ -365,9 +356,14 @@ export function setTabFromRoute(host: SettingsHost, next: Tab) {
} else {
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
}
if (host.connected) {
if (options.refreshPolicy === "always" || host.connected) {
void refreshActiveTab(host);
}
if (options.syncUrl) {
syncUrlWithTab(host, next, false);
}
}
export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) {

View File

@@ -1,10 +1,6 @@
import type { EventLogEntry } from "./app-events.ts";
import type { CompactionStatus, FallbackStatus } from "./app-tool-stream.ts";
import type {
CronFieldErrors,
CronJobsLastStatusFilter,
CronJobsScheduleKindFilter,
} from "./controllers/cron.ts";
import type { CronModelSuggestionsState, CronState } from "./controllers/cron.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";
@@ -21,16 +17,6 @@ import type {
ChannelsStatusSnapshot,
ConfigSnapshot,
ConfigUiHints,
CronJob,
CronJobsEnabledFilter,
CronJobsSortBy,
CronDeliveryStatus,
CronRunScope,
CronSortDir,
CronRunsStatusValue,
CronRunsStatusFilter,
CronRunLogEntry,
CronStatus,
HealthSnapshot,
LogEntry,
LogLevel,
@@ -44,7 +30,7 @@ import type {
ToolsCatalogResult,
StatusSummary,
} from "./types.ts";
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types.ts";
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
import type { SessionLogEntry } from "./views/usage.ts";
@@ -203,130 +189,133 @@ export type AppViewState = {
usageLogFilterTools: string[];
usageLogFilterHasTools: boolean;
usageLogFilterQuery: string;
cronLoading: boolean;
cronJobsLoadingMore: boolean;
cronJobs: CronJob[];
cronJobsTotal: number;
cronJobsHasMore: boolean;
cronJobsNextOffset: number | null;
cronJobsLimit: number;
cronJobsQuery: string;
cronJobsEnabledFilter: CronJobsEnabledFilter;
cronJobsScheduleKindFilter: CronJobsScheduleKindFilter;
cronJobsLastStatusFilter: CronJobsLastStatusFilter;
cronJobsSortBy: CronJobsSortBy;
cronJobsSortDir: CronSortDir;
cronStatus: CronStatus | null;
cronError: string | null;
cronForm: CronFormState;
cronFieldErrors: CronFieldErrors;
cronEditingJobId: string | null;
cronRunsJobId: string | null;
cronRunsLoadingMore: boolean;
cronRuns: CronRunLogEntry[];
cronRunsTotal: number;
cronRunsHasMore: boolean;
cronRunsNextOffset: number | null;
cronRunsLimit: number;
cronRunsScope: CronRunScope;
cronRunsStatuses: CronRunsStatusValue[];
cronRunsDeliveryStatuses: CronDeliveryStatus[];
cronRunsStatusFilter: CronRunsStatusFilter;
cronRunsQuery: string;
cronRunsSortDir: CronSortDir;
cronModelSuggestions: string[];
cronBusy: boolean;
skillsLoading: boolean;
skillsReport: SkillStatusReport | null;
skillsError: string | null;
skillsFilter: string;
skillEdits: Record<string, string>;
skillMessages: Record<string, SkillMessage>;
skillsBusyKey: string | null;
debugLoading: boolean;
debugStatus: StatusSummary | null;
debugHealth: HealthSnapshot | null;
debugModels: unknown[];
debugHeartbeat: unknown;
debugCallMethod: string;
debugCallParams: string;
debugCallResult: string | null;
debugCallError: string | null;
logsLoading: boolean;
logsError: string | null;
logsFile: string | null;
logsEntries: LogEntry[];
logsFilterText: string;
logsLevelFilters: Record<LogLevel, boolean>;
logsAutoFollow: boolean;
logsTruncated: boolean;
logsCursor: number | null;
logsLastFetchAt: number | null;
logsLimit: number;
logsMaxBytes: number;
logsAtBottom: boolean;
updateAvailable: import("./types.js").UpdateAvailable | null;
client: GatewayBrowserClient | null;
refreshSessionsAfterChat: Set<string>;
connect: () => void;
setTab: (tab: Tab) => void;
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
applySettings: (next: UiSettings) => void;
loadOverview: () => Promise<void>;
loadAssistantIdentity: () => Promise<void>;
loadCron: () => Promise<void>;
handleWhatsAppStart: (force: boolean) => Promise<void>;
handleWhatsAppWait: () => Promise<void>;
handleWhatsAppLogout: () => Promise<void>;
handleChannelConfigSave: () => Promise<void>;
handleChannelConfigReload: () => Promise<void>;
handleNostrProfileEdit: (accountId: string, profile: NostrProfile | null) => void;
handleNostrProfileCancel: () => void;
handleNostrProfileFieldChange: (field: keyof NostrProfile, value: string) => void;
handleNostrProfileSave: () => Promise<void>;
handleNostrProfileImport: () => Promise<void>;
handleNostrProfileToggleAdvanced: () => void;
handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise<void>;
handleGatewayUrlConfirm: () => void;
handleGatewayUrlCancel: () => void;
handleConfigLoad: () => Promise<void>;
handleConfigSave: () => Promise<void>;
handleConfigApply: () => Promise<void>;
handleConfigFormUpdate: (path: string, value: unknown) => void;
handleConfigFormModeChange: (mode: "form" | "raw") => void;
handleConfigRawChange: (raw: string) => void;
handleInstallSkill: (key: string) => Promise<void>;
handleUpdateSkill: (key: string) => Promise<void>;
handleToggleSkillEnabled: (key: string, enabled: boolean) => Promise<void>;
handleUpdateSkillEdit: (key: string, value: string) => void;
handleSaveSkillApiKey: (key: string, apiKey: string) => Promise<void>;
handleCronToggle: (jobId: string, enabled: boolean) => Promise<void>;
handleCronRun: (jobId: string) => Promise<void>;
handleCronRemove: (jobId: string) => Promise<void>;
handleCronAdd: () => Promise<void>;
handleCronRunsLoad: (jobId: string) => Promise<void>;
handleCronFormUpdate: (path: string, value: unknown) => void;
handleSessionsLoad: () => Promise<void>;
handleSessionsPatch: (key: string, patch: unknown) => Promise<void>;
handleLoadNodes: () => Promise<void>;
handleLoadPresence: () => Promise<void>;
handleLoadSkills: () => Promise<void>;
handleLoadDebug: () => Promise<void>;
handleLoadLogs: () => Promise<void>;
handleDebugCall: () => Promise<void>;
handleRunUpdate: () => Promise<void>;
setPassword: (next: string) => void;
setSessionKey: (next: string) => void;
setChatMessage: (next: string) => 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;
};
} & Pick<
CronState,
| "cronLoading"
| "cronJobsLoadingMore"
| "cronJobs"
| "cronJobsTotal"
| "cronJobsHasMore"
| "cronJobsNextOffset"
| "cronJobsLimit"
| "cronJobsQuery"
| "cronJobsEnabledFilter"
| "cronJobsScheduleKindFilter"
| "cronJobsLastStatusFilter"
| "cronJobsSortBy"
| "cronJobsSortDir"
| "cronStatus"
| "cronError"
| "cronForm"
| "cronFieldErrors"
| "cronEditingJobId"
| "cronRunsJobId"
| "cronRunsLoadingMore"
| "cronRuns"
| "cronRunsTotal"
| "cronRunsHasMore"
| "cronRunsNextOffset"
| "cronRunsLimit"
| "cronRunsScope"
| "cronRunsStatuses"
| "cronRunsDeliveryStatuses"
| "cronRunsStatusFilter"
| "cronRunsQuery"
| "cronRunsSortDir"
| "cronBusy"
> &
Pick<CronModelSuggestionsState, "cronModelSuggestions"> & {
skillsLoading: boolean;
skillsReport: SkillStatusReport | null;
skillsError: string | null;
skillsFilter: string;
skillEdits: Record<string, string>;
skillMessages: Record<string, SkillMessage>;
skillsBusyKey: string | null;
debugLoading: boolean;
debugStatus: StatusSummary | null;
debugHealth: HealthSnapshot | null;
debugModels: unknown[];
debugHeartbeat: unknown;
debugCallMethod: string;
debugCallParams: string;
debugCallResult: string | null;
debugCallError: string | null;
logsLoading: boolean;
logsError: string | null;
logsFile: string | null;
logsEntries: LogEntry[];
logsFilterText: string;
logsLevelFilters: Record<LogLevel, boolean>;
logsAutoFollow: boolean;
logsTruncated: boolean;
logsCursor: number | null;
logsLastFetchAt: number | null;
logsLimit: number;
logsMaxBytes: number;
logsAtBottom: boolean;
updateAvailable: import("./types.js").UpdateAvailable | null;
client: GatewayBrowserClient | null;
refreshSessionsAfterChat: Set<string>;
connect: () => void;
setTab: (tab: Tab) => void;
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
applySettings: (next: UiSettings) => void;
loadOverview: () => Promise<void>;
loadAssistantIdentity: () => Promise<void>;
loadCron: () => Promise<void>;
handleWhatsAppStart: (force: boolean) => Promise<void>;
handleWhatsAppWait: () => Promise<void>;
handleWhatsAppLogout: () => Promise<void>;
handleChannelConfigSave: () => Promise<void>;
handleChannelConfigReload: () => Promise<void>;
handleNostrProfileEdit: (accountId: string, profile: NostrProfile | null) => void;
handleNostrProfileCancel: () => void;
handleNostrProfileFieldChange: (field: keyof NostrProfile, value: string) => void;
handleNostrProfileSave: () => Promise<void>;
handleNostrProfileImport: () => Promise<void>;
handleNostrProfileToggleAdvanced: () => void;
handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise<void>;
handleGatewayUrlConfirm: () => void;
handleGatewayUrlCancel: () => void;
handleConfigLoad: () => Promise<void>;
handleConfigSave: () => Promise<void>;
handleConfigApply: () => Promise<void>;
handleConfigFormUpdate: (path: string, value: unknown) => void;
handleConfigFormModeChange: (mode: "form" | "raw") => void;
handleConfigRawChange: (raw: string) => void;
handleInstallSkill: (key: string) => Promise<void>;
handleUpdateSkill: (key: string) => Promise<void>;
handleToggleSkillEnabled: (key: string, enabled: boolean) => Promise<void>;
handleUpdateSkillEdit: (key: string, value: string) => void;
handleSaveSkillApiKey: (key: string, apiKey: string) => Promise<void>;
handleCronToggle: (jobId: string, enabled: boolean) => Promise<void>;
handleCronRun: (jobId: string) => Promise<void>;
handleCronRemove: (jobId: string) => Promise<void>;
handleCronAdd: () => Promise<void>;
handleCronRunsLoad: (jobId: string) => Promise<void>;
handleCronFormUpdate: (path: string, value: unknown) => void;
handleSessionsLoad: () => Promise<void>;
handleSessionsPatch: (key: string, patch: unknown) => Promise<void>;
handleLoadNodes: () => Promise<void>;
handleLoadPresence: () => Promise<void>;
handleLoadSkills: () => Promise<void>;
handleLoadDebug: () => Promise<void>;
handleLoadLogs: () => Promise<void>;
handleDebugCall: () => Promise<void>;
handleRunUpdate: () => Promise<void>;
setPassword: (next: string) => void;
setSessionKey: (next: string) => void;
setChatMessage: (next: string) => 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

@@ -5,51 +5,24 @@ import { stripThinkingTags } from "../format.ts";
const textCache = new WeakMap<object, string | null>();
const thinkingCache = new WeakMap<object, string | null>();
function processMessageText(text: string, role: string): string {
const shouldStripInboundMetadata = role.toLowerCase() === "user";
if (role === "assistant") {
return stripThinkingTags(text);
}
return shouldStripInboundMetadata
? stripInboundMetadata(stripEnvelope(text))
: stripEnvelope(text);
}
export function extractText(message: unknown): string | null {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "";
const shouldStripInboundMetadata = role.toLowerCase() === "user";
const content = m.content;
if (typeof content === "string") {
const processed =
role === "assistant"
? stripThinkingTags(content)
: shouldStripInboundMetadata
? stripInboundMetadata(stripEnvelope(content))
: stripEnvelope(content);
return processed;
const raw = extractRawText(message);
if (!raw) {
return null;
}
if (Array.isArray(content)) {
const parts = content
.map((p) => {
const item = p as Record<string, unknown>;
if (item.type === "text" && typeof item.text === "string") {
return item.text;
}
return null;
})
.filter((v): v is string => typeof v === "string");
if (parts.length > 0) {
const joined = parts.join("\n");
const processed =
role === "assistant"
? stripThinkingTags(joined)
: shouldStripInboundMetadata
? stripInboundMetadata(stripEnvelope(joined))
: stripEnvelope(joined);
return processed;
}
}
if (typeof m.text === "string") {
const processed =
role === "assistant"
? stripThinkingTags(m.text)
: shouldStripInboundMetadata
? stripInboundMetadata(stripEnvelope(m.text))
: stripEnvelope(m.text);
return processed;
}
return null;
return processMessageText(raw, role);
}
export function extractTextCached(message: unknown): string | null {

View File

@@ -37,6 +37,15 @@ function createState(): ConfigState {
};
}
function createRequestWithConfigGet() {
return vi.fn().mockImplementation(async (method: string) => {
if (method === "config.get") {
return { config: {}, valid: true, issues: [], raw: "{\n}\n" };
}
return {};
});
}
describe("applyConfigSnapshot", () => {
it("does not clobber form edits while dirty", () => {
const state = createState();
@@ -160,12 +169,7 @@ describe("applyConfig", () => {
});
it("coerces schema-typed values before config.apply in form mode", async () => {
const request = vi.fn().mockImplementation(async (method: string) => {
if (method === "config.get") {
return { config: {}, valid: true, issues: [], raw: "{\n}\n" };
}
return {};
});
const request = createRequestWithConfigGet();
const state = createState();
state.connected = true;
state.client = { request } as unknown as ConfigState["client"];
@@ -209,12 +213,7 @@ describe("applyConfig", () => {
describe("saveConfig", () => {
it("coerces schema-typed values before config.set in form mode", async () => {
const request = vi.fn().mockImplementation(async (method: string) => {
if (method === "config.get") {
return { config: {}, valid: true, issues: [], raw: "{\n}\n" };
}
return {};
});
const request = createRequestWithConfigGet();
const state = createState();
state.connected = true;
state.client = { request } as unknown as ConfigState["client"];
@@ -250,12 +249,7 @@ describe("saveConfig", () => {
});
it("skips coercion when schema is not an object", async () => {
const request = vi.fn().mockImplementation(async (method: string) => {
if (method === "config.get") {
return { config: {}, valid: true, issues: [], raw: "{\n}\n" };
}
return {};
});
const request = createRequestWithConfigGet();
const state = createState();
state.connected = true;
state.client = { request } as unknown as ConfigState["client"];

View File

@@ -89,17 +89,29 @@ function makeConfigWithProvider(): Record<string, unknown> {
};
}
function getFirstXaiModel(payload: Record<string, unknown>): Record<string, unknown> {
const model = payload.models as Record<string, unknown>;
const providers = model.providers as Record<string, unknown>;
const xai = providers.xai as Record<string, unknown>;
const models = xai.models as Array<Record<string, unknown>>;
return models[0] ?? {};
}
function expectNumericModelCore(model: Record<string, unknown>) {
expect(typeof model.maxTokens).toBe("number");
expect(model.maxTokens).toBe(8192);
expect(typeof model.contextWindow).toBe("number");
expect(model.contextWindow).toBe(131072);
}
describe("form-utils preserves numeric types", () => {
it("serializeConfigForm preserves numbers in JSON output", () => {
const form = makeConfigWithProvider();
const raw = serializeConfigForm(form);
const parsed = JSON.parse(raw);
const model = parsed.models.providers.xai.models[0];
const model = parsed.models.providers.xai.models[0] as Record<string, unknown>;
expect(typeof model.maxTokens).toBe("number");
expect(model.maxTokens).toBe(8192);
expect(typeof model.contextWindow).toBe("number");
expect(model.contextWindow).toBe(131072);
expectNumericModelCore(model);
expect(typeof model.cost.input).toBe("number");
expect(model.cost.input).toBe(0.5);
});
@@ -108,16 +120,9 @@ describe("form-utils preserves numeric types", () => {
const form = makeConfigWithProvider();
const cloned = cloneConfigObject(form);
setPathValue(cloned, ["gateway", "auth", "token"], "new-token");
const first = getFirstXaiModel(cloned);
const model = cloned.models as Record<string, unknown>;
const providers = model.providers as Record<string, unknown>;
const xai = providers.xai as Record<string, unknown>;
const models = xai.models as Array<Record<string, unknown>>;
const first = models[0];
expect(typeof first.maxTokens).toBe("number");
expect(first.maxTokens).toBe(8192);
expect(typeof first.contextWindow).toBe("number");
expectNumericModelCore(first);
expect(typeof first.cost).toBe("object");
expect(typeof (first.cost as Record<string, unknown>).input).toBe("number");
});
@@ -145,16 +150,9 @@ describe("coerceFormValues", () => {
};
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
const model = (
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
.xai as Record<string, unknown>
).models as Array<Record<string, unknown>>;
const first = model[0];
const first = getFirstXaiModel(coerced);
expect(typeof first.maxTokens).toBe("number");
expect(first.maxTokens).toBe(8192);
expect(typeof first.contextWindow).toBe("number");
expect(first.contextWindow).toBe(131072);
expectNumericModelCore(first);
expect(typeof first.cost).toBe("object");
const cost = first.cost as Record<string, number>;
expect(typeof cost.input).toBe("number");
@@ -170,12 +168,7 @@ describe("coerceFormValues", () => {
it("preserves already-correct numeric values", () => {
const form = makeConfigWithProvider();
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
const model = (
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
.xai as Record<string, unknown>
).models as Array<Record<string, unknown>>;
const first = model[0];
const first = getFirstXaiModel(coerced);
expect(typeof first.maxTokens).toBe("number");
expect(first.maxTokens).toBe(8192);
});
@@ -199,11 +192,7 @@ describe("coerceFormValues", () => {
};
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
const model = (
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
.xai as Record<string, unknown>
).models as Array<Record<string, unknown>>;
const first = model[0];
const first = getFirstXaiModel(coerced);
expect(first.maxTokens).toBe("not-a-number");
});
@@ -227,11 +216,8 @@ describe("coerceFormValues", () => {
};
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
const model = (
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
.xai as Record<string, unknown>
).models as Array<Record<string, unknown>>;
expect(model[0].reasoning).toBe(true);
const first = getFirstXaiModel(coerced);
expect(first.reasoning).toBe(true);
});
it("handles empty string for number fields as undefined", () => {
@@ -253,11 +239,8 @@ describe("coerceFormValues", () => {
};
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
const model = (
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
.xai as Record<string, unknown>
).models as Array<Record<string, unknown>>;
expect(model[0].maxTokens).toBeUndefined();
const first = getFirstXaiModel(coerced);
expect(first.maxTokens).toBeUndefined();
});
it("passes through null and undefined values untouched", () => {

View File

@@ -1,9 +1,10 @@
import {
clearDeviceAuthTokenFromStore,
type DeviceAuthEntry,
type DeviceAuthStore,
normalizeDeviceAuthRole,
normalizeDeviceAuthScopes,
} from "../../../src/shared/device-auth.js";
loadDeviceAuthTokenFromStore,
storeDeviceAuthTokenInStore,
} from "../../../src/shared/device-auth-store.js";
import type { DeviceAuthStore } from "../../../src/shared/device-auth.js";
const STORAGE_KEY = "openclaw.device.auth.v1";
@@ -41,16 +42,11 @@ export function loadDeviceAuthToken(params: {
deviceId: string;
role: string;
}): DeviceAuthEntry | null {
const store = readStore();
if (!store || store.deviceId !== params.deviceId) {
return null;
}
const role = normalizeDeviceAuthRole(params.role);
const entry = store.tokens[role];
if (!entry || typeof entry.token !== "string") {
return null;
}
return entry;
return loadDeviceAuthTokenFromStore({
adapter: { readStore, writeStore },
deviceId: params.deviceId,
role: params.role,
});
}
export function storeDeviceAuthToken(params: {
@@ -59,37 +55,19 @@ export function storeDeviceAuthToken(params: {
token: string;
scopes?: string[];
}): DeviceAuthEntry {
const role = normalizeDeviceAuthRole(params.role);
const next: DeviceAuthStore = {
version: 1,
return storeDeviceAuthTokenInStore({
adapter: { readStore, writeStore },
deviceId: params.deviceId,
tokens: {},
};
const existing = readStore();
if (existing && existing.deviceId === params.deviceId) {
next.tokens = { ...existing.tokens };
}
const entry: DeviceAuthEntry = {
role: params.role,
token: params.token,
role,
scopes: normalizeDeviceAuthScopes(params.scopes),
updatedAtMs: Date.now(),
};
next.tokens[role] = entry;
writeStore(next);
return entry;
scopes: params.scopes,
});
}
export function clearDeviceAuthToken(params: { deviceId: string; role: string }) {
const store = readStore();
if (!store || store.deviceId !== params.deviceId) {
return;
}
const role = normalizeDeviceAuthRole(params.role);
if (!store.tokens[role]) {
return;
}
const next = { ...store, tokens: { ...store.tokens } };
delete next.tokens[role];
writeStore(next);
clearDeviceAuthTokenFromStore({
adapter: { readStore, writeStore },
deviceId: params.deviceId,
role: params.role,
});
}

View File

@@ -1,14 +1,9 @@
import {
defaultTitle,
formatToolDetailText,
normalizeToolName,
normalizeVerb,
resolveActionSpec,
resolveDetailFromKeys,
resolveExecDetail,
resolveReadDetail,
resolveWebFetchDetail,
resolveWebSearchDetail,
resolveWriteDetail,
resolveActionArg,
resolveToolVerbAndDetail,
type ToolDisplaySpec as ToolDisplaySpecBase,
} from "../../../src/agents/tool-display-common.js";
import type { IconName } from "./icons.ts";
@@ -69,50 +64,17 @@ export function resolveToolDisplay(params: {
const icon = (spec?.icon ?? FALLBACK.icon ?? "puzzle") as IconName;
const title = spec?.title ?? defaultTitle(name);
const label = spec?.label ?? title;
const actionRaw =
params.args && typeof params.args === "object"
? ((params.args as Record<string, unknown>).action as string | undefined)
: undefined;
const action = typeof actionRaw === "string" ? actionRaw.trim() : undefined;
const actionSpec = resolveActionSpec(spec, action);
const fallbackVerb =
key === "web_search"
? "search"
: key === "web_fetch"
? "fetch"
: key.replace(/_/g, " ").replace(/\./g, " ");
const verb = normalizeVerb(actionSpec?.label ?? action ?? fallbackVerb);
let detail: string | undefined;
if (key === "exec") {
detail = resolveExecDetail(params.args);
}
if (!detail && key === "read") {
detail = resolveReadDetail(params.args);
}
if (!detail && (key === "write" || key === "edit" || key === "attach")) {
detail = resolveWriteDetail(key, params.args);
}
if (!detail && key === "web_search") {
detail = resolveWebSearchDetail(params.args);
}
if (!detail && key === "web_fetch") {
detail = resolveWebFetchDetail(params.args);
}
const detailKeys = actionSpec?.detailKeys ?? spec?.detailKeys ?? FALLBACK.detailKeys ?? [];
if (!detail && detailKeys.length > 0) {
detail = resolveDetailFromKeys(params.args, detailKeys, {
mode: "first",
coerce: { includeFalse: true, includeZero: true },
});
}
if (!detail && params.meta) {
detail = params.meta;
}
const action = resolveActionArg(params.args);
let { verb, detail } = resolveToolVerbAndDetail({
toolKey: key,
args: params.args,
meta: params.meta,
action,
spec,
fallbackDetailKeys: FALLBACK.detailKeys,
detailMode: "first",
detailCoerce: { includeFalse: true, includeZero: true },
});
if (detail) {
detail = shortenHomeInString(detail);
@@ -129,18 +91,7 @@ export function resolveToolDisplay(params: {
}
export function formatToolDetail(display: ToolDisplay): string | undefined {
if (!display.detail) {
return undefined;
}
if (display.detail.includes(" · ")) {
const compact = display.detail
.split(" · ")
.map((part) => part.trim())
.filter((part) => part.length > 0)
.join(", ");
return compact ? `with ${compact}` : undefined;
}
return display.detail;
return formatToolDetailText(display.detail, { prefixWithWith: true });
}
export function formatToolSummary(display: ToolDisplay): string {

View File

@@ -1,193 +1,8 @@
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[];
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;
};
import type { SessionsUsageResult as SharedSessionsUsageResult } from "../../../src/shared/usage-types.js";
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 SessionsUsageEntry = SharedSessionsUsageResult["sessions"][number];
export type SessionsUsageTotals = SharedSessionsUsageResult["totals"];
export type SessionsUsageResult = SharedSessionsUsageResult;
export type CostUsageDailyEntry = SessionsUsageTotals & { date: string };

View File

@@ -37,6 +37,17 @@ describe("config view", () => {
onSubsectionChange: vi.fn(),
});
function findActionButtons(container: HTMLElement): {
saveButton?: HTMLButtonElement;
applyButton?: HTMLButtonElement;
} {
const buttons = Array.from(container.querySelectorAll("button"));
return {
saveButton: buttons.find((btn) => btn.textContent?.trim() === "Save"),
applyButton: buttons.find((btn) => btn.textContent?.trim() === "Apply"),
};
}
it("allows save when form is unsafe", () => {
const container = document.createElement("div");
render(
@@ -97,12 +108,7 @@ describe("config view", () => {
container,
);
const saveButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Save",
);
const applyButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Apply",
);
const { saveButton, applyButton } = findActionButtons(container);
expect(saveButton).not.toBeUndefined();
expect(applyButton).not.toBeUndefined();
expect(saveButton?.disabled).toBe(true);
@@ -121,12 +127,7 @@ describe("config view", () => {
container,
);
const saveButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Save",
);
const applyButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Apply",
);
const { saveButton, applyButton } = findActionButtons(container);
expect(saveButton).not.toBeUndefined();
expect(applyButton).not.toBeUndefined();
expect(saveButton?.disabled).toBe(false);

View File

@@ -4,6 +4,11 @@ import type {
ExecApprovalsFile,
} from "../controllers/exec-approvals.ts";
import { clampText, formatRelativeTimestamp } from "../format.ts";
import {
resolveConfigAgents as resolveSharedConfigAgents,
resolveNodeTargets,
type NodeTargetOption,
} from "./nodes-shared.ts";
import type { NodesProps } from "./nodes.ts";
type ExecSecurity = "deny" | "allowlist" | "full";
@@ -22,10 +27,7 @@ type ExecApprovalsAgentOption = {
isDefault?: boolean;
};
type ExecApprovalsTargetNode = {
id: string;
label: string;
};
type ExecApprovalsTargetNode = NodeTargetOption;
type ExecApprovalsState = {
ready: boolean;
@@ -91,23 +93,11 @@ function resolveExecApprovalsDefaults(
}
function resolveConfigAgents(config: Record<string, unknown> | null): ExecApprovalsAgentOption[] {
const agentsNode = (config?.agents ?? {}) as Record<string, unknown>;
const list = Array.isArray(agentsNode.list) ? agentsNode.list : [];
const agents: ExecApprovalsAgentOption[] = [];
list.forEach((entry) => {
if (!entry || typeof entry !== "object") {
return;
}
const record = entry as Record<string, unknown>;
const id = typeof record.id === "string" ? record.id.trim() : "";
if (!id) {
return;
}
const name = typeof record.name === "string" ? record.name.trim() : undefined;
const isDefault = record.default === true;
agents.push({ id, name: name || undefined, isDefault });
});
return agents;
return resolveSharedConfigAgents(config).map((entry) => ({
id: entry.id,
name: entry.name,
isDefault: entry.isDefault,
}));
}
function resolveExecApprovalsAgents(
@@ -623,29 +613,5 @@ function renderAllowlistEntry(
function resolveExecApprovalsNodes(
nodes: Array<Record<string, unknown>>,
): ExecApprovalsTargetNode[] {
const list: ExecApprovalsTargetNode[] = [];
for (const node of nodes) {
const commands = Array.isArray(node.commands) ? node.commands : [];
const supports = commands.some(
(cmd) =>
String(cmd) === "system.execApprovals.get" || String(cmd) === "system.execApprovals.set",
);
if (!supports) {
continue;
}
const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : "";
if (!nodeId) {
continue;
}
const displayName =
typeof node.displayName === "string" && node.displayName.trim()
? node.displayName.trim()
: nodeId;
list.push({
id: nodeId,
label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}`,
});
}
list.sort((a, b) => a.label.localeCompare(b.label));
return list;
return resolveNodeTargets(nodes, ["system.execApprovals.get", "system.execApprovals.set"]);
}

View File

@@ -0,0 +1,67 @@
export type NodeTargetOption = {
id: string;
label: string;
};
export type ConfigAgentOption = {
id: string;
name?: string;
isDefault: boolean;
index: number;
record: Record<string, unknown>;
};
export function resolveConfigAgents(config: Record<string, unknown> | null): ConfigAgentOption[] {
const agentsNode = (config?.agents ?? {}) as Record<string, unknown>;
const list = Array.isArray(agentsNode.list) ? agentsNode.list : [];
const agents: ConfigAgentOption[] = [];
list.forEach((entry, index) => {
if (!entry || typeof entry !== "object") {
return;
}
const record = entry as Record<string, unknown>;
const id = typeof record.id === "string" ? record.id.trim() : "";
if (!id) {
return;
}
const name = typeof record.name === "string" ? record.name.trim() : undefined;
const isDefault = record.default === true;
agents.push({ id, name: name || undefined, isDefault, index, record });
});
return agents;
}
export function resolveNodeTargets(
nodes: Array<Record<string, unknown>>,
requiredCommands: string[],
): NodeTargetOption[] {
const required = new Set(requiredCommands);
const list: NodeTargetOption[] = [];
for (const node of nodes) {
const commands = Array.isArray(node.commands) ? node.commands : [];
const supports = commands.some((cmd) => required.has(String(cmd)));
if (!supports) {
continue;
}
const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : "";
if (!nodeId) {
continue;
}
const displayName =
typeof node.displayName === "string" && node.displayName.trim()
? node.displayName.trim()
: nodeId;
list.push({
id: nodeId,
label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}`,
});
}
list.sort((a, b) => a.label.localeCompare(b.label));
return list;
}

View File

@@ -8,6 +8,7 @@ import type {
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "../controllers/exec-approvals.ts";
import { formatRelativeTimestamp, formatList } from "../format.ts";
import { renderExecApprovals, resolveExecApprovalsState } from "./nodes-exec-approvals.ts";
import { resolveConfigAgents, resolveNodeTargets, type NodeTargetOption } from "./nodes-shared.ts";
export type NodesProps = {
loading: boolean;
nodes: Array<Record<string, unknown>>;
@@ -223,10 +224,7 @@ type BindingAgent = {
binding?: string | null;
};
type BindingNode = {
id: string;
label: string;
};
type BindingNode = NodeTargetOption;
type BindingState = {
ready: boolean;
@@ -408,28 +406,7 @@ function renderAgentBinding(agent: BindingAgent, state: BindingState) {
}
function resolveExecNodes(nodes: Array<Record<string, unknown>>): BindingNode[] {
const list: BindingNode[] = [];
for (const node of nodes) {
const commands = Array.isArray(node.commands) ? node.commands : [];
const supports = commands.some((cmd) => String(cmd) === "system.run");
if (!supports) {
continue;
}
const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : "";
if (!nodeId) {
continue;
}
const displayName =
typeof node.displayName === "string" && node.displayName.trim()
? node.displayName.trim()
: nodeId;
list.push({
id: nodeId,
label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}`,
});
}
list.sort((a, b) => a.label.localeCompare(b.label));
return list;
return resolveNodeTargets(nodes, ["system.run"]);
}
function resolveAgentBindings(config: Record<string, unknown> | null): {
@@ -452,34 +429,22 @@ function resolveAgentBindings(config: Record<string, unknown> | null): {
typeof exec.node === "string" && exec.node.trim() ? exec.node.trim() : null;
const agentsNode = (config.agents ?? {}) as Record<string, unknown>;
const list = Array.isArray(agentsNode.list) ? agentsNode.list : [];
if (list.length === 0) {
if (!Array.isArray(agentsNode.list) || agentsNode.list.length === 0) {
return { defaultBinding, agents: [fallbackAgent] };
}
const agents: BindingAgent[] = [];
list.forEach((entry, index) => {
if (!entry || typeof entry !== "object") {
return;
}
const record = entry as Record<string, unknown>;
const id = typeof record.id === "string" ? record.id.trim() : "";
if (!id) {
return;
}
const name = typeof record.name === "string" ? record.name.trim() : undefined;
const isDefault = record.default === true;
const toolsEntry = (record.tools ?? {}) as Record<string, unknown>;
const agents = resolveConfigAgents(config).map((entry) => {
const toolsEntry = (entry.record.tools ?? {}) as Record<string, unknown>;
const execEntry = (toolsEntry.exec ?? {}) as Record<string, unknown>;
const binding =
typeof execEntry.node === "string" && execEntry.node.trim() ? execEntry.node.trim() : null;
agents.push({
id,
name: name || undefined,
index,
isDefault,
return {
id: entry.id,
name: entry.name,
index: entry.index,
isDefault: entry.isDefault,
binding,
});
};
});
if (agents.length === 0) {

View File

@@ -1,5 +1,9 @@
import { html } from "lit";
import { buildUsageAggregateTail } from "../../../../src/shared/usage-aggregates.js";
import {
buildUsageAggregateTail,
mergeUsageDailyLatency,
mergeUsageLatency,
} from "../../../../src/shared/usage-aggregates.js";
import { UsageSessionEntry, UsageTotals, UsageAggregates } from "./usageTypes.ts";
const CHARS_PER_TOKEN = 4;
@@ -413,16 +417,7 @@ const buildAggregatesFromSessions = (
}
}
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);
}
}
mergeUsageLatency(latencyTotals, usage.latency);
if (session.agentId) {
const totals = agentMap.get(session.agentId) ?? emptyUsageTotals();
@@ -462,22 +457,7 @@ const buildAggregatesFromSessions = (
daily.errors += day.errors;
dailyMap.set(day.date, daily);
}
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);
}
mergeUsageDailyLatency(dailyLatencyMap, usage.dailyLatency);
for (const day of usage.dailyModelUsage ?? []) {
const key = `${day.date}::${day.provider ?? "unknown"}::${day.model ?? "unknown"}`;
const existing = modelDailyMap.get(key) ?? {