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