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

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

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

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

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