diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4dfb0880233..c3c286f30a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/package.json b/package.json index 5ced137eb3a..a862bcd8875 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/changed-lanes.d.mts b/scripts/changed-lanes.d.mts index 9f6e0ea8cb9..e50a00e0bbe 100644 --- a/scripts/changed-lanes.d.mts +++ b/scripts/changed-lanes.d.mts @@ -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; -export const STRICT_RATCHET_PACKAGE_DIRS: string[]; diff --git a/scripts/changed-lanes.mjs b/scripts/changed-lanes.mjs index da3d196cd0e..e3a43d73cb0 100644 --- a/scripts/changed-lanes.mjs +++ b/scripts/changed-lanes.mjs @@ -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; } diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index f25ff3637ef..a86308357d0 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -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"]); } diff --git a/scripts/check.mjs b/scripts/check.mjs index efc8c893ad3..f5d99bb2364 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -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"] }, ], }, diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index 75eb6a7e420..6e18282502a 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -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, diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index cfacd53d13b..ac7589ae281 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -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( diff --git a/test/scripts/oxlint-config.test.ts b/test/scripts/oxlint-config.test.ts index a25e45e8ee9..16d19918b1b 100644 --- a/test/scripts/oxlint-config.test.ts +++ b/test/scripts/oxlint-config.test.ts @@ -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([ diff --git a/test/scripts/strict-ratchet-sync.test.ts b/test/scripts/strict-ratchet-sync.test.ts deleted file mode 100644 index fdf8a0b62f1..00000000000 --- a/test/scripts/strict-ratchet-sync.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/test/vitest/vitest.system-load.ts b/test/vitest/vitest.system-load.ts index 41b711b43ad..7a8466fabb2 100644 --- a/test/vitest/vitest.system-load.ts +++ b/test/vitest/vitest.system-load.ts @@ -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; diff --git a/tsconfig.core.json b/tsconfig.core.json index 57283740a1f..8e6b0b4eaa8 100644 --- a/tsconfig.core.json +++ b/tsconfig.core.json @@ -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" diff --git a/tsconfig.projects.json b/tsconfig.projects.json index 4081b27f6d8..199fe46da30 100644 --- a/tsconfig.projects.json +++ b/tsconfig.projects.json @@ -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" } ] } diff --git a/tsconfig.strict-ratchet.json b/tsconfig.strict-ratchet.json deleted file mode 100644 index 324c6974b88..00000000000 --- a/tsconfig.strict-ratchet.json +++ /dev/null @@ -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/**"] -} diff --git a/tsconfig.ui.json b/tsconfig.ui.json index b767d44eb57..02539fb3833 100644 --- a/tsconfig.ui.json +++ b/tsconfig.ui.json @@ -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" diff --git a/ui/src/app/assistant-identity.ts b/ui/src/app/assistant-identity.ts index 87c0f6688b2..db3a71ac030 100644 --- a/ui/src/app/assistant-identity.ts +++ b/ui/src/app/assistant-identity.ts @@ -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 }; } diff --git a/ui/src/app/custom-theme.ts b/ui/src/app/custom-theme.ts index 56c9a07e44e..bba6628c70f 100644 --- a/ui/src/app/custom-theme.ts +++ b/ui/src/app/custom-theme.ts @@ -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; } diff --git a/ui/src/components/browser/browser-annotation.ts b/ui/src/components/browser/browser-annotation.ts index 333e53931a4..0201d6c112e 100644 --- a/ui/src/components/browser/browser-annotation.ts +++ b/ui/src/components/browser/browser-annotation.ts @@ -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(); } diff --git a/ui/src/components/command-palette.ts b/ui/src/components/command-palette.ts index e5d40aa30dc..c473ee7b6d2 100644 --- a/ui/src/components/command-palette.ts +++ b/ui/src/components/command-palette.ts @@ -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": diff --git a/ui/src/components/config-form.analyze.ts b/ui/src/components/config-form.analyze.ts index 114a8add400..124d8e5e6f9 100644 --- a/ui/src/components/config-form.analyze.ts +++ b/ui/src/components/config-form.analyze.ts @@ -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, diff --git a/ui/src/components/config-form.node.ts b/ui/src/components/config-form.node.ts index 0d92c3c4f2e..dfed8d97d2b 100644 --- a/ui/src/components/config-form.node.ts +++ b/ui/src/components/config-form.node.ts @@ -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) diff --git a/ui/src/components/lobster-pet.ts b/ui/src/components/lobster-pet.ts index ef6865efe94..517ed407fa5 100644 --- a/ui/src/components/lobster-pet.ts +++ b/ui/src/components/lobster-pet.ts @@ -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> = { }; 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(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; diff --git a/ui/src/components/markdown.ts b/ui/src/components/markdown.ts index 715b76372a5..cffa80a46f9 100644 --- a/ui/src/components/markdown.ts +++ b/ui/src/components/markdown.ts @@ -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 token from the plugin, not other user-supplied HTML. // The plugin inserts an 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" && /^ { // 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 | 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), }); diff --git a/ui/src/components/modal-dialog.ts b/ui/src/components/modal-dialog.ts index ab9ee631bd7..2c3d0cc8a8b 100644 --- a/ui/src/components/modal-dialog.ts +++ b/ui/src/components/modal-dialog.ts @@ -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)) { diff --git a/ui/src/lib/agents/display.ts b/ui/src/lib/agents/display.ts index 38fb4cbb536..c5fb418f420 100644 --- a/ui/src/lib/agents/display.ts +++ b/ui/src/lib/agents/display.ts @@ -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 { diff --git a/ui/src/lib/chat/session-diff.ts b/ui/src/lib/chat/session-diff.ts index 6d02917d65c..4d1952616a4 100644 --- a/ui/src/lib/chat/session-diff.ts +++ b/ui/src/lib/chat/session-diff.ts @@ -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) }); diff --git a/ui/src/lib/chat/tool-call-diff.ts b/ui/src/lib/chat/tool-call-diff.ts index 353d83fd226..1e1810bda11 100644 --- a/ui/src/lib/chat/tool-call-diff.ts +++ b/ui/src/lib/chat/tool-call-diff.ts @@ -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: "" }); diff --git a/ui/src/lib/chat/tool-call-patch.ts b/ui/src/lib/chat/tool-call-patch.ts index c5452d2f3a7..48bfbc799a7 100644 --- a/ui/src/lib/chat/tool-call-patch.ts +++ b/ui/src/lib/chat/tool-call-patch.ts @@ -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; diff --git a/ui/src/lib/chat/tool-call-view.ts b/ui/src/lib/chat/tool-call-view.ts index 57f37b1a016..9a47dc012af 100644 --- a/ui/src/lib/chat/tool-call-view.ts +++ b/ui/src/lib/chat/tool-call-view.ts @@ -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( diff --git a/ui/src/lib/config-form-utils.ts b/ui/src/lib/config-form-utils.ts index d178c71d802..51976b702ae 100644 --- a/ui/src/lib/config-form-utils.ts +++ b/ui/src/lib/config-form-utils.ts @@ -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 | unknown[]; } + const lastKey = path.at(-1); + if (lastKey === undefined) { + return null; + } return { current, - lastKey: path[path.length - 1], + lastKey, }; } diff --git a/ui/src/lib/config/index.ts b/ui/src/lib/config/index.ts index 607fce7ca9a..979fa4b9107 100644 --- a/ui/src/lib/config/index.ts +++ b/ui/src/lib/config/index.ts @@ -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) { diff --git a/ui/src/lib/cron/index.ts b/ui/src/lib/cron/index.ts index fd2d44feee8..151a47ce5fd 100644 --- a/ui/src/lib/cron/index.ts +++ b/ui/src/lib/cron/index.ts @@ -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; diff --git a/ui/src/lib/nodes/inventory.ts b/ui/src/lib/nodes/inventory.ts index 8c93fe114a1..f4e833b40ed 100644 --- a/ui/src/lib/nodes/inventory.ts +++ b/ui/src/lib/nodes/inventory.ts @@ -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, diff --git a/ui/src/lib/session-display.ts b/ui/src/lib/session-display.ts index c64556abd31..5d8c8d4925b 100644 --- a/ui/src/lib/session-display.ts +++ b/ui/src/lib/session-display.ts @@ -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:: 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. diff --git a/ui/src/lib/sessions/custom-groups.ts b/ui/src/lib/sessions/custom-groups.ts index ac61fc73f85..596e6d7d470 100644 --- a/ui/src/lib/sessions/custom-groups.ts +++ b/ui/src/lib/sessions/custom-groups.ts @@ -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; diff --git a/ui/src/lib/uuid.ts b/ui/src/lib/uuid.ts index 4534aef9a7a..5ec2f36589b 100644 --- a/ui/src/lib/uuid.ts +++ b/ui/src/lib/uuid.ts @@ -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) { diff --git a/ui/src/lib/workspace/index.ts b/ui/src/lib/workspace/index.ts index 113d27a949a..cd39a2d43b7 100644 --- a/ui/src/lib/workspace/index.ts +++ b/ui/src/lib/workspace/index.ts @@ -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; } diff --git a/ui/src/lib/workspace/widgets/table.ts b/ui/src/lib/workspace/widgets/table.ts index cca755c4fcc..75cd2a36bdd 100644 --- a/ui/src/lib/workspace/widgets/table.ts +++ b/ui/src/lib/workspace/widgets/table.ts @@ -36,7 +36,8 @@ function resolveColumns(widget: WorkspaceWidget, rows: Array 0 ? Object.keys(rows[0]) : []; + const firstRow = rows[0]; + return firstRow ? Object.keys(firstRow) : []; } function rowLimit(widget: WorkspaceWidget): number { diff --git a/ui/src/pages/agents/memory/view.ts b/ui/src/pages/agents/memory/view.ts index 2550d145c72..5af4eb80ded 100644 --- a/ui/src/pages/agents/memory/view.ts +++ b/ui/src/pages/agents/memory/view.ts @@ -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`
@@ -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`
diff --git a/ui/src/pages/chat/chat-page.ts b/ui/src/pages/chat/chat-page.ts index 1d29952c6ef..8f513e025b9 100644 --- a/ui/src/pages/chat/chat-page.ts +++ b/ui/src/pages/chat/chat-page.ts @@ -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`
${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` - 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) { diff --git a/ui/src/pages/chat/chat-thread.ts b/ui/src/pages/chat/chat-thread.ts index 0a4e9859bd2..bdf92396833 100644 --- a/ui/src/pages/chat/chat-thread.ts +++ b/ui/src/pages/chat/chat-thread.ts @@ -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 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(); diff --git a/ui/src/pages/chat/components/chat-tool-cards.ts b/ui/src/pages/chat/components/chat-tool-cards.ts index 9327c126aa6..e08280af3a4 100644 --- a/ui/src/pages/chat/components/chat-tool-cards.ts +++ b/ui/src/pages/chat/components/chat-tool-cards.ts @@ -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++; } diff --git a/ui/src/pages/chat/components/chat-welcome.ts b/ui/src/pages/chat/components/chat-welcome.ts index d25b4ee1822..4bd572f6407 100644 --- a/ui/src/pages/chat/components/chat-welcome.ts +++ b/ui/src/pages/chat/components/chat-welcome.ts @@ -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`
{ - 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)); diff --git a/ui/src/pages/chat/split-layout.ts b/ui/src/pages/chat/split-layout.ts index bfbe60c3662..bfa18755844 100644 --- a/ui/src/pages/chat/split-layout.ts +++ b/ui/src/pages/chat/split-layout.ts @@ -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(); 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 }; } diff --git a/ui/src/pages/chat/tool-titles.ts b/ui/src/pages/chat/tool-titles.ts index 16639f9204a..867656ef381 100644 --- a/ui/src/pages/chat/tool-titles.ts +++ b/ui/src/pages/chat/tool-titles.ts @@ -206,7 +206,8 @@ async function flushTitleQueue(): Promise { 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; diff --git a/ui/src/pages/config/view.ts b/ui/src/pages/config/view.ts index 0559cd045e9..37c0699fd34 100644 --- a/ui/src/pages/config/view.ts +++ b/ui/src/pages/config/view.ts @@ -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 }; } diff --git a/ui/src/pages/plugin/plugin-page.ts b/ui/src/pages/plugin/plugin-page.ts index 7919001bc7c..814f0f240a2 100644 --- a/ui/src/pages/plugin/plugin-page.ts +++ b/ui/src/pages/plugin/plugin-page.ts @@ -83,7 +83,8 @@ export class PluginPage extends OpenClawLightDomContentsElement { } protected loadBundledView(key: string): Promise { - 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() { diff --git a/ui/src/pages/plugins/presentation.ts b/ui/src/pages/plugins/presentation.ts index 60ee03bc624..f3f7707557f 100644 --- a/ui/src/pages/plugins/presentation.ts +++ b/ui/src/pages/plugins/presentation.ts @@ -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 = [ 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(); } diff --git a/ui/src/pages/plugins/view.ts b/ui/src/pages/plugins/view.ts index 3ba91a019b1..b5a2b33b520 100644 --- a/ui/src/pages/plugins/view.ts +++ b/ui/src/pages/plugins/view.ts @@ -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`
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)]; } diff --git a/ui/src/pages/skill-workshop/skill-workshop-page.ts b/ui/src/pages/skill-workshop/skill-workshop-page.ts index 6b6f2a48323..4e1c45f9507 100644 --- a/ui/src/pages/skill-workshop/skill-workshop-page.ts +++ b/ui/src/pages/skill-workshop/skill-workshop-page.ts @@ -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, diff --git a/ui/src/pages/skill-workshop/view.ts b/ui/src/pages/skill-workshop/view.ts index 4c426685fd5..5709ee1d91c 100644 --- a/ui/src/pages/skill-workshop/view.ts +++ b/ui/src/pages/skill-workshop/view.ts @@ -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`
@@ -371,10 +372,10 @@ function renderDetail(props: SkillWorkshopProps, proposal: SkillWorkshopProposal · v${proposal.version} · - ${proposal.supportFiles.length > 0 + ${firstSupportFile ? html`` @@ -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`
@@ -690,11 +692,11 @@ function renderToday( v${hero.version} Drafted by ${assistantName} · ${ageLabel}. - ${hero.supportFiles.length > 0 + ${firstSupportFile ? html` ` : nothing}
diff --git a/ui/src/pages/usage/helpers.ts b/ui/src/pages/usage/helpers.ts index 6603067f221..7aefb4ba0ad 100644 --- a/ui/src/pages/usage/helpers.ts +++ b/ui/src/pages/usage/helpers.ts @@ -42,11 +42,14 @@ export function toggleUsageRangeSelection( 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; } diff --git a/ui/src/pages/usage/metrics.ts b/ui/src/pages/usage/metrics.ts index 8d9d359b126..e48c999fb1e 100644 --- a/ui/src/pages/usage/metrics.ts +++ b/ui/src/pages/usage/metrics.ts @@ -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 { diff --git a/ui/src/pages/usage/query.ts b/ui/src/pages/usage/query.ts index 2bd4d144b09..681b5d3edda 100644 --- a/ui/src/pages/usage/query.ts +++ b/ui/src/pages/usage/query.ts @@ -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); diff --git a/ui/src/pages/usage/usage-page.ts b/ui/src/pages/usage/usage-page.ts index 5cf30be8282..c849f0be04b 100644 --- a/ui/src/pages/usage/usage-page.ts +++ b/ui/src/pages/usage/usage-page.ts @@ -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); + } } } diff --git a/ui/src/pages/usage/view-details.ts b/ui/src/pages/usage/view-details.ts index dff8414ffcf..bc1f660f182 100644 --- a/ui/src/pages/usage/view-details.ts +++ b/ui/src/pages/usage/view-details.ts @@ -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( ${points.length > 0 ? svg` - ${formatTimeMs(points[0].timestamp, { hour: "2-digit", minute: "2-digit" }, "")} - ${formatTimeMs(points[points.length - 1].timestamp, { hour: "2-digit", minute: "2-digit" }, "")} + ${formatTimeMs(expectDefined(points[0], "time series first point").timestamp, { hour: "2-digit", minute: "2-digit" }, "")} + ${formatTimeMs(expectDefined(points.at(-1), "time series last point").timestamp, { hour: "2-digit", minute: "2-digit" }, "")} ` : nothing} ${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)); } diff --git a/ui/src/pages/usage/view-overview.ts b/ui/src/pages/usage/view-overview.ts index 25e936d9d98..bc6fbc1e953 100644 --- a/ui/src/pages/usage/view-overview.ts +++ b/ui/src/pages/usage/view-overview.ts @@ -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(
${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) diff --git a/ui/src/pages/workboard/view.ts b/ui/src/pages/workboard/view.ts index 02b512ea8f7..2ede04b9a47 100644 --- a/ui/src/pages/workboard/view.ts +++ b/ui/src/pages/workboard/view.ts @@ -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) { diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index ef115fb4f5c..48d55fc4bd4 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -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,