feat(tooling): enforce noUncheckedIndexedAccess in core and ui lanes (NUIA phase 3c) (#104981)

* fix(ui): make indexed access explicit across the Control UI

Burns down all 322 ui-lane noUncheckedIndexedAccess errors: untrusted
markdown/diff/patch parsing gets miss-tolerant guards (renderer rules
degrade to empty output instead of throwing mid-render), accumulators
use canonical sparse initialization, DOM lookups stay null-guarded, and
length-checked constructions carry named invariants.

* feat(tooling): enforce noUncheckedIndexedAccess in the core and ui lanes

Flips the flag on for tsconfig.core.json and tsconfig.ui.json (covering
src, all packages including the two deferred ones, and ui) and retires
the strict-ratchet lane wholesale: config, script, projects reference,
check/CI wiring, changed-lane routing, and sync test. The assertion-ban
oxlint override stays as an independent surface.
This commit is contained in:
Peter Steinberger
2026-07-12 06:36:36 +01:00
committed by GitHub
parent a40e43e46e
commit 096cb91175
65 changed files with 501 additions and 350 deletions
-6
View File
@@ -1543,12 +1543,6 @@ jobs:
echo "Current CI targets must provide the tsgo:scripts package script." >&2
exit 1
fi
if pnpm run --silent 2>/dev/null | grep -q '^ tsgo:strict-ratchet$'; then
pnpm tsgo:strict-ratchet
elif [[ "$HISTORICAL_TARGET" != "true" ]]; then
echo "Current CI targets must provide the tsgo:strict-ratchet package script." >&2
exit 1
fi
if pnpm run --silent 2>/dev/null | grep -q '^ tsgo:test:root$'; then
pnpm tsgo:test:root
elif [[ "$HISTORICAL_TARGET" != "true" ]]; then
-1
View File
@@ -1974,7 +1974,6 @@
"tsgo:prod": "pnpm tsgo:core && pnpm tsgo:ui && pnpm tsgo:extensions",
"tsgo:profile": "node scripts/profile-tsgo.mjs",
"tsgo:scripts": "node scripts/run-tsgo.mjs -p tsconfig.scripts.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/scripts.tsbuildinfo",
"tsgo:strict-ratchet": "node scripts/run-tsgo.mjs -p tsconfig.strict-ratchet.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/strict-ratchet.tsbuildinfo",
"tsgo:test": "pnpm tsgo:core:test && pnpm tsgo:extensions:test && pnpm tsgo:test:root",
"tsgo:test:extensions": "pnpm tsgo:extensions:test",
"tsgo:test:packages": "node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.test.packages.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/test-packages.tsbuildinfo",
-2
View File
@@ -5,7 +5,6 @@ export type ChangedLane =
| "extensions"
| "extensionTests"
| "scripts"
| "strictRatchet"
| "testRoot"
| "apps"
| "docs"
@@ -55,4 +54,3 @@ export function isPackageScriptOnlyChange(before: string, after: string): boolea
export const LIVE_DOCKER_AUTH_SHELL_TARGETS: string[];
export const RELEASE_METADATA_PATHS: Set<string>;
export const STRICT_RATCHET_PACKAGE_DIRS: string[];
+1 -30
View File
@@ -13,26 +13,6 @@ const RAW_SYNC_CHANGED_LANES_ENV = "OPENCLAW_CHANGED_LANES_RAW_SYNC";
const SCRIPTS_TYPECHECK_PATH_RE =
/^(?:scripts\/.*\.(?:[cm]?ts|[cm]?tsx)|tsconfig\.scripts\.json)$/u;
// Keep aligned with tsconfig.strict-ratchet.json includes and its oxlint override.
export const STRICT_RATCHET_PACKAGE_DIRS = [
"packages/markdown-core",
"packages/net-policy",
"packages/media-understanding-common",
"packages/terminal-core",
"packages/normalization-core",
"packages/model-catalog-core",
"packages/web-content-core",
"packages/ai",
"packages/agent-core",
"packages/acp-core",
"packages/gateway-client",
"packages/gateway-protocol",
"packages/llm-core",
"packages/media-core",
"packages/media-generation-core",
"packages/plugin-package-contract",
"packages/sdk",
];
const TEST_ROOT_TYPECHECK_PATH_RE =
/^(?:test\/(?!fixtures\/).*\.(?:[cm]?ts|[cm]?tsx)|test\/tsconfig\/tsconfig\.test\.root\.json)$/u;
export const LIVE_DOCKER_AUTH_SHELL_TARGETS = [
@@ -69,7 +49,7 @@ export const RELEASE_METADATA_PATHS = new Set([
"package.json",
]);
/** @typedef {"core" | "coreTests" | "ui" | "extensions" | "extensionTests" | "scripts" | "strictRatchet" | "testRoot" | "apps" | "docs" | "tooling" | "liveDockerTooling" | "releaseMetadata" | "all"} ChangedLane */
/** @typedef {"core" | "coreTests" | "ui" | "extensions" | "extensionTests" | "scripts" | "testRoot" | "apps" | "docs" | "tooling" | "liveDockerTooling" | "releaseMetadata" | "all"} ChangedLane */
/**
* @typedef {{
@@ -92,7 +72,6 @@ export function createEmptyChangedLanes() {
extensions: false,
extensionTests: false,
scripts: false,
strictRatchet: false,
testRoot: false,
apps: false,
docs: false,
@@ -152,14 +131,6 @@ export function detectChangedLanes(changedPaths, options = {}) {
if (SCRIPTS_TYPECHECK_PATH_RE.test(changedPath)) {
lanes.scripts = true;
}
if (
changedPath === "tsconfig.strict-ratchet.json" ||
STRICT_RATCHET_PACKAGE_DIRS.some(
(packageDir) => changedPath === packageDir || changedPath.startsWith(`${packageDir}/`),
)
) {
lanes.strictRatchet = true;
}
if (TEST_ROOT_TYPECHECK_PATH_RE.test(changedPath)) {
lanes.testRoot = true;
}
-3
View File
@@ -447,9 +447,6 @@ export function createChangedCheckPlan(result, options = {}) {
if (lanes.scripts) {
addTypecheck("typecheck scripts", ["tsgo:scripts"]);
}
if (lanes.strictRatchet) {
addTypecheck("typecheck strict ratchet", ["tsgo:strict-ratchet"]);
}
if (lanes.testRoot) {
addTypecheck("typecheck test root", ["tsgo:test:root"]);
}
-1
View File
@@ -119,7 +119,6 @@ export async function main(argv = process.argv.slice(2)) {
: [
{ name: "typecheck prod", args: ["tsgo:prod"] },
{ name: "typecheck scripts", args: ["tsgo:scripts"] },
{ name: "typecheck strict ratchet", args: ["tsgo:strict-ratchet"] },
{ name: "typecheck test root", args: ["tsgo:test:root"] },
],
},
-3
View File
@@ -713,7 +713,6 @@ describe("scripts/changed-lanes", () => {
expectLanes(result.lanes, {
core: true,
coreTests: true,
strictRatchet: true,
});
expect(plan.commands.map((command) => command.args[0])).toContain(
"check:database-first-legacy-stores",
@@ -1117,7 +1116,6 @@ describe("scripts/changed-lanes", () => {
expectLanes(result.lanes, {
coreTests: true,
strictRatchet: true,
});
expect(createChangedCheckPlan(result).commands.map((command) => command.args[0])).toContain(
"tsgo:core:test",
@@ -2113,7 +2111,6 @@ describe("scripts/changed-lanes", () => {
extensions: false,
extensionTests: false,
scripts: false,
strictRatchet: false,
testRoot: false,
apps: false,
docs: false,
-1
View File
@@ -1878,7 +1878,6 @@ describe("ci workflow guards", () => {
"${{ needs.preflight.outputs.compatibility_target }}",
);
expect(checkShard.run).toContain("pnpm tsgo:scripts");
expect(checkShard.run).toContain("pnpm tsgo:strict-ratchet");
expect(checkShard.run).toContain('elif [[ "$HISTORICAL_TARGET" != "true" ]]');
const uiInstall = workflow.jobs["checks-ui"].steps.find(
+1 -1
View File
@@ -160,7 +160,7 @@ describe("oxlint config", () => {
]);
});
it("keeps lint overrides limited to the strict-ratchet and test-file policies", () => {
it("keeps lint overrides limited to the indexed-access and test-file policies", () => {
const config = readJson(".oxlintrc.json") as OxlintConfig;
expect(config.overrides).toEqual([
-48
View File
@@ -1,48 +0,0 @@
import fs from "node:fs";
import JSON5 from "json5";
import { describe, expect, it } from "vitest";
import { STRICT_RATCHET_PACKAGE_DIRS, detectChangedLanes } from "../../scripts/changed-lanes.mjs";
import { createChangedCheckPlan } from "../../scripts/check-changed.mjs";
const config: unknown = JSON5.parse(fs.readFileSync("tsconfig.strict-ratchet.json", "utf8"));
if (
!config ||
typeof config !== "object" ||
!("include" in config) ||
!Array.isArray(config.include) ||
!config.include.every((entry) => typeof entry === "string")
) {
throw new Error("expected strict-ratchet tsconfig includes to be strings");
}
const includedPackages = config.include
.filter(
(entry) =>
entry.startsWith("packages/") &&
(entry.endsWith("/**/*") || entry.endsWith("/*.ts") || entry.endsWith("/**/*.ts")),
)
.map((include) => ({
include,
packageDir: include.replace(/\/(?:src\/\*\*\/\*|\*\.ts|\*\*\/\*\.ts)$/u, ""),
}));
const includedPackageDirs = includedPackages.map(({ packageDir }) => packageDir);
describe("strict ratchet routing", () => {
it("keeps the changed-lane package list pinned to the tsconfig", () => {
expect(includedPackageDirs).toEqual(STRICT_RATCHET_PACKAGE_DIRS);
});
it.each(includedPackages)(
"routes $packageDir changes through the ratchet lane",
({ include }) => {
const result = detectChangedLanes([include.replace(/(?:\*\*\/\*|\*)/u, "example")]);
const plan = createChangedCheckPlan(result);
expect(result.lanes.strictRatchet).toBe(true);
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:strict-ratchet");
},
);
it("routes the ratchet tsconfig through its lane", () => {
expect(detectChangedLanes(["tsconfig.strict-ratchet.json"]).lanes.strictRatchet).toBe(true);
});
});
+3
View File
@@ -50,6 +50,9 @@ export function parseVitestProcessStats(
}
const [, rawPid, rawCpu, args] = match;
if (!rawPid || !rawCpu || args === undefined) {
continue;
}
const pid = Number.parseInt(rawPid, 10);
if (!Number.isFinite(pid) || pid === selfPid) {
continue;
+2
View File
@@ -3,6 +3,8 @@
"compilerOptions": {
// Node-side production code must not see browser globals; ui/ owns DOM via tsconfig.ui.json.
"lib": ["ES2023"],
// Production indexed reads must account for missing entries.
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"tsBuildInfoFile": ".artifacts/tsgo-cache/core.tsbuildinfo"
-1
View File
@@ -4,7 +4,6 @@
{ "path": "./tsconfig.core.projects.json" },
{ "path": "./tsconfig.extensions.projects.json" },
{ "path": "./tsconfig.scripts.json" },
{ "path": "./tsconfig.strict-ratchet.json" },
{ "path": "./test/tsconfig/tsconfig.test.root.json" }
]
}
-37
View File
@@ -1,37 +0,0 @@
// noUncheckedIndexedAccess ratchet: grow include as directories migrate.
// Keep aligned with the oxlint no-non-null-assertion override and changed-lanes routing.
// Delete this lane when the base tsconfig enables noUncheckedIndexedAccess.
// memory-host-sdk and speech-core are excluded: they import core src/** (directly
// or via openclaw/plugin-sdk path mappings), so the flag
// would apply transitively to all of core; they migrate together with src/.
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"tsBuildInfoFile": ".artifacts/tsgo-cache/strict-ratchet.tsbuildinfo"
},
"include": [
"packages/markdown-core/src/**/*",
"packages/net-policy/src/**/*",
"packages/media-understanding-common/src/**/*",
"packages/terminal-core/src/**/*",
"packages/normalization-core/src/**/*",
"packages/model-catalog-core/src/**/*",
"packages/web-content-core/src/**/*",
"packages/ai/src/**/*",
"packages/agent-core/src/**/*",
"packages/acp-core/src/**/*",
"packages/gateway-client/src/**/*",
"packages/gateway-protocol/src/**/*",
"packages/llm-core/src/**/*",
"packages/media-core/src/**/*",
"packages/media-generation-core/src/**/*",
"packages/plugin-package-contract/src/**/*",
"packages/sdk/src/**/*",
"src/**/*.d.ts",
"packages/**/*.d.ts"
],
"exclude": ["node_modules", "dist", "**/dist/**", "**/*.test.ts", "**/*.test.tsx", "test/**"]
}
+2
View File
@@ -1,6 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
// Control UI indexed reads must account for missing entries.
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"tsBuildInfoFile": ".artifacts/tsgo-cache/ui.tsbuildinfo"
+1 -1
View File
@@ -63,7 +63,7 @@ export function loadLocalAssistantIdentity(opts?: {
avatars[agentId] = legacyAvatar;
persistLocalAssistantAvatarMap(storage, avatars);
}
return { avatar: Object.hasOwn(avatars, agentId) ? avatars[agentId] : null, agentId };
return { avatar: Object.hasOwn(avatars, agentId) ? (avatars[agentId] ?? null) : null, agentId };
} catch {
return { avatar: null };
}
+8 -4
View File
@@ -173,13 +173,17 @@ function requireThemeId(value: string) {
function normalizeThemeIdFromPath(pathname: string): string | null {
const segments = pathname.split("/").filter(Boolean);
const themeId = segments.at(-1);
if (!themeId) {
return null;
}
if (segments.length === 2 && segments[0] === "themes") {
requireThemeId(segments[1]);
return segments[1];
requireThemeId(themeId);
return themeId;
}
if (segments.length === 3 && segments[0] === "r" && segments[1] === "themes") {
requireThemeId(segments[2]);
return segments[2];
requireThemeId(themeId);
return themeId;
}
return null;
}
@@ -190,7 +190,9 @@ export function paintAnnotations(
if (stroke.points.length === 1) {
// A click without movement still deserves a visible dot.
const point = stroke.points[0];
ctx.lineTo(clamp01(point.x) * params.width + 0.1, clamp01(point.y) * params.height);
if (point) {
ctx.lineTo(clamp01(point.x) * params.width + 0.1, clamp01(point.y) * params.height);
}
}
ctx.stroke();
}
+8 -2
View File
@@ -226,6 +226,9 @@ function trapFocus(event: KeyboardEvent, root: HTMLElement) {
const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first || !last) {
return;
}
const focusInside = active ? focusable.includes(active) : false;
if (event.shiftKey && (!focusInside || active === first)) {
@@ -265,8 +268,11 @@ function handleKeydown(e: KeyboardEvent, props: CommandPaletteProps) {
break;
case "Enter":
e.preventDefault();
if (items[props.activeIndex]) {
selectItem(items[props.activeIndex], props);
{
const item = items[props.activeIndex];
if (item) {
selectItem(item, props);
}
}
break;
case "Escape":
+14 -5
View File
@@ -168,14 +168,19 @@ function normalizeSecretInputUnion(
return null;
}
const nonString = remaining.filter((_, index) => index !== stringIndex);
if (nonString.length !== 1 || !isSecretRefUnion(nonString[0])) {
const secretRefSchema = nonString[0];
const stringSchema = remaining[stringIndex];
if (nonString.length !== 1 || !secretRefSchema || !stringSchema) {
return null;
}
if (!isSecretRefUnion(secretRefSchema)) {
return null;
}
return normalizeSchemaNode(
{
...schema,
...remaining[stringIndex],
nullable: nullable || remaining[stringIndex].nullable,
...stringSchema,
nullable: nullable || stringSchema.nullable,
anyOf: undefined,
oneOf: undefined,
allOf: undefined,
@@ -249,11 +254,15 @@ function normalizeUnion(
}
if (remaining.length === 1) {
const remainingSchema = remaining[0];
if (!remainingSchema) {
return null;
}
return normalizeSchemaNode(
{
...schema,
...remaining[0],
nullable: nullable || remaining[0].nullable,
...remainingSchema,
nullable: nullable || remainingSchema.nullable,
anyOf: undefined,
oneOf: undefined,
allOf: undefined,
+2 -1
View File
@@ -472,7 +472,8 @@ export function renderNode(params: {
);
if (nonNull.length === 1) {
return renderNode({ ...params, schema: nonNull[0] });
const selectedSchema = nonNull[0];
return selectedSchema ? renderNode({ ...params, schema: selectedSchema }) : nothing;
}
// Check if it's a set of literal values (enum-like)
+13 -3
View File
@@ -4,6 +4,7 @@
// Drawn in the smooth OpenClaw lobster style (see the dreams scene and
// icons.lobster). Look and personality are seeded per session + page load so
// every new session hatches a slightly different lobster.
import { expectDefined } from "@openclaw/normalization-core";
import { html, LitElement, nothing, svg, type TemplateResult } from "lit";
import { property, state } from "lit/decorators.js";
import { isLobsterDay } from "../../../src/shared/lobster-day.js";
@@ -362,7 +363,10 @@ const RARE_NAMES: Partial<Record<LobsterPetPaletteId, string>> = {
};
export function lobsterPetName(look: LobsterPetLook, seed: number): string {
return RARE_NAMES[look.palette.id] ?? PET_NAMES[(seed >>> 3) % PET_NAMES.length];
return (
RARE_NAMES[look.palette.id] ??
expectDefined(PET_NAMES[(seed >>> 3) % PET_NAMES.length], "lobster pet name catalog entry")
);
}
// Rare-event loads, planned per seed so tests can probe them purely: a molt
@@ -485,7 +489,7 @@ function pickWeighted<T>(rng: () => number, entries: Array<[T, number]>): T {
return value;
}
}
return entries[entries.length - 1][0];
return expectDefined(entries.at(-1), "weighted lobster choice fallback")[0];
}
function randomBetween(rng: () => number, min: number, max: number): number {
@@ -1586,7 +1590,13 @@ export class LobsterPet extends LitElement {
// The shed shell keeps the true pre-molt size; a max-tier pet sheds a
// max-tier shell.
this.shellScale = this.look.scale;
this.look = { ...this.look, scale: tiers[Math.min(index + 1, tiers.length - 1)] };
this.look = {
...this.look,
scale: expectDefined(
tiers[Math.min(index + 1, tiers.length - 1)],
"lobster molt size tier",
),
};
}
this.shellSpotPct = this.spotPct;
this.shellVisible = true;
+64 -29
View File
@@ -505,8 +505,9 @@ export function markdownFileLinkFromEvent(
function splitFileLineSuffix(raw: string): { path: string; line: number | null } {
const match = FILE_LINE_SUFFIX_RE.exec(raw);
return match
? { path: raw.slice(0, match.index), line: Number.parseInt(match[1], 10) }
const line = match?.[1];
return match && line
? { path: raw.slice(0, match.index), line: Number.parseInt(line, 10) }
: { path: raw, line: null };
}
@@ -739,7 +740,10 @@ function getFenceMarker(line: string): { marker: "`" | "~"; length: number } | n
return null;
}
const fence = match[1];
const marker = fence[0] as "`" | "~";
if (!fence) {
return null;
}
const marker = fence.charAt(0) as "`" | "~";
return { marker, length: fence.length };
}
@@ -761,8 +765,12 @@ function isFenceClose(line: string, fence: { marker: "`" | "~"; length: number }
if (!match) {
return false;
}
const marker = match[1][0];
if (marker !== fence.marker || match[1].length < fence.length) {
const markerText = match[1];
if (!markerText) {
return false;
}
const marker = markerText.charAt(0);
if (marker !== fence.marker || markerText.length < fence.length) {
return false;
}
return trimmed.slice(match[0].length).trim() === "";
@@ -1003,7 +1011,7 @@ md.linkify.add("www", {
for (const [close, open] of Object.entries(balancePairs)) {
balance[close] = 0;
for (let i = 0; i < len; i++) {
const c = tail[i];
const c = tail.charAt(i);
if (open === close) {
// Self-matching pair (e.g., "") — toggle between 0 and 1
if (c === open) {
@@ -1011,15 +1019,15 @@ md.linkify.add("www", {
}
} else if (c === open) {
// Distinct open/close (e.g., ())
balance[close]++;
balance[close] = (balance[close] ?? 0) + 1;
} else if (c === close) {
balance[close]--;
balance[close] = (balance[close] ?? 0) - 1;
}
}
}
while (len > 0) {
const ch = tail[len - 1];
const ch = tail.charAt(len - 1);
// GFM trailing punctuation: ?, !, ., ,, :, *, _, ~ stripped unconditionally.
// Semicolon is handled specially below (entity reference rule).
if (/[?!.,:*_~]/.test(ch)) {
@@ -1031,11 +1039,11 @@ md.linkify.add("www", {
if (ch === ";") {
// Backward scan to find & (O(n) total, avoids string allocation)
let j = len - 2;
while (j >= 0 && /[a-zA-Z0-9]/.test(tail[j])) {
while (j >= 0 && /[a-zA-Z0-9]/.test(tail.charAt(j))) {
j--;
}
// j < len - 2 ensures at least one alphanumeric between & and ;
if (j >= 0 && tail[j] === "&" && j < len - 2) {
if (j >= 0 && tail.charAt(j) === "&" && j < len - 2) {
len = j;
continue;
}
@@ -1047,14 +1055,14 @@ md.linkify.add("www", {
if (open !== undefined) {
if (open === ch) {
// Self-matching: strip if odd count (unbalanced)
if (balance[ch] !== 0) {
if ((balance[ch] ?? 0) !== 0) {
balance[ch] = 0;
len--;
continue;
}
} else if (balance[ch] < 0) {
} else if ((balance[ch] ?? 0) < 0) {
// Distinct pair: strip if more closes than opens
balance[ch]++;
balance[ch] = (balance[ch] ?? 0) + 1;
len--;
continue;
}
@@ -1092,6 +1100,9 @@ md.core.ruler.after("linkify", "linkify-cjk-trim", (state) => {
const children = blockToken.children;
for (let i = children.length - 1; i >= 0; i--) {
const token = children[i];
if (!token) {
continue;
}
if (token.type !== "link_open") {
continue;
}
@@ -1111,7 +1122,7 @@ md.core.ruler.after("linkify", "linkify-cjk-trim", (state) => {
// Middle CJK must be preserved (e.g. https://example.com/你/test stays intact);
// only strip a contiguous CJK tail adjacent to non-URL text.
let cjkIdx = displayText.length;
while (cjkIdx > 0 && CJK_RE.test(displayText[cjkIdx - 1])) {
while (cjkIdx > 0 && CJK_RE.test(displayText.charAt(cjkIdx - 1))) {
cjkIdx--;
}
if (cjkIdx <= 0 || cjkIdx === displayText.length) {
@@ -1129,7 +1140,7 @@ md.core.ruler.after("linkify", "linkify-cjk-trim", (state) => {
textToken.content = trimmedDisplay;
// Find link_close and insert CJK text after it
for (let j = i + 1; j < children.length; j++) {
if (children[j].type === "link_close") {
if (children[j]?.type === "link_close") {
const tailToken = new state.Token("text", "", 0);
tailToken.content = cjkTail;
children.splice(j + 1, 0, tailToken);
@@ -1163,6 +1174,9 @@ md.core.ruler.after("linkify", "file-links", (state) => {
let linkDepth = 0;
for (let index = 0; index < children.length; index += 1) {
const token = children[index];
if (!token) {
continue;
}
if (token.type === "link_open") {
const href = token.attrGet("href");
if (href) {
@@ -1261,23 +1275,28 @@ md.use(markdownItTaskLists, { enabled: false, label: false });
md.core.ruler.after("github-task-lists", "task-list-allowlist", (state) => {
const tokens = state.tokens;
for (let i = 2; i < tokens.length; i++) {
if (tokens[i].type !== "inline" || !tokens[i].children) {
continue;
}
if (tokens[i - 1].type !== "paragraph_open") {
continue;
}
if (tokens[i - 2].type !== "list_item_open") {
continue;
}
const token = tokens[i];
const paragraph = tokens[i - 1];
const listItem = tokens[i - 2];
if (!token || !paragraph || !listItem) {
continue;
}
if (token.type !== "inline" || !token.children) {
continue;
}
if (paragraph.type !== "paragraph_open") {
continue;
}
if (listItem.type !== "list_item_open") {
continue;
}
const cls = listItem.attrGet("class") ?? "";
if (!cls.includes("task-list-item")) {
continue;
}
// Only trust the checkbox <input> token from the plugin, not other user-supplied HTML.
// The plugin inserts an <input> at the start; user HTML elsewhere must stay escaped.
for (const child of tokens[i].children!) {
for (const child of token.children) {
if (child.type === "html_inline" && /^<input\s/i.test(child.content)) {
child.meta = { taskListPlugin: true };
break; // Only one checkbox per item
@@ -1290,11 +1309,17 @@ md.core.ruler.after("github-task-lists", "task-list-allowlist", (state) => {
// Exception: html_inline tokens marked by a trusted plugin (meta.taskListPlugin)
// are allowed through — they are generated by our own plugin pipeline, not user input,
// and DOMPurify provides the final safety net regardless.
// Renderer rules degrade to empty output on impossible token misses instead of
// throwing mid-render; markdown input is untrusted and the chat view must not crash.
md.renderer.rules.html_block = (tokens, idx) => {
return escapeHtml(tokens[idx].content) + "\n";
const token = tokens[idx];
return token ? escapeHtml(token.content) + "\n" : "";
};
md.renderer.rules.html_inline = (tokens, idx) => {
const token = tokens[idx];
if (!token) {
return "";
}
if (token.meta?.taskListPlugin === true) {
return token.content;
}
@@ -1304,7 +1329,8 @@ md.renderer.rules.html_inline = (tokens, idx) => {
md.renderer.rules.code_inline = (tokens, idx, options, env, self) => {
const rendered = defaultCodeInlineRenderer(tokens, idx, options, env, self);
const renderEnv = env as Partial<MarkdownRenderEnv> | undefined;
const target = renderEnv?.fileLinks === true ? parseFileLinkTarget(tokens[idx].content) : null;
const token = tokens[idx];
const target = token && renderEnv?.fileLinks === true ? parseFileLinkTarget(token.content) : null;
if (!target) {
return rendered;
}
@@ -1316,6 +1342,9 @@ md.renderer.rules.code_inline = (tokens, idx, options, env, self) => {
// Override image to only allow base64 data URIs (#15437)
md.renderer.rules.image = (tokens, idx) => {
const token = tokens[idx];
if (!token) {
return "";
}
const src = token.attrGet("src")?.trim() ?? "";
// Use token.content which preserves raw markdown formatting (e.g. **bold**)
// to match original marked.js behavior.
@@ -1329,6 +1358,9 @@ md.renderer.rules.image = (tokens, idx) => {
// Override fenced code blocks with copy button + JSON collapse
md.renderer.rules.fence = (tokens, idx, _options, env) => {
const token = tokens[idx];
if (!token) {
return "";
}
// token.info contains the full fence info string (e.g., "json title=foo");
// extract only the first whitespace-separated token as the language.
const lang = token.info.trim().split(/\s+/)[0] || "";
@@ -1339,7 +1371,10 @@ md.renderer.rules.fence = (tokens, idx, _options, env) => {
// Override indented code blocks (code_block) with the same treatment as fence
md.renderer.rules.code_block = (tokens, idx, _options, env) => {
const content = tokens[idx].content;
const content = tokens[idx]?.content;
if (content === undefined) {
return "";
}
return renderCodeBlock(content, "", env, {
copyText: codeBlockCopyTextFromMarkdownToken(content),
});
+3
View File
@@ -221,6 +221,9 @@ export class OpenClawModalDialog extends OpenClawLitElement {
const active = this.getActiveElement();
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first || !last) {
return;
}
const focusInside = active ? focusable.includes(active) : false;
if (event.shiftKey && (!focusInside || active === first || active === this.dialogElement)) {
+1 -1
View File
@@ -336,7 +336,7 @@ export function resolveModelLabel(model?: unknown): string {
export function normalizeModelValue(label: string): string {
const match = label.match(/^(.+) \(\+\d+ fallback\)$/);
return match ? match[1] : label;
return match?.[1] ?? label;
}
export function resolveModelPrimary(model?: unknown): string | null {
+2 -2
View File
@@ -38,8 +38,8 @@ export function parseSessionDiffPatch(
for (const raw of rawLines) {
const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
if (hunk) {
const oldStart = Number.parseInt(hunk[1], 10);
const newStart = Number.parseInt(hunk[2], 10);
const oldStart = Number.parseInt(hunk[1] ?? "", 10);
const newStart = Number.parseInt(hunk[2] ?? "", 10);
const gap = oldNext === undefined ? oldStart - 1 : oldStart - oldNext;
if (gap > 0) {
lines.push({ kind: "skip", text: formatGap(gap) });
+37 -14
View File
@@ -60,6 +60,9 @@ export function parseDiffDetailsString(diff: string): DiffLine[] | null {
return null;
}
const [, sign, lineNo, text] = match;
if (!sign || !lineNo) {
return null;
}
lines.push({
kind: sign === "+" ? "add" : sign === "-" ? "del" : "ctx",
lineNo: Number.parseInt(lineNo, 10),
@@ -101,7 +104,8 @@ function compactLineDiff(lines: DiffLine[], inputTruncated: boolean): DiffLine[]
}
const keep = new Uint8Array(lines.length);
for (let index = 0; index < lines.length; index++) {
if (lines[index].kind !== "add" && lines[index].kind !== "del") {
const line = lines[index];
if (!line || (line.kind !== "add" && line.kind !== "del")) {
continue;
}
const start = Math.max(0, index - 3);
@@ -125,7 +129,10 @@ function compactLineDiff(lines: DiffLine[], inputTruncated: boolean): DiffLine[]
clipped = true;
break;
}
preview.push(lines[index]);
const line = lines[index];
if (line) {
preview.push(line);
}
}
if (clipped && preview.at(-1)?.kind !== "skip") {
preview.push({ kind: "skip", text: "" });
@@ -151,35 +158,51 @@ export function computeLineDiff(oldText: string, newText: string): DiffLine[] {
Array.from({ length: m + 1 }, () => 0),
);
for (let i = n - 1; i >= 0; i--) {
const row = lcs[i];
const nextRow = lcs[i + 1];
if (!row || !nextRow) {
continue;
}
for (let j = m - 1; j >= 0; j--) {
lcs[i][j] =
row[j] =
oldLines[i] === newLines[j]
? lcs[i + 1][j + 1] + 1
: Math.max(lcs[i + 1][j], lcs[i][j + 1]);
? (nextRow[j + 1] ?? 0) + 1
: Math.max(nextRow[j] ?? 0, row[j + 1] ?? 0);
}
}
const lines: DiffLine[] = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (oldLines[i] === newLines[j]) {
lines.push({ kind: "ctx", text: oldLines[i] });
const oldLine = oldLines[i];
const newLine = newLines[j];
if (oldLine === undefined || newLine === undefined) {
break;
}
if (oldLine === newLine) {
lines.push({ kind: "ctx", text: oldLine });
i++;
j++;
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
lines.push({ kind: "del", text: oldLines[i] });
} else if ((lcs[i + 1]?.[j] ?? 0) >= (lcs[i]?.[j + 1] ?? 0)) {
lines.push({ kind: "del", text: oldLine });
i++;
} else {
lines.push({ kind: "add", text: newLines[j] });
lines.push({ kind: "add", text: newLine });
j++;
}
}
while (i < n) {
lines.push({ kind: "del", text: oldLines[i] });
const line = oldLines[i];
if (line !== undefined) {
lines.push({ kind: "del", text: line });
}
i++;
}
while (j < m) {
lines.push({ kind: "add", text: newLines[j] });
const line = newLines[j];
if (line !== undefined) {
lines.push({ kind: "add", text: line });
}
j++;
}
return compactLineDiff(lines, inputTruncated);
@@ -189,8 +212,8 @@ export function computeLineDiff(oldText: string, newText: string): DiffLine[] {
export function buildWriteDiffLines(content: string, maxLines = 80): DiffLine[] {
const sourceLines = splitDiffLines(content);
const lines: DiffLine[] = [];
for (let index = 0; index < sourceLines.length && index < maxLines; index++) {
lines.push({ kind: "add", lineNo: index + 1, text: sourceLines[index] });
for (const [index, text] of sourceLines.slice(0, maxLines).entries()) {
lines.push({ kind: "add", lineNo: index + 1, text });
}
if (sourceLines.length > maxLines) {
lines.push({ kind: "skip", text: "" });
+21 -8
View File
@@ -100,9 +100,9 @@ function parseHunkHeader(raw: string): HunkState {
return {};
}
return {
oldLine: Number.parseInt(match[1], 10),
oldLine: Number.parseInt(match[1] ?? "", 10),
oldLeft: match[2] === undefined ? 1 : Number.parseInt(match[2], 10),
newLine: Number.parseInt(match[3], 10),
newLine: Number.parseInt(match[3] ?? "", 10),
newLeft: match[4] === undefined ? 1 : Number.parseInt(match[4], 10),
};
}
@@ -194,7 +194,7 @@ function finish(collector: PatchCollector): PatchViewData | null {
if (clipped && lines.at(-1)?.kind !== "skip") {
lines.push({ kind: "skip", text: "" });
}
const only = collector.sections.length === 1 ? collector.sections[0] : undefined;
const only = collector.sections.length === 1 ? collector.sections.at(0) : undefined;
const move =
only && only.operation === "update" && only.sourcePath !== only.path
? { from: only.sourcePath, to: only.path }
@@ -211,14 +211,19 @@ function parseCodexPatch(text: string): PatchViewData | null {
const structural = mode === "update" ? raw.trimEnd() : raw.trim();
const fileMatch = structural.match(/^\*\*\* (Update|Add|Delete) File: (.+)$/);
if (fileMatch) {
mode = fileMatch[1].toLowerCase() as PatchOperation;
current = startSection(collector, mode, fileMatch[2]);
const operation = fileMatch[1];
const path = fileMatch[2];
if (!operation || !path) {
continue;
}
mode = operation.toLowerCase() as PatchOperation;
current = startSection(collector, mode, path);
hunk = null;
continue;
}
const moveMatch = mode === "update" ? structural.match(/^\*\*\* Move to: (.+)$/) : null;
if (moveMatch && current) {
current.path = moveMatch[1].trim();
current.path = moveMatch[1]?.trim() ?? current.path;
continue;
}
if (
@@ -290,10 +295,18 @@ function parseUnifiedPatch(text: string): PatchViewData | null {
let awaitingGitHeaders = false;
for (let index = 0; index < rawLines.length; index++) {
const raw = rawLines[index];
if (raw === undefined) {
continue;
}
const gitHeader = raw.match(/^diff --git a\/(.+) b\/(.+)$/);
if (gitHeader) {
current = startSection(collector, "update", gitHeader[2]);
current.sourcePath = gitHeader[1];
const sourcePath = gitHeader[1];
const path = gitHeader[2];
if (!sourcePath || !path) {
continue;
}
current = startSection(collector, "update", path);
current.sourcePath = sourcePath;
hunk = null;
awaitingGitHeaders = true;
continue;
+1 -1
View File
@@ -354,7 +354,7 @@ export function unwrapShellWrapperCommand(command: string): string {
const match = command.match(
/^\s*(?:\/(?:usr\/)?bin\/)?(?:ba|z|da)?sh\s+-l?c\s+(['"])([\s\S]+)\1\s*$/,
);
return match ? match[2] : command;
return match?.[2] ?? command;
}
function buildToolCallView(
+8 -1
View File
@@ -146,6 +146,9 @@ function resolvePathContainer(
for (let i = 0; i < path.length - 1; i += 1) {
const key = path[i];
const nextKey = path[i + 1];
if (key === undefined) {
return null;
}
if (typeof key === "number") {
if (!Array.isArray(current)) {
return null;
@@ -173,9 +176,13 @@ function resolvePathContainer(
current = record[key] as Record<string, unknown> | unknown[];
}
const lastKey = path.at(-1);
if (lastKey === undefined) {
return null;
}
return {
current,
lastKey: path[path.length - 1],
lastKey,
};
}
+2 -1
View File
@@ -351,7 +351,8 @@ export function coerceFormValues(value: unknown, schema: JsonSchema): unknown {
);
if (variants.length === 1) {
return coerceFormValues(value, variants[0]);
const variant = variants[0];
return variant ? coerceFormValues(value, variant) : value;
}
if (typeof value === "string") {
for (const variant of variants) {
+1 -2
View File
@@ -1174,8 +1174,7 @@ export function updateCronRunsFilter(
state.cronRunsScope = patch.cronRunsScope ?? state.cronRunsScope;
if (Array.isArray(patch.cronRunsStatuses)) {
state.cronRunsStatuses = patch.cronRunsStatuses;
state.cronRunsStatusFilter =
patch.cronRunsStatuses.length === 1 ? patch.cronRunsStatuses[0] : "all";
state.cronRunsStatusFilter = patch.cronRunsStatuses[0] ?? "all";
}
if (Array.isArray(patch.cronRunsDeliveryStatuses)) {
state.cronRunsDeliveryStatuses = patch.cronRunsDeliveryStatuses;
+3
View File
@@ -279,6 +279,9 @@ export function buildNodesInventory(params: {
for (const [key, bucket] of groupsByKey) {
const sorted = bucket.toSorted(compareEntries);
const primary = sorted[0];
if (!primary) {
continue;
}
groups.push({
key,
name: primary.name,
+9 -2
View File
@@ -129,6 +129,9 @@ function parseSessionKey(key: string): SessionKeyInfo {
if (directMatch) {
const channel = directMatch[1];
const identifier = directMatch[2];
if (!channel || !identifier) {
return { prefix: "", fallbackName: key };
}
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
return { prefix: "", fallbackName: `${channelLabel} · ${shortenPeerId(identifier)}` };
}
@@ -137,6 +140,9 @@ function parseSessionKey(key: string): SessionKeyInfo {
const groupMatch = key.match(/^agent:[^:]+:([^:]+):group:(.+)$/);
if (groupMatch) {
const channel = groupMatch[1];
if (!channel) {
return { prefix: "", fallbackName: key };
}
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
return { prefix: "", fallbackName: `${channelLabel} Group` };
}
@@ -159,8 +165,9 @@ function parseSessionKey(key: string): SessionKeyInfo {
// drop the agent:<id>: routing boilerplate and shorten opaque id runs so the
// slug reads as a name instead of a raw key.
const agentKeyMatch = key.match(/^agent:[^:]+:(?:explicit:)?(.+)$/);
if (agentKeyMatch) {
return { prefix: "", fallbackName: shortenOpaqueIdRuns(agentKeyMatch[1]) };
const agentKeyName = agentKeyMatch?.[1];
if (agentKeyName) {
return { prefix: "", fallbackName: shortenOpaqueIdRuns(agentKeyName) };
}
// Unknown: return key as-is.
+3
View File
@@ -16,6 +16,9 @@ export function reorderSessionCustomGroups(
return ordered;
}
const [moved] = ordered.splice(sourceIndex, 1);
if (!moved) {
return ordered;
}
const targetInsertionIndex = ordered.indexOf(target) + (position === "after" ? 1 : 0);
ordered.splice(targetInsertionIndex, 0, moved);
return ordered;
+7 -2
View File
@@ -7,8 +7,13 @@ type CryptoLike = {
let warnedWeakCrypto = false;
function uuidFromBytes(bytes: Uint8Array): string {
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
const versionByte = bytes[6];
const variantByte = bytes[8];
if (versionByte === undefined || variantByte === undefined) {
throw new Error("UUID byte buffer is shorter than 9 bytes");
}
bytes[6] = (versionByte & 0x0f) | 0x40; // version 4
bytes[8] = (variantByte & 0x3f) | 0x80; // variant 1
let hex = "";
for (const byte of bytes) {
+3 -2
View File
@@ -329,8 +329,9 @@ export function resolveActiveSlug(
return requestedTab.slug;
}
const visible = visibleTabs(workspace);
if (visible.length > 0) {
return visible[0].slug;
const firstVisible = visible[0];
if (firstVisible) {
return firstVisible.slug;
}
return orderedTabs(workspace)[0]?.slug ?? null;
}
+2 -1
View File
@@ -36,7 +36,8 @@ function resolveColumns(widget: WorkspaceWidget, rows: Array<Record<string, unkn
return picked;
}
}
return rows.length > 0 ? Object.keys(rows[0]) : [];
const firstRow = rows[0];
return firstRow ? Object.keys(firstRow) : [];
}
function rowLimit(widget: WorkspaceWidget): number {
+4 -3
View File
@@ -1,4 +1,5 @@
// Control UI view renders dreaming screen content.
import { expectDefined } from "@openclaw/normalization-core";
import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import {
@@ -1009,7 +1010,7 @@ function renderDiaryImportsSection(props: DreamingProps) {
}
const clusterIndex = Math.max(0, Math.min(state.diaryPage, clusters.length - 1));
const cluster = clusters[clusterIndex];
const cluster = expectDefined(clusters[clusterIndex], "selected imported insight cluster");
return html`
<div class="dreams-diary__daychips">
@@ -1201,7 +1202,7 @@ function renderMemoryPalaceSection(props: DreamingProps) {
}
const clusterIndex = Math.max(0, Math.min(state.diaryPage, clusters.length - 1));
const cluster = clusters[clusterIndex];
const cluster = expectDefined(clusters[clusterIndex], "selected memory palace cluster");
const totalPages = palace?.totalPages ?? palace?.totalItems ?? 0;
const totalClaims = palace?.totalClaims ?? 0;
const totalQuestions = palace?.totalQuestions ?? 0;
@@ -1387,7 +1388,7 @@ function renderDreamDiaryEntries(props: DreamingProps) {
const reversed = buildDiaryNavigation(entries);
const page = Math.max(0, Math.min(state.diaryPage, reversed.length - 1));
const entry = reversed[page];
const entry = expectDefined(reversed[page], "selected dreaming diary entry");
return html`
<div class="dreams-diary__daychips">
+27 -6
View File
@@ -1,4 +1,5 @@
import { consume } from "@lit/context";
import { expectDefined } from "@openclaw/normalization-core";
import { html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
@@ -33,6 +34,16 @@ import {
type ChatSplitPane,
} from "./split-layout.ts";
function splitWeight(weights: number[], index: number, context: string): number {
return expectDefined(weights[index], context);
}
function splitRatio(weights: number[], index: number, context: string): number {
const before = splitWeight(weights, index, `${context} before divider`);
const after = splitWeight(weights, index + 1, `${context} after divider`);
return before / (before + after);
}
type ChatRouteData = {
sessionKey: string;
draft?: string;
@@ -453,7 +464,11 @@ export class ChatPage extends OpenClawLightDomElement {
(column, columnIndex) => html`
<div
class="chat-split-view__column"
style="flex: ${layout.columnWeights[columnIndex]} 1 0"
style="flex: ${splitWeight(
layout.columnWeights,
columnIndex,
"rendered split column weight",
)} 1 0"
>
${repeat(
column.panes,
@@ -462,14 +477,17 @@ export class ChatPage extends OpenClawLightDomElement {
${this.renderPaneCell(
pane,
pane.id === layout.activePaneId,
column.paneWeights[paneIndex],
splitWeight(column.paneWeights, paneIndex, "rendered split pane weight"),
)}
${paneIndex < column.panes.length - 1
? html`
<resizable-divider
orientation="horizontal"
.splitRatio=${column.paneWeights[paneIndex] /
(column.paneWeights[paneIndex] + column.paneWeights[paneIndex + 1])}
.splitRatio=${splitRatio(
column.paneWeights,
paneIndex,
"split pane weight",
)}
.minRatio=${0.15}
.maxRatio=${0.85}
.label=${t("nav.resize")}
@@ -490,8 +508,11 @@ export class ChatPage extends OpenClawLightDomElement {
${columnIndex < layout.columns.length - 1
? html`
<resizable-divider
.splitRatio=${layout.columnWeights[columnIndex] /
(layout.columnWeights[columnIndex] + layout.columnWeights[columnIndex + 1])}
.splitRatio=${splitRatio(
layout.columnWeights,
columnIndex,
"split column weight",
)}
.minRatio=${0.15}
.maxRatio=${0.85}
.label=${t("nav.resize")}
+10 -6
View File
@@ -210,7 +210,9 @@ function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string }
if (!match) {
return null;
}
return { mimeType: match[1], content: match[2] };
const mimeType = match[1];
const content = match[2];
return mimeType && content ? { mimeType, content } : null;
}
function buildApiAttachments(attachments?: ChatAttachment[]) {
@@ -1277,11 +1279,13 @@ function clearSubmittedComposerState(
} {
const attachmentsUnchanged =
host.chatAttachments.length === submittedAttachments.length &&
host.chatAttachments.every(
(attachment, index) =>
attachmentSubmitSignature(attachment) ===
attachmentSubmitSignature(submittedAttachments[index]),
);
host.chatAttachments.every((attachment, index) => {
const submitted = submittedAttachments[index];
return (
submitted !== undefined &&
attachmentSubmitSignature(attachment) === attachmentSubmitSignature(submitted)
);
});
const clearedDraft = host.chatMessage === submittedDraft && attachmentsUnchanged;
const clearedAttachments = clearedDraft;
if (clearedDraft) {
+13 -7
View File
@@ -550,7 +550,11 @@ function refreshOpenCallIds(
openCallIndexes.delete(callId);
}
}
for (const callId of unresolvedToolCallIds(coalesced[callIndex])) {
const item = coalesced[callIndex];
if (!item) {
return;
}
for (const callId of unresolvedToolCallIds(item)) {
openCallIndexes.set(callId, callIndex);
}
}
@@ -567,8 +571,9 @@ function coalesceToolActivityMessages(items: ChatItem[]): ChatItem[] {
for (const resultItem of resultItems) {
const callId = resolveToolResultCallId(resultItem);
const callIndex = callId ? openCallIndexes.get(callId) : undefined;
const callItem = callIndex === undefined ? undefined : coalesced[callIndex];
const merged =
callIndex === undefined ? null : mergeToolCallResultPair(coalesced[callIndex], resultItem);
callIndex === undefined || !callItem ? null : mergeToolCallResultPair(callItem, resultItem);
if (!merged || callIndex === undefined) {
unmatchedResultItems.push(resultItem);
continue;
@@ -588,10 +593,8 @@ function coalesceToolActivityMessages(items: ChatItem[]): ChatItem[] {
if (unresolvedCallIds.size === 1) {
const callId = unresolvedCallIds.values().next().value;
const previousIndex = callId ? openCallIndexes.get(callId) : undefined;
if (
previousIndex !== undefined &&
unresolvedToolCallIds(coalesced[previousIndex]).size === 1
) {
const previous = previousIndex === undefined ? undefined : coalesced[previousIndex];
if (previousIndex !== undefined && previous && unresolvedToolCallIds(previous).size === 1) {
coalesced[previousIndex] = item;
refreshOpenCallIds(openCallIndexes, coalesced, previousIndex);
continue;
@@ -633,7 +636,7 @@ function annotateToolTurnOutcome(
let sawAssistantReply = false;
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item.kind !== "group") {
if (!item || item.kind !== "group") {
continue;
}
const role = item.role.toLowerCase();
@@ -1240,6 +1243,9 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
for (let i = 0; i < maxLen; i++) {
if (i < indexedSegments.length) {
const segment = indexedSegments[i];
if (!segment) {
continue;
}
const text = sanitizeStreamText(segment.text);
const usesAccumulatedText = streamSegmentUsesAccumulatedText(segment);
const visibleText = usesAccumulatedText
+39 -17
View File
@@ -566,8 +566,12 @@ function updateSlashMenu(
if (!opts.skipSlashIntent) {
requestSlashCommandRefresh(value, props, requestUpdate, getCurrentValue);
}
const cmdName = argMatch[1].toLowerCase();
const argFilter = argMatch[2].toLowerCase();
const cmdName = argMatch[1]?.toLowerCase();
const argFilter = argMatch[2]?.toLowerCase();
if (cmdName === undefined || argFilter === undefined) {
closeSlashMenuIfNeeded(state, requestUpdate);
return;
}
const cmd = SLASH_COMMANDS.find((entry) => entry.name === cmdName);
if (cmd?.argOptions?.length) {
const filtered = argFilter
@@ -593,7 +597,7 @@ function updateSlashMenu(
if (!opts.skipSlashIntent) {
requestSlashCommandRefresh(value, props, requestUpdate, getCurrentValue);
}
const items = getSlashCommandCompletions(match[1], {
const items = getSlashCommandCompletions(match[1] ?? "", {
showAll: state.slashMenuExpanded,
});
state.slashMenuItems = items;
@@ -843,8 +847,7 @@ function renderSlashMenu(
SlashCommandCategory,
Array<{ cmd: SlashCommandDef; globalIdx: number }>
>();
for (let i = 0; i < state.slashMenuItems.length; i++) {
const cmd = state.slashMenuItems[i];
for (const [i, cmd] of state.slashMenuItems.entries()) {
const cat = cmd.category ?? "session";
let list = grouped.get(cat);
if (!list) {
@@ -1087,11 +1090,15 @@ function dataImageClipboardFile(
if (!match) {
return null;
}
const mimeType = match[1].toLowerCase();
const mimeType = match[1]?.toLowerCase();
const base64Source = match[2];
if (!mimeType || !base64Source) {
return null;
}
if (!isSupportedChatAttachmentFile({ name: baseName, type: mimeType })) {
return null;
}
const base64 = match[2].replace(/\s+/g, "");
const base64 = base64Source.replace(/\s+/g, "");
try {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
@@ -1566,7 +1573,7 @@ function formatUsageWindowLabel(label: string): string {
}
const hours = /^(\d+)h$/.exec(label);
if (hours) {
return t("chat.composer.contextUsage.limitHours", { hours: hours[1] });
return t("chat.composer.contextUsage.limitHours", { hours: hours[1] ?? "" });
}
return label;
}
@@ -2179,16 +2186,21 @@ export function renderChatComposer(props: ChatComposerProps) {
return;
case "Tab":
event.preventDefault();
selectSlashArg(
state.slashMenuArgItems[state.slashMenuIndex],
props,
requestUpdate,
false,
);
{
const arg = state.slashMenuArgItems[state.slashMenuIndex];
if (arg !== undefined) {
selectSlashArg(arg, props, requestUpdate, false);
}
}
return;
case "Enter":
event.preventDefault();
selectSlashArg(state.slashMenuArgItems[state.slashMenuIndex], props, requestUpdate, true);
{
const arg = state.slashMenuArgItems[state.slashMenuIndex];
if (arg !== undefined) {
selectSlashArg(arg, props, requestUpdate, true);
}
}
return;
case "Escape":
event.preventDefault();
@@ -2216,11 +2228,21 @@ export function renderChatComposer(props: ChatComposerProps) {
return;
case "Tab":
event.preventDefault();
tabCompleteSlashCommand(state.slashMenuItems[state.slashMenuIndex], props, requestUpdate);
{
const command = state.slashMenuItems[state.slashMenuIndex];
if (command) {
tabCompleteSlashCommand(command, props, requestUpdate);
}
}
return;
case "Enter":
event.preventDefault();
selectSlashCommand(state.slashMenuItems[state.slashMenuIndex], props, requestUpdate);
{
const command = state.slashMenuItems[state.slashMenuIndex];
if (command) {
selectSlashCommand(command, props, requestUpdate);
}
}
return;
case "Escape":
event.preventDefault();
@@ -482,10 +482,10 @@ function tokenizeCommand(command: string): CommandToken[] {
let index = 0;
let expectName = true;
while (index < command.length) {
const char = command[index];
const char = command.charAt(index);
if (/\s/.test(char)) {
let end = index;
while (end < command.length && /\s/.test(command[end])) {
while (end < command.length && /\s/.test(command.charAt(end))) {
end++;
}
tokens.push({ text: command.slice(index, end), cls: "ws" });
@@ -494,8 +494,8 @@ function tokenizeCommand(command: string): CommandToken[] {
}
if (char === "'" || char === '"') {
let end = index + 1;
while (end < command.length && command[end] !== char) {
end += command[end] === "\\" ? 2 : 1;
while (end < command.length && command.charAt(end) !== char) {
end += command.charAt(end) === "\\" ? 2 : 1;
}
end = Math.min(end + 1, command.length);
tokens.push({ text: command.slice(index, end), cls: "str" });
@@ -505,7 +505,7 @@ function tokenizeCommand(command: string): CommandToken[] {
}
if (COMMAND_OP_CHARS.has(char)) {
let end = index;
while (end < command.length && COMMAND_OP_CHARS.has(command[end])) {
while (end < command.length && COMMAND_OP_CHARS.has(command.charAt(end))) {
end++;
}
tokens.push({ text: command.slice(index, end), cls: "op" });
@@ -516,10 +516,10 @@ function tokenizeCommand(command: string): CommandToken[] {
let end = index;
while (
end < command.length &&
!/\s/.test(command[end]) &&
!COMMAND_OP_CHARS.has(command[end]) &&
command[end] !== "'" &&
command[end] !== '"'
!/\s/.test(command.charAt(end)) &&
!COMMAND_OP_CHARS.has(command.charAt(end)) &&
command.charAt(end) !== "'" &&
command.charAt(end) !== '"'
) {
end++;
}
+3 -1
View File
@@ -1,4 +1,5 @@
// Control UI chat module implements chat welcome behavior.
import { expectDefined } from "@openclaw/normalization-core";
import { html, nothing } from "lit";
import type { GatewaySessionRow, SessionsListResult } from "../../../api/types.ts";
import {
@@ -95,7 +96,8 @@ export function selectWelcomeRecentSessions(
// big and borderless with its own gentle idle loop (see layout.css).
function renderWelcomeClawd() {
const palette =
LOBSTER_PET_PALETTES.find((entry) => entry.id === "crimson") ?? LOBSTER_PET_PALETTES[0];
LOBSTER_PET_PALETTES.find((entry) => entry.id === "crimson") ??
expectDefined(LOBSTER_PET_PALETTES[0], "welcome lobster palette");
const look = canonicalLobsterLook(palette);
return html`
<div
@@ -112,6 +112,9 @@ function upsertRealtimeConversationEntry(
return upsertRealtimeConversationEntry(state, role, null, text, isFinal, nowMs);
}
const entry = state.entries[targetIndex];
if (!entry) {
return upsertRealtimeConversationEntry(state, role, null, text, isFinal, nowMs);
}
const updatedText =
role === "assistant"
? mergeAssistantTranscriptText(entry.text, text, isFinal)
+3 -2
View File
@@ -71,8 +71,9 @@ export class WebRtcSdpRealtimeTalkTransport implements RealtimeTalkTransport {
this.audio.style.display = "none";
document.body.append(this.audio);
peer.addEventListener("track", (event) => {
if (this.audio) {
this.audio.srcObject = event.streams[0];
const stream = event.streams[0];
if (this.audio && stream) {
this.audio.srcObject = stream;
}
});
const media = await this.awaitSetupStep(peer, openRealtimeTalkInput(this.ctx.inputDeviceId));
+39 -13
View File
@@ -1,3 +1,5 @@
import { expectDefined } from "@openclaw/normalization-core";
export type ChatSplitPane = { id: string; sessionKey: string };
type ChatSplitColumn = { id: string; panes: ChatSplitPane[]; paneWeights: number[] };
export type ChatSplitEdge = "left" | "right" | "up" | "down";
@@ -67,10 +69,13 @@ export function findPane(
layout: ChatSplitLayout,
paneId: string,
): { column: ChatSplitColumn; columnIndex: number; pane: ChatSplitPane; paneIndex: number } | null {
for (let columnIndex = 0; columnIndex < layout.columns.length; columnIndex += 1) {
const column = layout.columns[columnIndex];
for (const [columnIndex, column] of layout.columns.entries()) {
const paneIndex = column.panes.findIndex((pane) => pane.id === paneId);
if (paneIndex >= 0) {
const selectedPane = column.panes[paneIndex];
if (!selectedPane) {
continue;
}
return {
column: {
...column,
@@ -78,7 +83,7 @@ export function findPane(
paneWeights: [...column.paneWeights],
},
columnIndex,
pane: { ...column.panes[paneIndex] },
pane: { ...selectedPane },
paneIndex,
};
}
@@ -103,7 +108,10 @@ export function insertPane(
}
const newPaneId = nextPaneId(layout);
if (edge === "left" || edge === "right") {
const sourceWeight = next.columnWeights[location.columnIndex];
const sourceWeight = expectDefined(
next.columnWeights[location.columnIndex],
"split column weight for located pane",
);
const insertIndex = location.columnIndex + (edge === "right" ? 1 : 0);
next.columns.splice(insertIndex, 0, {
id: nextColumnId(layout),
@@ -113,7 +121,13 @@ export function insertPane(
next.columnWeights.splice(location.columnIndex, 1, sourceWeight / 2, sourceWeight / 2);
} else {
const column = next.columns[location.columnIndex];
const sourceWeight = column.paneWeights[location.paneIndex];
if (!column) {
return next;
}
const sourceWeight = expectDefined(
column.paneWeights[location.paneIndex],
"split pane weight for located pane",
);
const insertIndex = location.paneIndex + (edge === "down" ? 1 : 0);
column.panes.splice(insertIndex, 0, { id: newPaneId, sessionKey });
column.paneWeights.splice(location.paneIndex, 1, sourceWeight / 2, sourceWeight / 2);
@@ -129,6 +143,9 @@ export function closePane(layout: ChatSplitLayout, paneId: string): ChatSplitLay
}
const next = cloneLayout(layout);
const column = next.columns[location.columnIndex];
if (!column) {
return next;
}
const activeWasClosed = next.activePaneId === paneId;
let nextActivePaneId = next.activePaneId;
if (activeWasClosed) {
@@ -180,7 +197,12 @@ function resizePair(weights: number[], boundaryIndex: number, pairRatio: number)
if (boundaryIndex < 0 || boundaryIndex + 1 >= weights.length) {
return next;
}
const pairSum = weights[boundaryIndex] + weights[boundaryIndex + 1];
const before = weights[boundaryIndex];
const after = weights[boundaryIndex + 1];
if (before === undefined || after === undefined) {
return next;
}
const pairSum = before + after;
const ratio = Math.max(MIN_PAIR_SHARE, Math.min(1 - MIN_PAIR_SHARE, pairRatio));
next[boundaryIndex] = pairSum * ratio;
next[boundaryIndex + 1] = pairSum * (1 - ratio);
@@ -265,15 +287,13 @@ export function normalizeChatSplitLayout(value: unknown): ChatSplitLayout | unde
const usedColumnIds = new Set<string>();
const columns: ChatSplitColumn[] = [];
const sourceColumnIndexes: number[] = [];
for (let columnIndex = 0; columnIndex < rawColumns.length; columnIndex += 1) {
const rawColumn = rawColumns[columnIndex];
for (const [columnIndex, rawColumn] of rawColumns.entries()) {
if (!Array.isArray(rawColumn.panes)) {
continue;
}
const panes: ChatSplitPane[] = [];
const sourcePaneIndexes: number[] = [];
for (let paneIndex = 0; paneIndex < rawColumn.panes.length; paneIndex += 1) {
const rawPane = rawColumn.panes[paneIndex];
for (const [paneIndex, rawPane] of rawColumn.panes.entries()) {
if (!isRecord(rawPane) || typeof rawPane.sessionKey !== "string") {
continue;
}
@@ -291,7 +311,11 @@ export function normalizeChatSplitLayout(value: unknown): ChatSplitLayout | unde
continue;
}
const rawPaneWeights = readWeights(rawColumn.paneWeights, rawColumn.panes.length);
const paneWeights = normalizedWeights(sourcePaneIndexes.map((index) => rawPaneWeights[index]));
const paneWeights = normalizedWeights(
sourcePaneIndexes.map((index) =>
expectDefined(rawPaneWeights[index], "normalized split pane source weight"),
),
);
columns.push({
id: uniqueId(rawColumn.id, usedColumnIds, () => `c${++columnSequence}`),
panes,
@@ -304,7 +328,9 @@ export function normalizeChatSplitLayout(value: unknown): ChatSplitLayout | unde
}
const rawColumnWeights = readWeights(value.columnWeights, rawColumns.length);
const columnWeights = normalizedWeights(
sourceColumnIndexes.map((index) => rawColumnWeights[index]),
sourceColumnIndexes.map((index) =>
expectDefined(rawColumnWeights[index], "normalized split column source weight"),
),
);
const allPanes = columns.flatMap((column) => column.panes);
if (allPanes.length < 2) {
@@ -314,6 +340,6 @@ export function normalizeChatSplitLayout(value: unknown): ChatSplitLayout | unde
typeof value.activePaneId === "string" ? value.activePaneId.trim() : "";
const activePaneId = allPanes.some((pane) => pane.id === requestedActivePaneId)
? requestedActivePaneId
: allPanes[0].id;
: expectDefined(allPanes[0], "normalized split layout first pane").id;
return { columns, columnWeights, activePaneId };
}
+2 -1
View File
@@ -206,7 +206,8 @@ async function flushTitleQueue(): Promise<void> {
const titles = result?.titles ?? {};
let changed = false;
for (const item of batch) {
const title = typeof titles[item.key] === "string" ? titles[item.key].trim() : "";
const rawTitle = titles[item.key];
const title = typeof rawTitle === "string" ? rawTitle.trim() : "";
if (title) {
titlesByKey.set(item.key, title);
changed = true;
+4 -1
View File
@@ -543,7 +543,10 @@ function scopeSchemaSections(
if (exclude && exclude.size > 0 && exclude.has(key)) {
continue;
}
nextProps[key] = schema.properties[key];
const property = schema.properties[key];
if (property) {
nextProps[key] = property;
}
}
return { ...schema, properties: nextProps };
}
+2 -1
View File
@@ -83,7 +83,8 @@ export class PluginPage extends OpenClawLightDomContentsElement {
}
protected loadBundledView(key: string): Promise<BundledPluginTabView> {
return BUNDLED_TAB_VIEWS[key]();
const load = BUNDLED_TAB_VIEWS[key];
return load ? load() : Promise.reject(new Error(`Unknown bundled plugin tab: ${key}`));
}
override willUpdate() {
+9 -3
View File
@@ -1,5 +1,6 @@
// Presentation data for the plugins catalog: bundled cover art, deterministic
// fallback gradients, category shelving, and curated connector suggestions.
import { expectDefined } from "@openclaw/normalization-core";
import { inferControlUiPublicAssetPath } from "../../app/public-assets.ts";
import { t } from "../../i18n/index.ts";
@@ -208,9 +209,12 @@ const FALLBACK_GRADIENTS: ReadonlyArray<readonly [string, string]> = [
export function pluginFallbackGradient(id: string): readonly [string, string] {
let hash = 0;
for (const char of id) {
hash = (hash * 31 + char.codePointAt(0)!) >>> 0;
hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0;
}
return FALLBACK_GRADIENTS[hash % FALLBACK_GRADIENTS.length]!;
return expectDefined(
FALLBACK_GRADIENTS[hash % FALLBACK_GRADIENTS.length],
"plugin fallback gradient palette entry",
);
}
export function pluginMonogram(name: string): string {
@@ -218,7 +222,9 @@ export function pluginMonogram(name: string): string {
if (words.length === 0) {
return "";
}
const initials = words.length === 1 ? words[0].slice(0, 2) : `${words[0][0]}${words[1][0]}`;
const first = expectDefined(words[0], "plugin monogram first word");
const second = words[1];
const initials = second ? `${first.charAt(0)}${second.charAt(0)}` : first.slice(0, 2);
return initials.toLocaleUpperCase();
}
+6 -6
View File
@@ -422,7 +422,7 @@ function pluginMenuItems(
rowKey: string,
options: { details: boolean },
): PluginMenuItem[] {
const blocked = !props.canMutate || props.busy[rowKey];
const blocked = !props.canMutate || (props.busy[rowKey] ?? false);
const items: PluginMenuItem[] = [];
if (options.details) {
items.push({
@@ -620,7 +620,7 @@ function renderInventoryPulse(props: PluginsViewProps) {
function renderInstalledRow(plugin: PluginCatalogItem, props: PluginsViewProps): TemplateResult {
const key = pluginRowKey(plugin.id);
const busy = props.busy[key];
const busy = props.busy[key] ?? false;
return html`
<article
class="plugins-row plugins-row--${plugin.state} plugins-row--clickable"
@@ -855,7 +855,7 @@ function renderInstalled(props: PluginsViewProps) {
function renderCatalogCard(plugin: PluginCatalogItem, props: PluginsViewProps): TemplateResult {
const key = pluginRowKey(plugin.id);
const busy = props.busy[key];
const busy = props.busy[key] ?? false;
return html`
<article
class="plugins-card plugins-card--clickable"
@@ -900,7 +900,7 @@ function renderConnectorCard(
props: PluginsViewProps,
): TemplateResult {
const key = connectorRowKey(connector.id);
const busy = props.busy[key];
const busy = props.busy[key] ?? false;
const isMcp = connector.action.kind === "mcp";
const installed =
isMcp &&
@@ -1006,7 +1006,7 @@ function renderClawHubResult(item: PluginSearchResult, props: PluginsViewProps):
const pkg = item.package;
const installed = findInstalledSearchPlugin(item, props.result?.plugins ?? []);
const key = clawHubRowKey(pkg.name);
const busy = props.busy[key];
const busy = props.busy[key] ?? false;
const artSlug = pkg.runtimeId ?? pkg.name;
return html`
<article
@@ -1192,7 +1192,7 @@ function renderDetailOverlay(props: PluginsViewProps) {
return nothing;
}
const key = pluginRowKey(plugin.id);
const busy = props.busy[key];
const busy = props.busy[key] ?? false;
return html`
<div
class="plugins-detail-backdrop"
+8 -5
View File
@@ -120,13 +120,16 @@ export function computeStreaks(daily: readonly DailyTokensEntry[], today: string
let longest = 1;
let run = 1;
for (let index = 1; index < dates.length; index += 1) {
const gapDays = Math.round(
(dateToUtcNoon(dates[index]) - dateToUtcNoon(dates[index - 1])) / DAY_MS,
);
const currentDate = dates[index];
const previousDate = dates[index - 1];
if (!currentDate || !previousDate) {
continue;
}
const gapDays = Math.round((dateToUtcNoon(currentDate) - dateToUtcNoon(previousDate)) / DAY_MS);
run = gapDays === 1 ? run + 1 : 1;
longest = Math.max(longest, run);
}
const last = dates[dates.length - 1];
const last = dates.at(-1) ?? today;
const sinceLast = Math.round((dateToUtcNoon(today) - dateToUtcNoon(last)) / DAY_MS);
return { current: sinceLast <= 1 ? run : 0, longest };
}
@@ -134,7 +137,7 @@ export function computeStreaks(daily: readonly DailyTokensEntry[], today: string
function levelThresholds(values: number[]): [number, number, number] {
const sorted = values.toSorted((a, b) => a - b);
const pick = (ratio: number) =>
sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))];
sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))] ?? 0;
return [pick(0.25), pick(0.5), pick(0.75)];
}
@@ -267,7 +267,10 @@ function renderSkillWorkshopPage(
selectedIndex < 0
? 0
: (selectedIndex + delta + visibleProposals.length) % visibleProposals.length;
selectProposal(visibleProposals[nextIndex].key);
const nextProposal = visibleProposals[nextIndex];
if (nextProposal) {
selectProposal(nextProposal.key);
}
};
const selectVisibleFallback = (proposals: typeof visibleProposals) => {
if (
@@ -276,7 +279,10 @@ function renderSkillWorkshopPage(
) {
return;
}
selectProposal(proposals[0].key);
const firstProposal = proposals[0];
if (firstProposal) {
selectProposal(firstProposal.key);
}
};
return renderSkillWorkshop({
loading: state.skillWorkshopLoading,
+15 -10
View File
@@ -359,6 +359,7 @@ function renderDetail(props: SkillWorkshopProps, proposal: SkillWorkshopProposal
? `Edited ${formatRelative(editedAt)}`
: `Created ${formatRelative(proposal.createdAt)}`;
const detailLoading = props.inspectingKey === proposal.key && !proposal.body;
const firstSupportFile = proposal.supportFiles[0];
return html`
<div class="sw-detail">
@@ -371,10 +372,10 @@ function renderDetail(props: SkillWorkshopProps, proposal: SkillWorkshopProposal
<span>·</span>
<span>v${proposal.version}</span>
<span>·</span>
${proposal.supportFiles.length > 0
${firstSupportFile
? html`<button
class="sw-detail__meta-link"
@click=${() => props.onPreviewFile(proposal.key, proposal.supportFiles[0].path)}
@click=${() => props.onPreviewFile(proposal.key, firstSupportFile.path)}
>
${proposal.supportFiles.length} support files
</button>`
@@ -645,6 +646,7 @@ function renderToday(
const busy = props.actionBusy?.key === hero.key ? props.actionBusy.action : null;
const disabled = Boolean(props.actionBusy);
const assistantName = resolveSkillWorkshopAgentName(props, "agent");
const firstSupportFile = hero.supportFiles[0];
return html`
<div class="sw-today">
@@ -690,11 +692,11 @@ function renderToday(
<span class="sw-today__avatar">v${hero.version}</span>
<span>
Drafted by <strong>${assistantName}</strong> · ${ageLabel}.
${hero.supportFiles.length > 0
${firstSupportFile
? html`
<button
class="sw-today__files-link"
@click=${() => props.onPreviewFile(hero.key, hero.supportFiles[0].path)}
@click=${() => props.onPreviewFile(hero.key, firstSupportFile.path)}
>
${hero.supportFiles.length}
${hero.supportFiles.length === 1 ? "support file" : "support files"}
@@ -870,8 +872,9 @@ function splitProposalBodySections(body: string): ProposalBodySection[] {
inCode = !inCode;
}
const heading = !inCode ? /^(#{2,4})\s+(.+?)\s*$/.exec(trimmed) : null;
if (heading) {
current = { title: normalizeSectionTitle(heading[2]), lines: [] };
const headingText = heading?.[2];
if (headingText) {
current = { title: normalizeSectionTitle(headingText), lines: [] };
sections.push(current);
continue;
}
@@ -905,8 +908,9 @@ function extractTopLevelListItems(lines: readonly string[]): string[] {
}
const line = raw.trim();
const m = /^(?:[-*]|\d+\.)\s+(.+)/.exec(line);
if (m) {
out.push(cleanTodayPreviewItem(m[1]));
const item = m?.[1];
if (item) {
out.push(cleanTodayPreviewItem(item));
}
}
return out.filter(Boolean);
@@ -1001,9 +1005,10 @@ function renderProposalBody(body: string) {
continue;
}
const olMatch = /^\d+\.\s+(.+)/.exec(line);
if (olMatch) {
const listItem = olMatch?.[1];
if (listItem) {
flushPara();
list.push(olMatch[1]);
list.push(listItem);
continue;
}
para.push(line);
+5 -3
View File
@@ -564,7 +564,8 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
const busy = props.busyKey === skill.skillKey;
const apiKey = props.edits[skill.skillKey] ?? "";
const message = props.messages[skill.skillKey] ?? null;
const canInstall = skill.install.length > 0 && skill.missing.bins.length > 0;
const installOption = skill.install[0];
const canInstall = installOption !== undefined && skill.missing.bins.length > 0;
const showBundledBadge = Boolean(skill.bundled && skill.source !== "openclaw-bundled");
const missing = computeSkillMissing(skill);
const reasons = computeSkillReasons(skill);
@@ -668,9 +669,10 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
? html`<button
class="btn"
?disabled=${busy}
@click=${() => props.onInstall(skill.skillKey, skill.name, skill.install[0].id)}
@click=${() =>
installOption && props.onInstall(skill.skillKey, skill.name, installOption.id)}
>
${busy ? "Installing\u2026" : skill.install[0].label}
${busy ? "Installing\u2026" : installOption?.label}
</button>`
: nothing}
</div>
+11 -8
View File
@@ -42,11 +42,14 @@ export function toggleUsageRangeSelection<T>(
append: boolean,
): T[] {
if (shiftKey && selected.length > 0) {
const lastIndex = orderedValues.indexOf(selected[selected.length - 1]);
const nextIndex = orderedValues.indexOf(value);
if (lastIndex !== -1 && nextIndex !== -1) {
const [start, end] = lastIndex < nextIndex ? [lastIndex, nextIndex] : [nextIndex, lastIndex];
return [...new Set([...selected, ...orderedValues.slice(start, end + 1)])];
for (const lastSelected of selected.slice(-1)) {
const lastIndex = orderedValues.indexOf(lastSelected);
const nextIndex = orderedValues.indexOf(value);
if (lastIndex !== -1 && nextIndex !== -1) {
const [start, end] =
lastIndex < nextIndex ? [lastIndex, nextIndex] : [nextIndex, lastIndex];
return [...new Set([...selected, ...orderedValues.slice(start, end + 1)])];
}
}
}
if (selected.includes(value)) {
@@ -72,7 +75,7 @@ export function selectUsageSessionKeys(
return rightValue - leftValue;
})
.map((session) => session.key);
const lastIndex = orderedKeys.indexOf(selected[selected.length - 1]);
const lastIndex = orderedKeys.indexOf(selected.at(-1) ?? "");
const nextIndex = orderedKeys.indexOf(key);
if (lastIndex !== -1 && nextIndex !== -1) {
const [start, end] = lastIndex < nextIndex ? [lastIndex, nextIndex] : [nextIndex, lastIndex];
@@ -349,8 +352,8 @@ export function parseToolSummary(content: string) {
const nonToolLines: string[] = [];
for (const line of lines) {
const match = /^\[Tool:\s*([^\]]+)\]/.exec(line.trim());
if (match) {
const name = match[1];
const name = match?.[1];
if (name) {
toolCounts.set(name, (toolCounts.get(name) ?? 0) + 1);
continue;
}
+11 -10
View File
@@ -87,6 +87,7 @@ function buildPeakErrorHours(sessions: UsageSessionEntry[], timeZone: "local" |
if (!usage?.messageCounts || usage.messageCounts.total === 0) {
continue;
}
const messageCounts = usage.messageCounts;
// Prefer precise quarter-hour message counts when available.
// Data is stored as UTC quarter-hour buckets (quarterIndex 0-95) with UTC date keys.
@@ -102,22 +103,22 @@ function buildPeakErrorHours(sessions: UsageSessionEntry[], timeZone: "local" |
if (!mapped) {
continue;
}
hourErrors[mapped.hour] += quarterHour.errors;
hourMsgs[mapped.hour] += quarterHour.total;
hourErrors[mapped.hour] = (hourErrors[mapped.hour] ?? 0) + quarterHour.errors;
hourMsgs[mapped.hour] = (hourMsgs[mapped.hour] ?? 0) + quarterHour.total;
}
continue;
}
// Fallback: time-based proportional allocation (legacy algorithm)
forEachSessionHourSlice(session, timeZone, ({ hour, share }) => {
hourErrors[hour] += usage.messageCounts!.errors * share;
hourMsgs[hour] += usage.messageCounts!.total * share;
hourErrors[hour] = (hourErrors[hour] ?? 0) + (messageCounts.errors ?? 0) * share;
hourMsgs[hour] = (hourMsgs[hour] ?? 0) + messageCounts.total * share;
});
}
return hourMsgs
.map((msgs, hour) => {
const errors = hourErrors[hour];
const errors = hourErrors[hour] ?? 0;
const rate = msgs > 0 ? errors / msgs : 0;
return {
hour,
@@ -286,8 +287,8 @@ function buildUsageMosaicStats(
if (
forEachSessionTokenUsageBucket(session, timeZone, ({ hour, weekday, tokens }) => {
hourTotals[hour] += tokens;
weekdayTotals[weekday] += tokens;
hourTotals[hour] = (hourTotals[hour] ?? 0) + tokens;
weekdayTotals[weekday] = (weekdayTotals[weekday] ?? 0) + tokens;
})
) {
hasData = true;
@@ -296,8 +297,8 @@ function buildUsageMosaicStats(
if (
!forEachSessionHourSlice(session, timeZone, ({ usage: usageLocal, hour, weekday, share }) => {
hourTotals[hour] += usageLocal.totalTokens * share;
weekdayTotals[weekday] += usageLocal.totalTokens * share;
hourTotals[hour] = (hourTotals[hour] ?? 0) + usageLocal.totalTokens * share;
weekdayTotals[weekday] = (weekdayTotals[weekday] ?? 0) + usageLocal.totalTokens * share;
})
) {
continue;
@@ -315,7 +316,7 @@ function buildUsageMosaicStats(
t("usage.mosaic.sat"),
].map((label, index) => ({
label,
tokens: weekdayTotals[index],
tokens: weekdayTotals[index] ?? 0,
}));
return {
+6 -3
View File
@@ -136,9 +136,12 @@ const buildQuerySuggestions = (
return [];
}
const tokens = trimmed.length ? trimmed.split(/\s+/) : [];
const lastToken = tokens.length ? tokens[tokens.length - 1] : "";
const [rawKey, rawValue] = lastToken.includes(":")
? [lastToken.slice(0, lastToken.indexOf(":")), lastToken.slice(lastToken.indexOf(":") + 1)]
const lastQueryWord = tokens.at(-1) ?? "";
const [rawKey, rawValue] = lastQueryWord.includes(":")
? [
lastQueryWord.slice(0, lastQueryWord.indexOf(":")),
lastQueryWord.slice(lastQueryWord.indexOf(":") + 1),
]
: ["", ""];
const key = normalizeLowercaseStringOrEmpty(rawKey);
+4 -2
View File
@@ -452,8 +452,10 @@ class UsagePage extends OpenClawLightDomElement {
if (this.usageSelectedSessions.length === 1) {
const sessionKey = this.usageSelectedSessions[0];
void this.loadSessionTimeSeries(sessionKey);
void this.loadSessionLogs(sessionKey);
if (sessionKey) {
void this.loadSessionTimeSeries(sessionKey);
void this.loadSessionLogs(sessionKey);
}
}
}
+15 -8
View File
@@ -1,3 +1,4 @@
import { expectDefined } from "@openclaw/normalization-core";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
// Control UI view renders usage render details screen content.
import { html, svg, nothing } from "lit";
@@ -201,6 +202,8 @@ function computeFilteredUsage(
userMessages++;
}
}
const first = expectDefined(filtered[0], "filtered usage first point");
const last = expectDefined(filtered.at(-1), "filtered usage last point");
return {
...baseUsage,
@@ -210,9 +213,9 @@ function computeFilteredUsage(
output: totalOutput,
cacheRead: totalCacheRead,
cacheWrite: totalCacheWrite,
durationMs: filtered[filtered.length - 1].timestamp - filtered[0].timestamp,
firstActivity: filtered[0].timestamp,
lastActivity: filtered[filtered.length - 1].timestamp,
durationMs: last.timestamp - first.timestamp,
firstActivity: first.timestamp,
lastActivity: last.timestamp,
messageCounts: {
total: filtered.length,
user: userMessages,
@@ -582,13 +585,13 @@ function renderTimeSeriesCompact(
<!-- X axis labels (first and last) -->
${points.length > 0
? svg`
<text x="${padding.left}" y="${padding.top + chartHeight + 10}" text-anchor="start" class="ts-axis-label">${formatTimeMs(points[0].timestamp, { hour: "2-digit", minute: "2-digit" }, "")}</text>
<text x="${width - padding.right}" y="${padding.top + chartHeight + 10}" text-anchor="end" class="ts-axis-label">${formatTimeMs(points[points.length - 1].timestamp, { hour: "2-digit", minute: "2-digit" }, "")}</text>
<text x="${padding.left}" y="${padding.top + chartHeight + 10}" text-anchor="start" class="ts-axis-label">${formatTimeMs(expectDefined(points[0], "time series first point").timestamp, { hour: "2-digit", minute: "2-digit" }, "")}</text>
<text x="${width - padding.right}" y="${padding.top + chartHeight + 10}" text-anchor="end" class="ts-axis-label">${formatTimeMs(expectDefined(points.at(-1), "time series last point").timestamp, { hour: "2-digit", minute: "2-digit" }, "")}</text>
`
: nothing}
<!-- Bars -->
${points.map((p, i) => {
const val = barTotals[i];
const val = expectDefined(barTotals[i], "time series bar total");
const x = padding.left + i * (barWidth + barGap);
const bh = (val / maxValue) * chartHeight;
const y = padding.top + chartHeight - bh;
@@ -707,11 +710,15 @@ function renderTimeSeriesCompact(
return;
}
if (side === "left") {
const endTs = cursorEnd ?? points[points.length - 1].timestamp;
const endTs =
cursorEnd ??
expectDefined(points.at(-1), "time series right cursor point").timestamp;
// Don't let left go past right
onCursorRangeChange(Math.min(pt.timestamp, endTs), endTs);
} else {
const startTs = cursorStart ?? points[0].timestamp;
const startTs =
cursorStart ??
expectDefined(points[0], "time series left cursor point").timestamp;
// Don't let right go past left
onCursorRangeChange(startTs, Math.max(pt.timestamp, startTs));
}
+6 -4
View File
@@ -1,3 +1,4 @@
import { expectDefined } from "@openclaw/normalization-core";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
// Control UI view renders usage render overview screen content.
import { html, nothing } from "lit";
@@ -94,18 +95,19 @@ function renderFilterChips(
return nothing;
}
const selectedSessionKey = selectedSessions.at(0) ?? "";
const selectedSession =
selectedSessions.length === 1 ? sessions.find((s) => s.key === selectedSessions[0]) : null;
selectedSessions.length === 1 ? sessions.find((s) => s.key === selectedSessionKey) : null;
const sessionsLabel = selectedSession
? truncateUtf16Safe(selectedSession.label || selectedSession.key, 20) +
((selectedSession.label || selectedSession.key).length > 20 ? "…" : "")
: selectedSessions.length === 1
? selectedSessions[0].slice(0, 8) + "…"
? selectedSessionKey.slice(0, 8) + "…"
: t("usage.filters.sessionsCount", { count: String(selectedSessions.length) });
const sessionsFullName = selectedSession
? selectedSession.label || selectedSession.key
: selectedSessions.length === 1
? selectedSessions[0]
? selectedSessionKey
: selectedSessions.join(", ");
const daysLabel =
@@ -335,7 +337,7 @@ function renderDailyChartCompact(
</div>
<div class="daily-chart-bars" style="--bar-max-width: ${barMaxWidth}px">
${daily.map((d, idx) => {
const heightPx = barHeights[idx];
const heightPx = expectDefined(barHeights[idx], "daily usage bar height");
const isSelected = selectedDaySet.has(d.date);
const label = formatDayLabel(d.date);
// Shorter label for many days (just day number)
+8 -2
View File
@@ -282,7 +282,7 @@ function focusWorkboardDialog(root: HTMLElement, initialFocusSelector?: string)
preferred && isFocusableWorkboardElement(preferred)
? preferred
: initialFocusSelector
? getFocusableWorkboardElements(root)[0]
? (getFocusableWorkboardElements(root)[0] ?? root)
: root;
focusElement(target);
});
@@ -323,6 +323,9 @@ function trapWorkboardDialogFocus(event: KeyboardEvent, root: HTMLElement) {
const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first || !last) {
return;
}
const focusInside = active ? root.contains(active) : false;
if (event.shiftKey && (!focusInside || active === first || active === root)) {
@@ -907,7 +910,10 @@ function focusRelativeWorkboardSelectOption(
? (activeIndex + 1) % options.length
: (activeIndex - 1 + options.length) % options.length;
}
focusWorkboardSelectOption(details, options[nextIndex] ?? options[0]);
const option = options[nextIndex] ?? options[0];
if (option) {
focusWorkboardSelectOption(details, option);
}
}
function focusWorkboardSelectTypeaheadOption(details: HTMLDetailsElement, key: string) {
+6
View File
@@ -881,6 +881,9 @@ function installControlUiMockGateway(input: {
throw new Error(`No deferred mock Gateway response for ${method}`);
}
const [response] = deferredResponses.splice(index, 1);
if (!response) {
throw new Error(`Deferred mock Gateway response disappeared for ${method}`);
}
response.socket.deliver({
error: {
code: error?.code ?? "INVALID_REQUEST",
@@ -900,6 +903,9 @@ function installControlUiMockGateway(input: {
throw new Error(`No deferred mock Gateway response for ${method}`);
}
const [response] = deferredResponses.splice(index, 1);
if (!response) {
throw new Error(`Deferred mock Gateway response disappeared for ${method}`);
}
response.socket.deliver({
id: response.id,
ok: true,