From 096cb911751d6c765cbe55753b1480611227d59d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Jul 2026 06:36:36 +0100 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 6 -- package.json | 1 - scripts/changed-lanes.d.mts | 2 - scripts/changed-lanes.mjs | 31 +------ scripts/check-changed.mjs | 3 - scripts/check.mjs | 1 - test/scripts/changed-lanes.test.ts | 3 - test/scripts/ci-workflow-guards.test.ts | 1 - test/scripts/oxlint-config.test.ts | 2 +- test/scripts/strict-ratchet-sync.test.ts | 48 ---------- test/vitest/vitest.system-load.ts | 3 + tsconfig.core.json | 2 + tsconfig.projects.json | 1 - tsconfig.strict-ratchet.json | 37 -------- tsconfig.ui.json | 2 + ui/src/app/assistant-identity.ts | 2 +- ui/src/app/custom-theme.ts | 12 ++- .../components/browser/browser-annotation.ts | 4 +- ui/src/components/command-palette.ts | 10 +- ui/src/components/config-form.analyze.ts | 19 +++- ui/src/components/config-form.node.ts | 3 +- ui/src/components/lobster-pet.ts | 16 +++- ui/src/components/markdown.ts | 93 +++++++++++++------ ui/src/components/modal-dialog.ts | 3 + ui/src/lib/agents/display.ts | 2 +- ui/src/lib/chat/session-diff.ts | 4 +- ui/src/lib/chat/tool-call-diff.ts | 51 +++++++--- ui/src/lib/chat/tool-call-patch.ts | 29 ++++-- ui/src/lib/chat/tool-call-view.ts | 2 +- ui/src/lib/config-form-utils.ts | 9 +- ui/src/lib/config/index.ts | 3 +- ui/src/lib/cron/index.ts | 3 +- ui/src/lib/nodes/inventory.ts | 3 + ui/src/lib/session-display.ts | 11 ++- ui/src/lib/sessions/custom-groups.ts | 3 + ui/src/lib/uuid.ts | 9 +- ui/src/lib/workspace/index.ts | 5 +- ui/src/lib/workspace/widgets/table.ts | 3 +- ui/src/pages/agents/memory/view.ts | 7 +- ui/src/pages/chat/chat-page.ts | 33 +++++-- ui/src/pages/chat/chat-send.ts | 16 ++-- ui/src/pages/chat/chat-thread.ts | 20 ++-- ui/src/pages/chat/components/chat-composer.ts | 56 +++++++---- .../pages/chat/components/chat-tool-cards.ts | 18 ++-- ui/src/pages/chat/components/chat-welcome.ts | 4 +- .../pages/chat/realtime-talk-conversation.ts | 3 + ui/src/pages/chat/realtime-talk-webrtc.ts | 5 +- ui/src/pages/chat/split-layout.ts | 52 ++++++++--- ui/src/pages/chat/tool-titles.ts | 3 +- ui/src/pages/config/view.ts | 5 +- ui/src/pages/plugin/plugin-page.ts | 3 +- ui/src/pages/plugins/presentation.ts | 12 ++- ui/src/pages/plugins/view.ts | 12 +-- ui/src/pages/profile/stats.ts | 13 ++- .../skill-workshop/skill-workshop-page.ts | 10 +- ui/src/pages/skill-workshop/view.ts | 25 +++-- ui/src/pages/skills/view.ts | 8 +- ui/src/pages/usage/helpers.ts | 19 ++-- ui/src/pages/usage/metrics.ts | 21 +++-- ui/src/pages/usage/query.ts | 9 +- ui/src/pages/usage/usage-page.ts | 6 +- ui/src/pages/usage/view-details.ts | 23 +++-- ui/src/pages/usage/view-overview.ts | 10 +- ui/src/pages/workboard/view.ts | 10 +- ui/src/test-helpers/control-ui-e2e.ts | 6 ++ 65 files changed, 501 insertions(+), 350 deletions(-) delete mode 100644 test/scripts/strict-ratchet-sync.test.ts delete mode 100644 tsconfig.strict-ratchet.json 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,