diff --git a/scripts/control-ui-i18n-verify.ts b/scripts/control-ui-i18n-verify.ts index 71a042dc3cb7..70511ab1c902 100644 --- a/scripts/control-ui-i18n-verify.ts +++ b/scripts/control-ui-i18n-verify.ts @@ -6,6 +6,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { loadControlUiTranslationMemory, materializeControlUiLocaleCatalog, + mergeControlUiTranslationMaps, } from "./lib/control-ui-i18n-catalog.ts"; import { CONTROL_UI_LOCALE_ENTRIES } from "./lib/control-ui-i18n-config.ts"; import { syncControlUiRawCopyBaseline } from "./lib/control-ui-i18n-raw-copy.ts"; @@ -21,6 +22,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const LOCALES_DIR = path.join(ROOT, "ui", "src", "i18n", "locales"); const I18N_ASSETS_DIR = path.join(ROOT, "ui", "src", "i18n", ".i18n"); const SOURCE_LOCALE_PATH = path.join(LOCALES_DIR, "en.ts"); +const ACTIVITY_SOURCE_LOCALE_PATH = path.join(LOCALES_DIR, "en-activity.ts"); const FALLBACK_BASELINE_PATH = path.join(I18N_ASSETS_DIR, "catalog-fallbacks.json"); const FALLBACK_BASELINE_VERSION = 1; @@ -52,6 +54,26 @@ async function loadLocaleMap(filePath: string, exportName: string): Promise { + const source = await loadLocaleMap(SOURCE_LOCALE_PATH, "en"); + const activitySource = ( + await importLocaleModule<{ + registerActivityEnglish: { catalog: TranslationMap }; + }>(ACTIVITY_SOURCE_LOCALE_PATH) + ).registerActivityEnglish.catalog; + if (!source || !activitySource) { + throw new Error("Control UI English source catalogs are incomplete"); + } + return mergeControlUiTranslationMaps(source, activitySource); +} + +async function readSourceLocaleRaw(): Promise { + const sources = await Promise.all( + [SOURCE_LOCALE_PATH, ACTIVITY_SOURCE_LOCALE_PATH].map((filePath) => readFile(filePath, "utf8")), + ); + return sources.join("\n"); +} + function extractPlaceholders(text: string): string[] { return [...new Set([...text.matchAll(/\{(\w+)\}/g)].map((match) => match[1] ?? ""))] .filter(Boolean) @@ -136,11 +158,8 @@ async function buildCatalogFallbackBaseline( allowCatalogDrift?: boolean; } = {}, ): Promise { - const sourceRaw = await readFile(SOURCE_LOCALE_PATH, "utf8"); - const sourceMap = await loadLocaleMap(SOURCE_LOCALE_PATH, "en"); - if (!sourceMap) { - throw new Error("ui/src/i18n/locales/en.ts does not export en"); - } + const sourceRaw = await readSourceLocaleRaw(); + const sourceMap = await loadSourceLocaleMap(); const sourceFlat = flattenControlUiCatalog(sourceMap, "en"); const localeFlats = new Map>(); for (const entry of CONTROL_UI_LOCALE_ENTRIES) { @@ -191,10 +210,7 @@ function printCatalogFallbackSummary(baseline: CatalogFallbackBaseline) { } async function verifyControlUiSourceCatalogShape() { - const sourceMap = await loadLocaleMap(SOURCE_LOCALE_PATH, "en"); - if (!sourceMap) { - throw new Error("ui/src/i18n/locales/en.ts does not export en"); - } + const sourceMap = await loadSourceLocaleMap(); const sourceFlat = flattenControlUiCatalog(sourceMap, "en"); process.stdout.write(`control-ui-i18n: source: keys=${sourceFlat.size}\n`); } diff --git a/scripts/control-ui-i18n.ts b/scripts/control-ui-i18n.ts index 5bd68416bcfa..734d30537590 100644 --- a/scripts/control-ui-i18n.ts +++ b/scripts/control-ui-i18n.ts @@ -18,6 +18,7 @@ import { hashControlUiTranslationText, loadControlUiTranslationMemory, materializeControlUiLocaleCatalog, + mergeControlUiTranslationMaps, } from "./lib/control-ui-i18n-catalog.ts"; import { CONTROL_UI_LOCALE_ENTRIES } from "./lib/control-ui-i18n-config.ts"; import { syncControlUiRawCopyBaseline } from "./lib/control-ui-i18n-raw-copy.ts"; @@ -52,6 +53,7 @@ const ROOT = path.resolve(HERE, ".."); const LOCALES_DIR = path.join(ROOT, "ui", "src", "i18n", "locales"); const I18N_ASSETS_DIR = path.join(ROOT, "ui", "src", "i18n", ".i18n"); const SOURCE_LOCALE_PATH = path.join(LOCALES_DIR, "en.ts"); +const ACTIVITY_SOURCE_LOCALE_PATH = path.join(LOCALES_DIR, "en-activity.ts"); const SOURCE_LOCALE = "en"; const MAX_BATCH_ITEMS = 20; const DEFAULT_BATCH_CHAR_BUDGET = 2_000; @@ -291,6 +293,26 @@ async function loadLocaleMap(filePath: string, exportName: string): Promise { + const source = await loadLocaleMap(SOURCE_LOCALE_PATH, "en"); + const activitySource = ( + await importLocaleModule<{ + registerActivityEnglish: { catalog: TranslationMap }; + }>(ACTIVITY_SOURCE_LOCALE_PATH) + ).registerActivityEnglish.catalog; + if (!source || !activitySource) { + throw new Error("Control UI English source catalogs are incomplete"); + } + return mergeControlUiTranslationMaps(source, activitySource); +} + +async function readSourceLocaleRaw(): Promise { + const sources = await Promise.all( + [SOURCE_LOCALE_PATH, ACTIVITY_SOURCE_LOCALE_PATH].map((filePath) => readFile(filePath, "utf8")), + ); + return sources.join("\n"); +} + type PlaceholderMismatch = { key: string; locale: string; @@ -1089,9 +1111,9 @@ async function syncLocale( ) { const localeLabel = formatLocaleLabel(entry.locale, context); const localeStartedAt = Date.now(); - const sourceRaw = await readFile(SOURCE_LOCALE_PATH, "utf8"); + const sourceRaw = await readSourceLocaleRaw(); const sourceHash = sha256(sourceRaw); - const sourceMap = (await loadLocaleMap(SOURCE_LOCALE_PATH, "en")) ?? {}; + const sourceMap = await loadSourceLocaleMap(); const sourceFlat = flattenTranslations(sourceMap); const tm = loadControlUiTranslationMemory(tmPath(entry)); const existingMap = materializeControlUiLocaleCatalog(sourceFlat, tm); diff --git a/scripts/lib/control-ui-i18n-catalog.ts b/scripts/lib/control-ui-i18n-catalog.ts index ae865877abb2..c6fdf45544e5 100644 --- a/scripts/lib/control-ui-i18n-catalog.ts +++ b/scripts/lib/control-ui-i18n-catalog.ts @@ -6,6 +6,28 @@ export function hashControlUiTranslationText(text: string): string { return createHash("sha256").update(text.trim().split(/\s+/).join(" ")).digest("hex"); } +export function mergeControlUiTranslationMaps( + ...maps: ReadonlyArray +): TranslationMap { + const merged: TranslationMap = {}; + const mergeInto = (target: TranslationMap, source: TranslationMap): void => { + for (const [key, value] of Object.entries(source)) { + if (typeof value === "string") { + target[key] = value; + continue; + } + const existing = target[key]; + const nested = typeof existing === "object" ? existing : {}; + target[key] = nested; + mergeInto(nested, value); + } + }; + for (const map of maps) { + mergeInto(merged, map); + } + return merged; +} + export function loadControlUiTranslationMemory( filePath: string, ): Map { diff --git a/test/scripts/control-ui-i18n-sync-plan.test.ts b/test/scripts/control-ui-i18n-sync-plan.test.ts index fde403db52a0..03e287e14a65 100644 --- a/test/scripts/control-ui-i18n-sync-plan.test.ts +++ b/test/scripts/control-ui-i18n-sync-plan.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { hashControlUiTranslationText, materializeControlUiLocaleCatalog, + mergeControlUiTranslationMaps, } from "../../scripts/lib/control-ui-i18n-catalog.ts"; import { createControlUiLocaleSyncPlan, @@ -55,6 +56,18 @@ function localeMeta(overrides: Partial = {}): LocaleMeta { } describe("createControlUiLocaleSyncPlan", () => { + it("merges lazy English source catalogs without losing sibling keys", () => { + expect( + mergeControlUiTranslationMaps( + { activity: { title: "Activity" }, common: { ok: "OK" } }, + { activity: { runInspector: { title: "Run inspector" } } }, + ), + ).toEqual({ + activity: { title: "Activity", runInspector: { title: "Run inspector" } }, + common: { ok: "OK" }, + }); + }); + it("preserves provenance when a configured provider performs no translation", () => { const previousMeta = localeMeta(); diff --git a/ui/config/control-ui-locales.ts b/ui/config/control-ui-locales.ts index 5c17a5eca881..f61e9747d65e 100644 --- a/ui/config/control-ui-locales.ts +++ b/ui/config/control-ui-locales.ts @@ -4,9 +4,11 @@ import type { Plugin } from "vite"; import { loadControlUiTranslationMemory, materializeControlUiLocaleCatalog, + mergeControlUiTranslationMaps, } from "../../scripts/lib/control-ui-i18n-catalog.ts"; import { CONTROL_UI_LOCALE_ENTRIES } from "../../scripts/lib/control-ui-i18n-config.ts"; import { flattenTranslations } from "../../scripts/lib/control-ui-i18n-sync-plan.ts"; +import { registerActivityEnglish } from "../src/i18n/locales/en-activity.ts"; import { en } from "../src/i18n/locales/en.ts"; const localeModulePrefix = "virtual:openclaw-control-ui-locale/"; @@ -17,6 +19,7 @@ const i18nAssetsDir = path.resolve( "../src/i18n/.i18n", ); const locales = new Set(CONTROL_UI_LOCALE_ENTRIES.map(({ locale }) => locale)); +const sourceCatalog = mergeControlUiTranslationMaps(en, registerActivityEnglish.catalog); export function controlUiLocaleModulesPlugin(): Plugin { return { @@ -42,7 +45,7 @@ export function controlUiLocaleModulesPlugin(): Plugin { if (memory.size === 0) { throw new Error(`Control UI ${locale} translation memory is missing or empty`); } - const catalog = materializeControlUiLocaleCatalog(flattenTranslations(en), memory); + const catalog = materializeControlUiLocaleCatalog(flattenTranslations(sourceCatalog), memory); return `export default ${JSON.stringify(catalog)};`; }, }; diff --git a/ui/src/app/vite-config.node.test.ts b/ui/src/app/vite-config.node.test.ts index 2d3f20ecb1cb..bd84d25ad1f2 100644 --- a/ui/src/app/vite-config.node.test.ts +++ b/ui/src/app/vite-config.node.test.ts @@ -459,6 +459,7 @@ describe("Control UI Vite config", () => { } const catalog = JSON.parse(result.replace(/^export default /, "").replace(/;$/, "")); expect(catalog.common.health).toBe("Santé"); + expect(catalog.activity.title).toBeTypeOf("string"); expect(addWatchFile).toHaveBeenCalledWith(path.join(repoRoot, "ui/src/i18n/.i18n/fr.tm.jsonl")); }); }); diff --git a/ui/src/i18n/locales/en-activity.ts b/ui/src/i18n/locales/en-activity.ts new file mode 100644 index 000000000000..b3a0390cbe13 --- /dev/null +++ b/ui/src/i18n/locales/en-activity.ts @@ -0,0 +1,223 @@ +import type { TranslationMap } from "../lib/types.ts"; +import { en } from "./en.ts"; + +// Activity-only copy is registered when the lazy Activity page loads so the +// diagnostic inspector does not tax every Control UI startup. +const enActivity = { + activity: { + title: "Activity", + visibleCount: "{visible} of {total}", + search: "Search", + searchPlaceholder: "Filter by activity, summary, run, session", + toolFilter: "Tool", + allTools: "All tools", + statusFilters: "Status filters", + autoFollow: "Auto-follow", + expandAll: "Expand all", + collapseAll: "Collapse all", + clear: "Clear", + empty: "No activity yet.", + emptyFiltered: "No activity matches these filters.", + entrySummary: "{argumentSummary}", + argumentHiddenOne: "1 argument hidden", + argumentsHidden: "{count} arguments hidden", + streamLabel: "Agent activity entries", + toolCallId: "Tool call", + runId: "Run", + session: "Session", + outputTruncated: "Preview redacted and truncated.", + noOutputPreview: "No output preview.", + answerCandidate: { + title: "Answer candidate", + itemId: "Item", + candidate: "Candidate answer", + superseded: "Superseded answer", + selected: "Selected answer", + }, + status: { + running: "Running", + done: "Done", + error: "Error", + }, + subtitle: "Ephemeral agent activity derived from live session events.", + runInspector: { + activityView: "Activity view", + liveMode: "Live activity", + mode: "Run inspector", + intro: + "Durable Gateway-backed identity evidence for one run. Reloading this page queries the Gateway again.", + bestEffortWarning: + "Best-effort audit warning: this view is for operational diagnostics, not a lossless compliance record. Absence of evidence does not prove that an action or run did not occur.", + evidenceStateLabel: "Evidence state: {state}", + evidenceState: { + present: "Present", + absent: "Absent", + unknown: "Unknown", + unsupported: "Unsupported", + }, + coverageStatusLabel: "Inspection coverage: {state}", + coverage: { + enforced: { + label: "Enforced", + description: + "A decision receipt proves identity-aware evaluation; it does not by itself mean the action was allowed.", + }, + attributionOnly: { + label: "Attribution only", + description: + "Identity facts were recorded, but no identity-aware policy or grant evaluation is proven.", + }, + unattributed: { + label: "Unattributed", + description: "The supported path was observed without a usable invoker principal.", + }, + unknown: { + label: "Unknown", + description: + "Expected evidence is missing, corrupt, expired unexpectedly, or unreadable.", + }, + unsupported: { + label: "Unsupported", + description: "This path has no Phase 0 identity evidence contract.", + }, + }, + facts: { + trustDomain: "Trust domain", + ingress: "Ingress", + invoker: "Invoker", + representedSubject: "Represented subject", + sponsor: "Sponsor", + agentPrincipal: "Agent principal", + agentDefinition: "Agent definition", + runtimeInstance: "Runtime instance", + applicableGrants: "Applicable grants", + applicableGrant: "Applicable grant {index}", + assuranceEvidence: "Assurance evidence", + assuranceEvidenceItem: "Assurance evidence {index}", + lineage: "Lineage", + }, + values: { + label: "Label", + kind: "Kind", + principalReference: "Principal reference", + domainReference: "Domain reference", + owningBoundary: "Owning boundary", + sourceReference: "Source reference", + relationshipReference: "Relationship reference", + definitionReference: "Definition reference", + revisionReference: "Revision reference", + runtimeReference: "Runtime reference", + grantReference: "Grant reference", + strength: "Strength", + evidenceReference: "Evidence reference", + depth: "Depth", + parentRunReference: "Parent run reference", + parentExecutionReference: "Parent execution reference", + parentContextReference: "Parent context reference", + delegationReference: "Delegation reference", + }, + reasons: { + absent: "No {label} was recorded at the owning boundary.", + unknown: "The {label} was expected, but its evidence is unavailable or unreadable.", + unsupported: "This execution path does not provide {label} evidence.", + invokerAbsent: "The supported ingress boundary recorded no usable invoker principal.", + noGrants: "No applicable grants were recorded for this run.", + noAssurance: "No assurance evidence was recorded for this run.", + noLineage: "No parent or subagent lineage was recorded for this run.", + }, + identityHeading: "Identity and authority", + missingEvidenceHeading: "Missing evidence", + noMissingEvidence: "No missing evidence was reported for this projection.", + nextStepsHeading: "Next steps", + decisions: { + heading: "Decision receipts", + none: "No decision receipts were returned for this bounded page.", + returned: "The Gateway returned {count} receipt summaries for this bounded page.", + more: "Additional decision receipts are available. This inspector intentionally shows only the bounded first page; use the audit CLI with a cursor for later pages.", + bounded: "Decision inspection is bounded to at most 50 records per request.", + }, + diagnosticReason: "Diagnostic reason:", + diagnostic: { + notFound: { + title: "Run not found", + description: + "No retained run or identity record matched this reference. Missing best-effort evidence does not prove that the run never occurred.", + }, + expired: { + title: "Identity evidence expired", + description: + "The Gateway found the run, but its identity context is outside the 30-day retention window.", + }, + corrupt: { + title: "Identity evidence is corrupt", + description: + "The Gateway found evidence for this run but could not validate the stored identity context.", + }, + ambiguous: { + title: "Multiple executions match this run", + description: + "A run reference can correlate more than one execution. The inspector will not guess which execution you meant.", + }, + unsupported: { + title: "Identity evidence unsupported", + description: + "The run is known, but this execution path did not retain a supported identity context.", + }, + unknown: { + title: "Identity evidence unknown", + description: + "The path promises evidence, but the expected record is missing, unreadable, or otherwise unavailable.", + }, + }, + candidates: { + listLabel: "Matching executions", + recorded: "Recorded {date}", + executionReference: "Inspect execution", + more: "More matching executions exist beyond this bounded page. Use the audit CLI to continue discovery and select one exact execution.", + }, + panels: { + empty: { + title: "No run selected", + description: + "Open a link shaped like /activity?view=run&run= to inspect durable identity evidence.", + }, + waiting: { + title: "Waiting for the Gateway", + description: "The durable projection will load when this browser reconnects.", + }, + loading: { + title: "Loading run inspection", + description: "Reading the Gateway's retained identity projection…", + }, + disconnected: { + title: "Gateway disconnected", + description: + "Run identity is durable on the Gateway, but it cannot be read while this browser is disconnected.", + }, + unauthorized: { + title: "Operator read access required", + description: + "This connection does not have operator.read, so retained run identity cannot be loaded.", + }, + unsupported: { + title: "Run inspection unsupported", + description: + "This Gateway does not offer audit.run.inspect. Upgrade the Gateway, enable execution identity collection, and record a new run.", + }, + error: { + title: "Run inspection failed", + description: + "The Gateway could not return this diagnostic projection. No identity facts were inferred from Live activity.", + }, + }, + retry: "Retry inspection", + }, + }, +} satisfies TranslationMap; + +export const registerActivityEnglish = Object.assign( + () => { + en.activity = enActivity.activity; + }, + { catalog: enActivity }, +); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 2425bd73cd01..01aab66b16b9 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -3206,215 +3206,6 @@ export const en: TranslationMap = { applicabilityHeading: "When the agent should use it", }, }, - activity: { - title: "Activity", - subtitle: "Ephemeral agent activity derived from live session events.", - runInspector: { - activityView: "Activity view", - liveMode: "Live activity", - mode: "Run inspector", - intro: - "Durable Gateway-backed identity evidence for one run. Reloading this page queries the Gateway again.", - bestEffortWarning: - "Best-effort audit warning: this view is for operational diagnostics, not a lossless compliance record. Absence of evidence does not prove that an action or run did not occur.", - evidenceStateLabel: "Evidence state: {state}", - evidenceState: { - present: "Present", - absent: "Absent", - unknown: "Unknown", - unsupported: "Unsupported", - }, - coverageStatusLabel: "Inspection coverage: {state}", - coverage: { - enforced: { - label: "Enforced", - description: - "A decision receipt proves identity-aware evaluation; it does not by itself mean the action was allowed.", - }, - attributionOnly: { - label: "Attribution only", - description: - "Identity facts were recorded, but no identity-aware policy or grant evaluation is proven.", - }, - unattributed: { - label: "Unattributed", - description: "The supported path was observed without a usable invoker principal.", - }, - unknown: { - label: "Unknown", - description: - "Expected evidence is missing, corrupt, expired unexpectedly, or unreadable.", - }, - unsupported: { - label: "Unsupported", - description: "This path has no Phase 0 identity evidence contract.", - }, - }, - facts: { - trustDomain: "Trust domain", - ingress: "Ingress", - invoker: "Invoker", - representedSubject: "Represented subject", - sponsor: "Sponsor", - agentPrincipal: "Agent principal", - agentDefinition: "Agent definition", - runtimeInstance: "Runtime instance", - applicableGrants: "Applicable grants", - applicableGrant: "Applicable grant {index}", - assuranceEvidence: "Assurance evidence", - assuranceEvidenceItem: "Assurance evidence {index}", - lineage: "Lineage", - }, - values: { - label: "Label", - kind: "Kind", - principalReference: "Principal reference", - domainReference: "Domain reference", - owningBoundary: "Owning boundary", - sourceReference: "Source reference", - relationshipReference: "Relationship reference", - definitionReference: "Definition reference", - revisionReference: "Revision reference", - runtimeReference: "Runtime reference", - grantReference: "Grant reference", - strength: "Strength", - evidenceReference: "Evidence reference", - depth: "Depth", - parentRunReference: "Parent run reference", - parentExecutionReference: "Parent execution reference", - parentContextReference: "Parent context reference", - delegationReference: "Delegation reference", - }, - reasons: { - absent: "No {label} was recorded at the owning boundary.", - unknown: "The {label} was expected, but its evidence is unavailable or unreadable.", - unsupported: "This execution path does not provide {label} evidence.", - invokerAbsent: "The supported ingress boundary recorded no usable invoker principal.", - noGrants: "No applicable grants were recorded for this run.", - noAssurance: "No assurance evidence was recorded for this run.", - noLineage: "No parent or subagent lineage was recorded for this run.", - }, - identityHeading: "Identity and authority", - missingEvidenceHeading: "Missing evidence", - noMissingEvidence: "No missing evidence was reported for this projection.", - nextStepsHeading: "Next steps", - decisions: { - heading: "Decision receipts", - none: "No decision receipts were returned for this bounded page.", - returned: "The Gateway returned {count} receipt summaries for this bounded page.", - more: "Additional decision receipts are available. This inspector intentionally shows only the bounded first page; use the audit CLI with a cursor for later pages.", - bounded: "Decision inspection is bounded to at most 50 records per request.", - }, - diagnosticReason: "Diagnostic reason:", - diagnostic: { - notFound: { - title: "Run not found", - description: - "No retained run or identity record matched this reference. Missing best-effort evidence does not prove that the run never occurred.", - }, - expired: { - title: "Identity evidence expired", - description: - "The Gateway found the run, but its identity context is outside the 30-day retention window.", - }, - corrupt: { - title: "Identity evidence is corrupt", - description: - "The Gateway found evidence for this run but could not validate the stored identity context.", - }, - ambiguous: { - title: "Multiple executions match this run", - description: - "A run reference can correlate more than one execution. The inspector will not guess which execution you meant.", - }, - unsupported: { - title: "Identity evidence unsupported", - description: - "The run is known, but this execution path did not retain a supported identity context.", - }, - unknown: { - title: "Identity evidence unknown", - description: - "The path promises evidence, but the expected record is missing, unreadable, or otherwise unavailable.", - }, - }, - candidates: { - listLabel: "Matching executions", - recorded: "Recorded {date}", - executionReference: "Inspect execution", - more: "More matching executions exist beyond this bounded page. Use the audit CLI to continue discovery and select one exact execution.", - }, - panels: { - empty: { - title: "No run selected", - description: - "Open a link shaped like /activity?view=run&run= to inspect durable identity evidence.", - }, - waiting: { - title: "Waiting for the Gateway", - description: "The durable projection will load when this browser reconnects.", - }, - loading: { - title: "Loading run inspection", - description: "Reading the Gateway's retained identity projection…", - }, - disconnected: { - title: "Gateway disconnected", - description: - "Run identity is durable on the Gateway, but it cannot be read while this browser is disconnected.", - }, - unauthorized: { - title: "Operator read access required", - description: - "This connection does not have operator.read, so retained run identity cannot be loaded.", - }, - unsupported: { - title: "Run inspection unsupported", - description: - "This Gateway does not offer audit.run.inspect. Upgrade the Gateway, enable execution identity collection, and record a new run.", - }, - error: { - title: "Run inspection failed", - description: - "The Gateway could not return this diagnostic projection. No identity facts were inferred from Live activity.", - }, - }, - retry: "Retry inspection", - }, - visibleCount: "{visible} of {total}", - search: "Search", - searchPlaceholder: "Filter by activity, summary, run, session", - toolFilter: "Tool", - allTools: "All tools", - statusFilters: "Status filters", - autoFollow: "Auto-follow", - expandAll: "Expand all", - collapseAll: "Collapse all", - clear: "Clear", - empty: "No activity yet.", - emptyFiltered: "No activity matches these filters.", - entrySummary: "{argumentSummary}", - argumentHiddenOne: "1 argument hidden", - argumentsHidden: "{count} arguments hidden", - streamLabel: "Agent activity entries", - toolCallId: "Tool call", - runId: "Run", - session: "Session", - outputTruncated: "Preview redacted and truncated.", - noOutputPreview: "No output preview.", - answerCandidate: { - title: "Answer candidate", - itemId: "Item", - candidate: "Candidate answer", - superseded: "Superseded answer", - selected: "Selected answer", - }, - status: { - running: "Running", - done: "Done", - error: "Error", - }, - }, gatewayLogs: { title: "Logs", subtitle: "Gateway file logs (JSONL).", diff --git a/ui/src/pages/activity/activity-page.ts b/ui/src/pages/activity/activity-page.ts index 8c9606cbab5a..f1e73b4676ec 100644 --- a/ui/src/pages/activity/activity-page.ts +++ b/ui/src/pages/activity/activity-page.ts @@ -25,10 +25,11 @@ import { uiSessionEventMatches } from "../../lib/sessions/session-key.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { StreamAutoFollowController } from "../../lit/stream-auto-follow-controller.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; -import type { - ActivityRouteData, - RunInspectorSelector, - RunInspectorState, +import { + resolveActivityRouteData, + type ActivityRouteData, + type RunInspectorSelector, + type RunInspectorState, } from "./run-inspector-model.ts"; import { renderRunInspector } from "./run-inspector-view.ts"; import { @@ -49,7 +50,8 @@ class ActivityPage extends OpenClawLightDomElement { @consume({ context: applicationContext, subscribe: true }) private context!: ApplicationContext; - @property({ attribute: false }) routeData: ActivityRouteData | undefined; + @property({ attribute: false }) routeSearch = ""; + private routeData: ActivityRouteData = { mode: "live", selector: null }; @state() private entries: ActivityEntry[] = []; @state() private filterText = ""; @@ -89,8 +91,14 @@ class ActivityPage extends OpenClawLightDomElement { }, ); + override willUpdate(changed: PropertyValues) { + if (changed.has("routeSearch")) { + this.routeData = resolveActivityRouteData(this.routeSearch); + } + } + override updated(changed: PropertyValues) { - if (changed.has("routeData")) { + if (changed.has("routeSearch")) { this.bindInspectorRoute(); } if ( @@ -406,6 +414,13 @@ class ActivityPage extends OpenClawLightDomElement { } } +export const activityPageComponent = { + header: true, + render: (search: unknown) => html``, +}; + if (!customElements.get("openclaw-activity-page")) { customElements.define("openclaw-activity-page", ActivityPage); } diff --git a/ui/src/pages/activity/route.test.ts b/ui/src/pages/activity/route.test.ts index 904da9525c80..733612467a4a 100644 --- a/ui/src/pages/activity/route.test.ts +++ b/ui/src/pages/activity/route.test.ts @@ -3,21 +3,22 @@ import type { RouteLoaderOptions, RouteLocation } from "@openclaw/uirouter"; import { describe, expect, it } from "vitest"; import type { ApplicationContext } from "../../app/context.ts"; import { page } from "./route.ts"; -import type { ActivityRouteData } from "./run-inspector-model.ts"; +import { resolveActivityRouteData, type ActivityRouteData } from "./run-inspector-model.ts"; function loadRoute(search: string): ActivityRouteData { if (!page.loader) { throw new Error("activity route has no loader"); } const location: RouteLocation = { pathname: "/activity", search, hash: "" }; - return page.loader({} as ApplicationContext, { + const loaded = page.loader({} as ApplicationContext, { signal: new AbortController().signal, shouldRun: () => true, revalidating: false, location, deps: search, cause: "navigation", - } satisfies RouteLoaderOptions) as ActivityRouteData; + } satisfies RouteLoaderOptions); + return resolveActivityRouteData(typeof loaded === "string" ? loaded : ""); } describe("resolveActivityRouteData", () => { diff --git a/ui/src/pages/activity/route.ts b/ui/src/pages/activity/route.ts index ad580555795e..e87a5a2f6d91 100644 --- a/ui/src/pages/activity/route.ts +++ b/ui/src/pages/activity/route.ts @@ -1,34 +1,9 @@ -import type { RouteLocation } from "@openclaw/uirouter"; import { definePage } from "@openclaw/uirouter"; -import { html } from "lit"; import { routePageSpec } from "../../app-route-paths.ts"; -import type { ApplicationContext } from "../../app/context.ts"; -import type { ActivityRouteData } from "./run-inspector-model.ts"; - -function resolveActivityRouteData(search: string): ActivityRouteData { - const params = new URLSearchParams(search); - if (params.get("view") !== "run") { - return { mode: "live", selector: null }; - } - const executionId = params.get("execution"); - if (executionId?.trim()) { - return { mode: "run", selector: { kind: "execution", id: executionId } }; - } - const runId = params.get("run"); - return { - mode: "run", - selector: runId?.trim() ? { kind: "run", id: runId } : null, - }; -} export const page = definePage({ ...routePageSpec("activity"), - loaderDeps: (_context: ApplicationContext, location: RouteLocation) => location.search, - loader: (_context: ApplicationContext, { location }) => resolveActivityRouteData(location.search), - component: () => - import("./activity-page.ts").then(() => ({ - header: true, - render: (data: ActivityRouteData | undefined) => - html``, - })), + loaderDeps: (_context, { search }) => search, + loader: (_context, { deps }) => deps, + component: () => import("./activity-page.ts").then((module) => module.activityPageComponent), }); diff --git a/ui/src/pages/activity/run-inspector-model.ts b/ui/src/pages/activity/run-inspector-model.ts index 0e81726745f0..3eb1661ba1a2 100644 --- a/ui/src/pages/activity/run-inspector-model.ts +++ b/ui/src/pages/activity/run-inspector-model.ts @@ -6,6 +6,22 @@ export type ActivityRouteData = | { mode: "live"; selector: null } | { mode: "run"; selector: RunInspectorSelector | null }; +export function resolveActivityRouteData(search: string): ActivityRouteData { + const params = new URLSearchParams(search); + if (params.get("view") !== "run") { + return { mode: "live", selector: null }; + } + const executionId = params.get("execution"); + if (executionId?.trim()) { + return { mode: "run", selector: { kind: "execution", id: executionId } }; + } + const runId = params.get("run"); + return { + mode: "run", + selector: runId?.trim() ? { kind: "run", id: runId } : null, + }; +} + export type RunInspectorState = | { status: "empty" } | { status: "loading"; waitingForGateway: boolean } diff --git a/ui/src/pages/activity/run-inspector-view.ts b/ui/src/pages/activity/run-inspector-view.ts index 0fadd9a64c08..1ee2843e18cf 100644 --- a/ui/src/pages/activity/run-inspector-view.ts +++ b/ui/src/pages/activity/run-inspector-view.ts @@ -6,9 +6,12 @@ import type { } from "../../../../packages/gateway-protocol/src/schema/audit-run.js"; import { pathForRoute } from "../../app-route-paths.ts"; import { t } from "../../i18n/index.ts"; +import { registerActivityEnglish } from "../../i18n/locales/en-activity.ts"; import { classifyRunInspection, type RunInspectorState } from "./run-inspector-model.ts"; import "./run-inspector.css"; +registerActivityEnglish(); + type EvidenceState = "present" | "absent" | "unknown" | "unsupported"; type RunInspectorProps = { diff --git a/ui/src/pages/activity/view.ts b/ui/src/pages/activity/view.ts index 3dacf784e265..ed2e3bcc069e 100644 --- a/ui/src/pages/activity/view.ts +++ b/ui/src/pages/activity/view.ts @@ -7,11 +7,14 @@ import { renderSettingsToggle, } from "../../components/settings-ui.ts"; import { t } from "../../i18n/index.ts"; +import { registerActivityEnglish } from "../../i18n/locales/en-activity.ts"; import { formatDurationCompact, formatTimeMs } from "../../lib/format.ts"; import { normalizeLowercaseStringOrEmpty, sortUniqueStrings } from "../../lib/string-coerce.ts"; import "../../styles/activity.css"; import type { ActivityEntry, ActivityStatus } from "./tool-activity.ts"; +registerActivityEnglish(); + const STATUS_ORDER: ActivityStatus[] = ["running", "done", "error"]; type ActivityProps = {