refactor(ui): derive gateway types from protocol and consolidate navigation/error helpers (#120348)

* refactor(protocol): derive shared gateway types

* refactor(ui): consolidate navigation click handling

* refactor(ui): centralize UI state and error helpers
This commit is contained in:
Peter Steinberger
2026-08-07 15:25:07 -07:00
committed by GitHub
parent fb0812c857
commit 8bf62e7007
84 changed files with 447 additions and 562 deletions
@@ -122,7 +122,7 @@ aa8a411ad37c1d1143b67376bf2d20255b9eedff61d80815f42e4f8ed7bd8e58 module/secret-
57dcb1462d4c4f9a98d934c4ca975b163d704758af9821a64001ff3ac05637c3 module/secret-input-runtime
dc0ee07d392a85c218939000b28c0138f139215da00f5592b34a68ba8e29a25d module/secret-ref-runtime
f97549081955e412d8eb64070c64bb5921bb1324744d824db05767d3adcce403 module/security-runtime
1d4a6e7e97a21c95c47be75e0e1745daa42d6ea37d956dce3c114277f42c9906 module/session-catalog
4bfa843c91a04a4cff0496ed89d9b9481c07b8dbdce6de784e2743f4f66afc1d module/session-catalog
50f5e344f98c27570b7a30e32a906b612e2383d21f102e88cd93e1d5425a6de9 module/session-discussion
f112bdabc51ba8659b37d0a6f6a32a2b1d471e5b49b56e108bf750ec55a7ea71 module/session-store-runtime
36affbe151431a6141664b6838e20f2d121ff210d57a3c1b4b41a8818b5c81d8 module/setup
+1
View File
@@ -29,6 +29,7 @@ export {
SessionToolOverridesSchema,
type SessionCreatedActor,
type SessionRow,
type SessionRunStatus,
type SessionToolOverrides,
} from "./schema/sessions-row.js";
export * from "./schema/session-classification.js";
@@ -109,6 +109,9 @@ const RunToolBindingsSchema = Type.Record(
{ maxProperties: 16 },
);
const QUEUE_MODES = ["steer", "followup", "collect", "interrupt"] as const;
export type QueueMode = (typeof QUEUE_MODES)[number];
/** User-to-agent send request; idempotency key lets clients safely retry transport failures. */
export const ChatSendParamsSchema = closedObject({
sessionKey: ChatSendSessionKeyString,
@@ -120,7 +123,7 @@ export const ChatSendParamsSchema = closedObject({
// One-turn override for auto fast-mode cutoff seconds.
fastAutoOnSeconds: Type.Optional(Type.Integer({ minimum: 1 })),
// One-turn override for active-run queue admission.
queueMode: Type.Optional(Type.String({ enum: ["steer", "followup", "collect", "interrupt"] })),
queueMode: Type.Optional(Type.String({ enum: [...QUEUE_MODES] })),
deliver: Type.Optional(Type.Boolean()),
originatingChannel: Type.Optional(Type.String()),
originatingTo: Type.Optional(Type.String()),
@@ -135,3 +135,4 @@ export const SessionRowSchema = Type.Object(
export type SessionCreatedActor = Static<typeof SessionCreatedActorSchema>;
export type SessionToolOverrides = Static<typeof SessionToolOverridesSchema>;
export type SessionRow = Static<typeof SessionRowSchema>;
export type SessionRunStatus = NonNullable<SessionRow["status"]>;
@@ -17,6 +17,7 @@ export {
SessionToolOverridesSchema,
type SessionCreatedActor,
type SessionRow,
type SessionRunStatus,
type SessionToolOverrides,
} from "./sessions-row.js";
@@ -1,5 +1,6 @@
// Shared sessions_spawn test harness for gateway, registry, and lifecycle mocks.
import { vi, type Mock } from "vitest";
import type { SessionRunStatus } from "../../packages/gateway-protocol/src/schema/sessions-row.js";
import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js";
import { resolveRequesterStoreKey } from "./subagent-requester-store-key.js";
@@ -22,7 +23,7 @@ type TestSessionEntry = {
updatedAt: number;
startedAt?: number;
endedAt?: number;
status?: "running" | "done" | "failed" | "killed" | "timeout";
status?: SessionRunStatus;
};
type SessionsSpawnGatewayMockOptions = {
includeSessionsList?: boolean;
+1 -3
View File
@@ -21,6 +21,7 @@ export {
shouldResolveSessionIdInput,
} from "./sessions-resolution.js";
import { normalizeOptionalString, type FastMode } from "@openclaw/normalization-core/string-coerce";
import type { SessionRunStatus } from "../../../packages/gateway-protocol/src/schema/sessions-row.js";
import { getRuntimeConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { parseRawSessionConversationRef } from "../../sessions/session-key-utils.js";
@@ -37,9 +38,6 @@ type SessionListDeliveryContext = {
threadId?: string | number;
};
/** Compact run status shown by session tools. */
export type SessionRunStatus = "running" | "done" | "failed" | "killed" | "timeout";
/** Full Gateway session row consumed by session orchestration internals. */
export type GatewaySessionListRow = {
key: string;
+1 -1
View File
@@ -9,6 +9,7 @@ import {
} from "@openclaw/normalization-core/string-coerce";
import pMap from "p-map";
import { Type } from "typebox";
import type { SessionRunStatus } from "../../../packages/gateway-protocol/src/schema/sessions-row.js";
import { getRuntimeConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -47,7 +48,6 @@ import {
resolveSandboxedSessionToolContext,
type GatewaySessionListRow,
type SessionListRow,
type SessionRunStatus,
} from "./sessions-helpers.js";
const SessionsListToolSchema = Type.Object({
@@ -1,5 +1,6 @@
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
// Parses inline reply directives into typed execution and routing options.
import type { QueueMode } from "../../../packages/gateway-protocol/src/schema/logs-chat.js";
import type { ExecAsk, ExecSecurity, ExecTarget } from "../../infra/exec-approvals.js";
import { extractModelDirective } from "../model.js";
import { isSessionDefaultDirectiveValue } from "../thinking.js";
@@ -21,7 +22,7 @@ import {
extractVerboseDirective,
} from "./directives.js";
import { extractQueueDirective } from "./queue/directive.js";
import type { QueueDropPolicy, QueueMode } from "./queue/types.js";
import type { QueueDropPolicy } from "./queue/types.js";
const NATIVE_REPLY_DIRECTIVE_COMMANDS = {
think: true,
+2 -1
View File
@@ -1,3 +1,4 @@
import type { QueueMode } from "../../../packages/gateway-protocol/src/schema/logs-chat.js";
import type { SessionToolOverrides } from "../../config/sessions/types.js";
// Shared get-reply type contracts for command, directive, and runtime layers.
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -5,7 +6,7 @@ import type { ReplyOptionsWithHeartbeatRunScope } from "../../infra/heartbeat-ru
import type { GetReplyOptions } from "../get-reply-options.types.js";
import type { ReplyPayload } from "../reply-payload.js";
import type { MsgContext } from "../templating.js";
import type { FollowupQueueDisposition, QueueMode } from "./queue/types.js";
import type { FollowupQueueDisposition } from "./queue/types.js";
import type { ReplyOptionsWithOperationRunState } from "./reply-operation-run-state.js";
import type { ReplyOperation } from "./reply-run-registry.js";
+2 -1
View File
@@ -1,10 +1,11 @@
// Converts queue directives into normalized queue settings.
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import type { QueueMode } from "../../../../packages/gateway-protocol/src/schema/logs-chat.js";
import { parseDurationMs } from "../../../cli/parse-duration.js";
import { parseStrictPositiveInteger } from "../../../infra/parse-finite-number.js";
import { skipDirectiveArgPrefix, takeDirectiveToken } from "../directive-parsing.js";
import { normalizeQueueDropPolicy, normalizeQueueMode } from "./normalize.js";
import type { QueueDropPolicy, QueueMode } from "./types.js";
import type { QueueDropPolicy } from "./types.js";
/** Parses debounce durations in `/queue` directives. */
function parseQueueDebounce(raw?: string): number | undefined {
+2 -1
View File
@@ -1,6 +1,7 @@
// Normalizes queue config values from user and persisted settings.
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import type { QueueDropPolicy, QueueMode } from "./types.js";
import type { QueueMode } from "../../../../packages/gateway-protocol/src/schema/logs-chat.js";
import type { QueueDropPolicy } from "./types.js";
/** Normalizes user-entered queue mode aliases from directives/config. */
export function normalizeQueueMode(raw?: string): QueueMode | undefined {
+1 -1
View File
@@ -1,5 +1,6 @@
// Tracks queue state for active, pending, and recently deduped reply runs.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { QueueMode } from "../../../../packages/gateway-protocol/src/schema/logs-chat.js";
import type { ModelFallbackRouteResolution } from "../../../agents/model-fallback.types.js";
import { resolveGlobalMap } from "../../../shared/global-singleton.js";
import { applyQueueRuntimeSettings } from "../../../utils/queue-helpers.js";
@@ -13,7 +14,6 @@ import {
completeFollowupRunLifecycle,
type FollowupRun,
type QueueDropPolicy,
type QueueMode,
type QueueSettings,
} from "./types.js";
+1 -2
View File
@@ -1,5 +1,6 @@
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
// Shared queue type contracts for admission, drain, and fallback handling.
import type { QueueMode } from "../../../../packages/gateway-protocol/src/schema/logs-chat.js";
import type { AutoFallbackPrimaryProbe } from "../../../agents/agent-scope.js";
import type { ExecToolDefaults } from "../../../agents/bash-tools.js";
import type { CliSessionBindingFacts } from "../../../agents/cli-runner/types.js";
@@ -28,8 +29,6 @@ import type { ThinkingCatalogEntry } from "../../thinking.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../directives.js";
import { releaseRecentQueueMessageId } from "./recent-message-ids.js";
export type QueueMode = "steer" | "followup" | "collect" | "interrupt";
export type QueueDropPolicy = "old" | "new" | "summarize";
export type QueueSettings = {
@@ -1,8 +1,7 @@
import type { SessionRunStatus } from "../../../packages/gateway-protocol/src/schema/sessions-row.js";
import type { SessionRestartRecoveryState } from "./restart-recovery-types.js";
import type { InternalSessionEntry as SessionEntry } from "./types.js";
type SessionRunStatus = "running" | "done" | "failed" | "killed" | "timeout";
/** Authoritative lifecycle snapshot required for an atomic transcript admission. */
export type SessionTranscriptTurnExpectedState = {
abortedLastRun: boolean | undefined;
+4 -2
View File
@@ -6,6 +6,8 @@ import type {
SessionAcpMeta,
} from "@openclaw/acp-core/types";
import { normalizeOptionalString, type FastMode } from "@openclaw/normalization-core/string-coerce";
import type { QueueMode } from "../../../packages/gateway-protocol/src/schema/logs-chat.js";
import type { SessionRunStatus } from "../../../packages/gateway-protocol/src/schema/sessions-row.js";
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-icon.js";
import type { ChatType } from "../../channels/chat-type.js";
@@ -420,7 +422,7 @@ type SessionEntryCore = SessionRestartRecoveryState &
/** Accumulated runtime across subagent follow-up runs, persisted after completion. */
runtimeMs?: number;
/** Final persisted subagent run status, used after in-memory run archival. */
status?: "running" | "done" | "failed" | "killed" | "timeout";
status?: SessionRunStatus;
/** Compact user-facing reason for the latest failed or timed-out run. */
lastRunError?: string;
/**
@@ -512,7 +514,7 @@ type SessionEntryCore = SessionRestartRecoveryState &
groupActivation?: "mention" | "always";
groupActivationNeedsSystemIntro?: boolean;
sendPolicy?: "allow" | "deny";
queueMode?: "steer" | "followup" | "collect" | "interrupt";
queueMode?: QueueMode;
queueDebounceMs?: number;
queueCap?: number;
queueDrop?: "old" | "new" | "summarize";
+2 -1
View File
@@ -1,5 +1,6 @@
// Defines message queue and delivery configuration types.
import type { QueueDropPolicy, QueueMode, QueueModeByProvider } from "./types.queue.js";
import type { QueueMode } from "../../packages/gateway-protocol/src/schema/logs-chat.js";
import type { QueueDropPolicy, QueueModeByProvider } from "./types.queue.js";
export type MentionPatternsMode = "allow" | "deny";
+3 -2
View File
@@ -1,5 +1,6 @@
/** Queue handling mode for inbound channel messages. */
export type QueueMode = "steer" | "followup" | "collect" | "interrupt";
import type { QueueMode } from "../../packages/gateway-protocol/src/schema/logs-chat.js";
/** Queue overflow policy for inbound channel messages. */
export type QueueDropPolicy = "old" | "new" | "summarize";
export type QueueModeByProvider = {
@@ -9,8 +9,8 @@ import {
formatValidationErrors,
validateChatSendParams,
} from "../../../packages/gateway-protocol/src/index.js";
import type { QueueMode } from "../../../packages/gateway-protocol/src/schema/logs-chat.js";
import { isBtwRequestText } from "../../auto-reply/reply/btw-command.js";
import type { QueueMode } from "../../auto-reply/reply/queue/types.js";
import type { InputProvenance } from "../../sessions/input-provenance.js";
import { normalizeInputProvenance } from "../../sessions/input-provenance.js";
import { isBrowserCopilotClient, isOperatorUiClient } from "../../utils/message-channel.js";
+2 -1
View File
@@ -1,6 +1,7 @@
// Gateway session lifecycle state projection.
// Converts agent run lifecycle events into session row/store status updates.
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { SessionRunStatus } from "../../packages/gateway-protocol/src/schema/sessions-row.js";
import { isAgentLifecycleYieldedWaiting } from "../agents/agent-lifecycle-parent-state.js";
import {
buildAgentRunTerminalOutcomeFromLifecycleEvent,
@@ -17,7 +18,7 @@ import { updateSessionEntry } from "../config/sessions/session-accessor.js";
import { getAgentEventLifecycleGeneration, type AgentEventPayload } from "../infra/agent-events.js";
import { parseCronRunScopeSuffix } from "../sessions/session-key-utils.js";
import { loadSessionEntry } from "./session-utils.js";
import type { GatewaySessionRow, SessionRunStatus } from "./session-utils.types.js";
import type { GatewaySessionRow } from "./session-utils.types.js";
type LifecyclePhase = "start" | "end" | "error";
+2 -4
View File
@@ -7,11 +7,12 @@ import type {
SessionPeerKind,
SessionPlacement,
SessionRow,
SessionRunStatus,
SessionSharingRole,
SessionVisibility,
} from "../../packages/gateway-protocol/src/index.js";
import type { QueueMode } from "../../packages/gateway-protocol/src/schema/logs-chat.js";
import type { SessionObserverDigest } from "../../packages/gateway-protocol/src/schema/sessions.js";
import type { QueueMode } from "../auto-reply/reply/queue/types.js";
import type { ChatType } from "../channels/chat-type.js";
import type {
SessionCompactionCheckpoint,
@@ -43,9 +44,6 @@ export type GatewaySessionsDefaults = {
thinkingDefault?: string;
};
/** Runtime status surfaced for the latest session run. */
export type SessionRunStatus = "running" | "done" | "failed" | "killed" | "timeout";
type SubagentRunState = "active" | "interrupted" | "historical";
type SessionCompactionCheckpointPreview = Pick<
+2 -3
View File
@@ -1,4 +1,5 @@
import type { DatabaseSync } from "node:sqlite";
import type { SessionRunStatus } from "../../packages/gateway-protocol/src/schema/sessions-row.js";
import {
ensureMemoryChunkProvenance,
ensureMemoryRecallMetadataSchema,
@@ -356,9 +357,7 @@ function migratedChatType(value: unknown): "direct" | "group" | "channel" | null
return null;
}
function migratedStatus(
value: unknown,
): "running" | "done" | "failed" | "killed" | "timeout" | null {
function migratedStatus(value: unknown): SessionRunStatus | null {
if (
value === "running" ||
value === "done" ||
+3 -4
View File
@@ -31,7 +31,6 @@ import {
PROTOCOL_VERSION,
} from "@openclaw/gateway-client/browser";
// Control UI module implements gateway behavior.
import { formatErrorMessage } from "@openclaw/normalization-core";
import {
CONTROL_UI_OWNER_BOOTSTRAP_PROFILE_HINT,
type ControlUiBootstrapProfileHint,
@@ -40,7 +39,7 @@ import {
BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES,
} from "../../../src/shared/device-bootstrap-profile.js";
import { redactToolDetail } from "../lib/browser-redact.ts";
import { formatUiError } from "../lib/format-error.ts";
import {
clearDeviceAuthToken,
loadDeviceAuthToken,
@@ -225,7 +224,7 @@ function getErrorName(err: unknown): string | undefined {
function isBrowserWebSocketSecurityError(err: unknown): boolean {
const name = getErrorName(err)?.toLowerCase();
const message = formatErrorMessage(err, { redact: redactToolDetail }).toLowerCase();
const message = formatUiError(err).toLowerCase();
return (
name === "securityerror" ||
message.includes("security error") ||
@@ -236,7 +235,7 @@ function isBrowserWebSocketSecurityError(err: unknown): boolean {
function formatBrowserWebSocketConstructorError(err: unknown, url: string): GatewayErrorInfo {
const securityError = isBrowserWebSocketSecurityError(err);
const browserMessage = formatErrorMessage(err, { redact: redactToolDetail });
const browserMessage = formatUiError(err);
const isPlaintextWs = url.trim().toLowerCase().startsWith("ws://");
const details = {
code: securityError
+10 -101
View File
@@ -1,7 +1,11 @@
export type UpdateAvailable = import("../../../src/infra/update-startup.js").UpdateAvailable;
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
import type { AgentsListResult as ProtocolAgentsListResult } from "../../../packages/gateway-protocol/src/schema/agents-models-skills.js";
import type { ChannelsStatusResult } from "../../../packages/gateway-protocol/src/schema/channels.js";
import type { QueueMode } from "../../../packages/gateway-protocol/src/schema/logs-chat.js";
import type { SessionRow } from "../../../packages/gateway-protocol/src/schema/sessions-row.js";
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
import type { PresenceEntry as ProtocolPresenceEntry } from "../../../packages/gateway-protocol/src/schema/snapshot.js";
import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-icon.js";
import type { SessionGoal } from "../../../src/config/sessions/types.js";
import type { CronJobBase } from "../../../src/cron/types-shared.js";
@@ -11,7 +15,6 @@ import type { FastModeSource } from "../../../src/shared/fast-mode.js";
import type {
GatewayAgentRuntime,
GatewayAgentRow as SharedGatewayAgentRow,
SessionBoardFace,
SessionsListResultBase,
SessionsPatchResultBase,
} from "../../../src/shared/session-types.js";
@@ -28,10 +31,9 @@ export type ChannelsPairingRequest =
import("../../../packages/gateway-protocol/src/index.js").ChannelsPairingRequest;
export type SessionVisibility =
import("../../../packages/gateway-protocol/src/index.js").SessionVisibility;
type SessionSharingRole =
import("../../../packages/gateway-protocol/src/index.js").SessionSharingRole;
export type SessionMembersListResult =
import("../../../packages/gateway-protocol/src/index.js").SessionMembersListResult;
export type { SessionRunStatus } from "../../../packages/gateway-protocol/src/schema/sessions-row.js";
export type ChannelsStatusSnapshot = ChannelsStatusResult;
export type ChannelUiMetaEntry = NonNullable<ChannelsStatusResult["channelMeta"]>[number];
export type ChannelAccountSnapshot = ChannelsStatusResult["channelAccounts"][string][number];
@@ -253,30 +255,7 @@ export type ConfigSchemaResponse = {
generatedAt: string;
};
export type PresenceEntry = {
deviceId?: string | null;
instanceId?: string | null;
host?: string | null;
ip?: string | null;
version?: string | null;
platform?: string | null;
deviceFamily?: string | null;
modelIdentifier?: string | null;
roles?: string[] | null;
scopes?: string[] | null;
mode?: string | null;
lastInputSeconds?: number | null;
reason?: string | null;
text?: string | null;
ts?: number | null;
user?: {
id: string;
email?: string | null;
name?: string | null;
avatarUrl?: string | null;
} | null;
watchedSessions?: string[] | null;
};
export type PresenceEntry = ProtocolPresenceEntry;
export type GatewaySessionsDefaults = {
modelProvider: string | null;
@@ -295,12 +274,7 @@ export type GatewayThinkingLevelOption = {
export type GatewayAgentRow = SharedGatewayAgentRow;
export type AgentsListResult = {
defaultId: string;
mainKey: string;
scope: string;
agents: GatewayAgentRow[];
};
export type AgentsListResult = ProtocolAgentsListResult;
export type AgentIdentityResult = {
agentId: string;
@@ -417,7 +391,6 @@ export type ArtifactDownloadResult = {
expiresAt?: string;
};
export type SessionRunStatus = "running" | "done" | "failed" | "killed" | "timeout";
type SubagentRunState = "active" | "interrupted" | "historical";
type SessionCompactionCheckpointReason =
@@ -452,68 +425,19 @@ type SessionCompactionCheckpointPreview = Pick<
"checkpointId" | "createdAt" | "reason"
>;
export type GatewaySessionRow = {
key: string;
classification?: import("../../../packages/gateway-protocol/src/index.js").SessionClassification;
agentId?: string;
accountId?: string;
peerKind?: import("../../../packages/gateway-protocol/src/index.js").SessionPeerKind;
isMain?: boolean;
isBackground?: boolean;
visibility?: SessionVisibility;
sharingRole?: SessionSharingRole;
incognito?: true;
spawnedBy?: string;
controlOwnerSessionKey?: string;
/** Collector swarm group that owns this child session, when applicable. */
swarmGroupId?: string;
parentSessionKey?: string;
/** Managed worktree bound to this session (repo checkout + branch). */
worktree?: { id: string; branch: string; repoRoot: string };
/** Session-scoped exec node binding (exec host=node routing). */
execNode?: string;
spawnedWorkspaceDir?: string;
spawnedCwd?: string;
execCwd?: string;
forkedFromParent?: boolean;
spawnDepth?: number;
subagentRole?: "orchestrator" | "leaf";
subagentControlScope?: "children" | "none";
createdVia?: "operator" | "spawn" | "channel" | "cron" | "talk" | "run" | "plugin" | "internal";
createdActor?: import("../../../packages/gateway-protocol/src/schema/sessions.js").SessionCreatedActor;
createdAt?: number;
forkSource?: { sessionKey: string; sessionId: string; entryId?: string };
previousSessionId?: string;
export type GatewaySessionRow = SessionRow & {
placement?: import("../../../packages/gateway-protocol/src/index.js").SessionPlacement;
kind: "direct" | "group" | "global" | "unknown";
label?: string;
/** User-defined organization bucket; unrelated to chat-group kind/groupChannel. */
category?: string;
/** Preferred Control UI face for generic session navigation. */
boardFace?: SessionBoardFace;
displayName?: string;
derivedTitle?: string;
channel?: string;
surface?: string;
subject?: string;
room?: string;
space?: string;
updatedAt: number | null;
unread?: boolean;
lastReadAt?: number;
agentStatus?: SessionAgentStatus;
observerDigest?: Pick<
SessionObserverDigest,
"agentId" | "runId" | "headline" | "health" | "updatedAt" | "revision"
>;
lastActivityAt?: number;
archived?: boolean;
archivedAt?: number;
archivedBy?: import("../../../packages/gateway-protocol/src/schema/sessions.js").SessionCreatedActor;
pinned?: boolean;
pinnedAt?: number;
icon?: string;
sessionId?: string;
systemSent?: boolean;
abortedLastRun?: boolean;
thinkingLevel?: string;
@@ -521,25 +445,14 @@ export type GatewaySessionRow = {
thinkingOptions?: string[];
thinkingDefault?: string;
fastMode?: FastMode;
toolOverrides?: import("../lib/sessions/patch.js").SessionToolOverrides;
effectiveFastMode?: FastMode;
effectiveFastModeSource?: FastModeSource;
fastAutoOnSeconds?: number;
verboseLevel?: string;
reasoningLevel?: string;
elevatedLevel?: string;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
totalTokensFresh?: boolean;
estimatedCostUsd?: number;
status?: SessionRunStatus;
/** Compact user-facing reason for the latest failed or timed-out run. */
lastRunError?: string;
hasActiveRun?: boolean;
activeRunIds?: string[];
/** Active transcript-branch leaf returned with chat history. */
activeLeafEntryId?: string | null;
/** An enabled cron job is bound to this session (runs in it or delivers to it). */
hasAutomation?: boolean;
subagentRunState?: SubagentRunState;
@@ -549,15 +462,11 @@ export type GatewaySessionRow = {
runtimeMs?: number;
/** UI-local timestamp for the runtimeMs sample; absent on raw Gateway rows. */
runtimeSampledAt?: number;
childSessions?: string[];
model?: string;
modelProvider?: string;
modelSelectionLocked?: boolean;
effectiveResponseUsage?: "on" | "off" | "tokens" | "full";
queueMode?: "steer" | "followup" | "collect" | "interrupt";
effectiveQueueMode?: "steer" | "followup" | "collect" | "interrupt";
queueMode?: QueueMode;
effectiveQueueMode?: QueueMode;
agentRuntime?: GatewayAgentRuntime;
contextTokens?: number;
compactionCheckpointCount?: number;
latestCompactionCheckpoint?: SessionCompactionCheckpointPreview;
goal?: SessionGoal;
+2 -8
View File
@@ -1,6 +1,7 @@
import { promoteToPopoverTopLayer } from "../components/menu-surface.ts";
import { NativeLinkMenu, type NativeLinkMenuAction } from "../components/native-link-menu.ts";
import { copyToClipboard } from "../lib/clipboard.ts";
import { shouldHandleNavigationClick } from "../lib/navigation-click.ts";
type NativeLinkTarget = "inline" | "external";
@@ -179,14 +180,7 @@ export function startNativeLinkRouting(): NativeLinkRouting {
};
const handleClick = (event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
if (!shouldHandleNavigationClick(event)) {
return;
}
const appLink = trustedExternalAppUrl(event);
+5 -5
View File
@@ -10,9 +10,9 @@ import {
describe("connection user profile helpers", () => {
it("resolves identity only from the current live presence entry", () => {
const entries = [
{ instanceId: "other", user: { id: "other-profile", name: "Other" } },
{ instanceId: "self", user: { id: "old", name: "Old" }, reason: "disconnect" },
{ instanceId: "self", user: { id: "profile-1", name: "Ada" } },
{ instanceId: "other", user: { id: "other-profile", name: "Other" }, ts: 1 },
{ instanceId: "self", user: { id: "old", name: "Old" }, reason: "disconnect", ts: 2 },
{ instanceId: "self", user: { id: "profile-1", name: "Ada" }, ts: 3 },
];
expect(resolveSelfPresenceUser(entries, "self")).toEqual({ id: "profile-1", name: "Ada" });
@@ -21,7 +21,7 @@ describe("connection user profile helpers", () => {
});
it("prefers locally refreshed identity state over the presence snapshot", () => {
const presenceEntries = [{ instanceId: "self", user: { id: "profile-1", name: "Ada" } }];
const presenceEntries = [{ instanceId: "self", user: { id: "profile-1", name: "Ada" }, ts: 1 }];
expect(
resolveCurrentSelfUser({
@@ -44,7 +44,7 @@ describe("connection user profile helpers", () => {
});
it("reads presence payloads and builds scoped cache-busted avatar URLs", () => {
const entries = [{ instanceId: "self", user: { id: "profile/1" } }];
const entries = [{ instanceId: "self", user: { id: "profile/1" }, ts: 1 }];
expect(readPresenceEntries({ presence: entries })).toEqual(entries);
expect(readPresenceEntries({ presence: null })).toBeUndefined();
expect(
+2 -3
View File
@@ -1,7 +1,6 @@
// Application-owned browser push subscription lifecycle.
import { formatErrorMessage } from "@openclaw/normalization-core";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import { redactToolDetail } from "../lib/browser-redact.ts";
import { formatUiError } from "../lib/format-error.ts";
import type { ApplicationGateway } from "./gateway.ts";
type WebPushSnapshot = {
@@ -89,7 +88,7 @@ export function createWebPushCapability(gateway: ApplicationGateway): WebPushCap
publish({ loading: true, error: null });
operation = action(client)
.catch((error: unknown) => {
publish({ error: formatErrorMessage(error, { redact: redactToolDetail }) });
publish({ error: formatUiError(error) });
})
.finally(() => {
operation = null;
+2 -12
View File
@@ -16,6 +16,7 @@ import {
} from "../app-navigation.ts";
import { pathForRoute } from "../app-route-paths.ts";
import { t } from "../i18n/index.ts";
import { shouldHandleNavigationClick } from "../lib/navigation-click.ts";
import { pluginTabSearch } from "../pages/plugin/route.ts";
import type { SidebarWorkboardBoard, SidebarWorkboardRenderers } from "./app-sidebar-workboard.ts";
import { icons, type IconName } from "./icons.ts";
@@ -23,18 +24,6 @@ import { consumeDropdownKeyboardDismissal, trackDropdownKeyboardDismissal } from
type SidebarMenuPosition = { x: number; y: number };
/** Ordinary primary click without modifiers; anything else keeps native link behavior. */
export function shouldHandleNavigationClick(event: MouseEvent): boolean {
return (
!event.defaultPrevented &&
event.button === 0 &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
);
}
/** Settings routes highlight Settings; hub tabs highlight their hub entry. */
export function isSidebarRouteActive(
activeRouteId: NavigationRouteId | undefined,
@@ -160,6 +149,7 @@ function renderMoreMenuRoute(params: SidebarMoreMenuParams, routeId: SidebarNavR
@pointerleave=${params.onCancelPreload}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
// wa-select also fires for native clicks; mark them so it does not add SPA navigation.
(event.currentTarget as HTMLElement).dataset.nativeNavigation = "true";
return;
}
+2 -1
View File
@@ -13,6 +13,7 @@ import { t } from "../i18n/index.ts";
import { normalizeAgentLabel, resolveAgentTextAvatar } from "../lib/agents/display.ts";
import { deriveAvatarInitial, resolveAgentAvatarUrl } from "../lib/avatar.ts";
import { sessionHasBoard } from "../lib/board/provider.ts";
import { shouldHandleNavigationClick } from "../lib/navigation-click.ts";
import {
resolveSessionPreferredFace,
sessionNavigationTarget,
@@ -23,7 +24,7 @@ import {
parseAgentSessionKey,
} from "../lib/sessions/session-key.ts";
import { pluginTabKey } from "../pages/plugin/route.ts";
import { renderSidebarPluginTab, shouldHandleNavigationClick } from "./app-sidebar-nav-menus.ts";
import { renderSidebarPluginTab } from "./app-sidebar-nav-menus.ts";
import type { AppSidebarSessionNavigationElement } from "./app-sidebar-session-navigation.ts";
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
import type { SidebarWorkboardBoard } from "./app-sidebar-workboard.ts";
@@ -8,6 +8,7 @@ import type { GatewaySessionRow } from "../api/types.ts";
import type { NavigationRouteId } from "../app-navigation.ts";
import type { ApplicationNavigationOptions } from "../app/context.ts";
import { t } from "../i18n/index.ts";
import { shouldHandleNavigationClick } from "../lib/navigation-click.ts";
import type { CatalogSessionKey } from "../lib/sessions/catalog-key.ts";
import { buildCatalogSessionKey } from "../lib/sessions/catalog-key.ts";
import {
@@ -17,7 +18,6 @@ import {
} from "../lib/sessions/catalog-project-grouping.ts";
import { sessionNavigationTarget } from "../lib/sessions/route-navigation.ts";
import type { NewSessionTarget } from "../pages/new-session/location.ts";
import { shouldHandleNavigationClick } from "./app-sidebar-nav-menus.ts";
import {
formatSidebarTimestamp,
type CatalogBackingSessionDisplay,
@@ -5,6 +5,7 @@ import { serializeSidebarEntry } from "../app-navigation.ts";
import { isSessionRouteId } from "../app-route-paths.ts";
import { t } from "../i18n/index.ts";
import { listSelectableAgents } from "../lib/agents/display.ts";
import { shouldHandleNavigationClick } from "../lib/navigation-click.ts";
import { isCronSessionKey, resolveSessionDisplayName } from "../lib/session-display.ts";
import type { SidebarSessionsGrouping } from "../lib/sessions/grouping.ts";
import {
@@ -107,8 +108,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
private readonly runtimeSampledAtByRow = new WeakMap<GatewaySessionRow, number>();
private readonly attention = new SessionAttentionController(this);
// These controllers initialize on AppSidebar after the navigation-owned
// controllers, matching the former inheritance-chain field order.
// Controller order preserves the former inheritance-chain field initialization order.
declare readonly sessionOrganizer: SessionOrganizerController;
declare readonly sidebarMenus: SidebarMenusController;
@@ -200,8 +200,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
void this.sessionData.loadChildSessions(session.key);
}
}
// The main session hides behind the identity card, so nothing in the list
// triggers its child fetch; load eagerly or its threads never surface.
// The hidden main row needs an eager child fetch or its threads never surface.
const mainRow = this.mainSessionRow();
if (
mainRow &&
@@ -373,20 +372,16 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
}
handleSessionRowClick(event: MouseEvent, session: SidebarRecentSession) {
if (event.defaultPrevented || event.button !== 0) {
return;
}
if (session.isChild) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
if (session.isChild && shouldHandleNavigationClick(event)) {
event.preventDefault();
this.clearSessionSelection();
this.selectSession(session.key);
return;
}
// Cmd/Ctrl and Shift clicks build the multi-select instead of the browser's
// open-in-new-tab default; middle-click still opens the row in a new tab.
if (session.isChild || event.defaultPrevented || event.button !== 0) {
return;
}
// Modified parent clicks build multi-select; middle-click keeps native new-tab behavior.
if (event.metaKey || event.ctrlKey) {
event.preventDefault();
this.toggleSessionSelected(session.key);
@@ -2,6 +2,7 @@ import { html } from "lit";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import { pathForWorkboardBoard } from "../app-route-paths.ts";
import { t } from "../i18n/index.ts";
import { shouldHandleNavigationClick } from "../lib/navigation-click.ts";
import { workboardBoardLabel } from "../lib/workboard/board-presentation.ts";
import { normalizeBoardsPayload } from "../lib/workboard/normalization.ts";
import { getWorkboardState } from "../lib/workboard/runtime.ts";
@@ -191,14 +192,7 @@ export const renderSidebarWorkboardEntry: SidebarWorkboardRenderers["renderEntry
class="nav-item nav-item--workboard-board ${params.active ? "nav-item--active" : ""}"
aria-current=${params.active ? "page" : undefined}
@click=${(event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
+3 -1
View File
@@ -1,3 +1,5 @@
import type { SessionRunStatus } from "../../../packages/gateway-protocol/src/schema/sessions-row.js";
export type LobsterPetMode = "idle" | "busy" | "offline";
export type LobsterRunOutcome = "ok" | "error" | "aborted";
@@ -125,7 +127,7 @@ export function lobsterPetSeed(sessionKey: string): number {
export function resolveLobsterRunOutcome(
sessions:
| ReadonlyArray<{
status?: "running" | "done" | "failed" | "killed" | "timeout";
status?: SessionRunStatus;
endedAt?: number | null;
lastActivityAt?: number | null;
updatedAt?: number | null;
+3 -16
View File
@@ -17,6 +17,7 @@ import {
import { pathForRoute, type RouteId } from "../app-route-paths.ts";
import type { ApplicationNavigationOptions } from "../app/context.ts";
import { t } from "../i18n/index.ts";
import { shouldHandleNavigationClick } from "../lib/navigation-click.ts";
import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts";
import { icons } from "./icons.ts";
import { redactLoginFailureError } from "./login-gate.ts";
@@ -163,14 +164,7 @@ function renderItem(props: SettingsSidebarProps, routeId: RouteId, label?: strin
@touchstart=${(event: TouchEvent) =>
scheduleRoutePreload(props.preloadTimers, routeId, event, props.onPreload, active, true)}
@click=${(event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
@@ -201,14 +195,7 @@ function renderBlockItem(props: SettingsSidebarProps, block: SettingsSearchBlock
class="settings-sidebar__subitem ${active ? "settings-sidebar__subitem--active" : ""}"
aria-current=${active ? "location" : nothing}
@click=${(event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
+1 -12
View File
@@ -3,6 +3,7 @@ import { property } from "lit/decorators.js";
import { pathForRoute } from "../app-route-paths.ts";
import { CONTROL_UI_BUILD_INFO } from "../build-info.ts";
import { t } from "../i18n/index.ts";
import { shouldHandleNavigationClick } from "../lib/navigation-click.ts";
import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
import {
formatBuildChipText,
@@ -11,18 +12,6 @@ import {
} from "./sidebar-build-chip-format.ts";
import "./tooltip.ts";
function shouldHandleNavigationClick(event: MouseEvent): boolean {
// Preserve browser behavior for modified clicks and non-primary buttons.
return (
!event.defaultPrevented &&
event.button === 0 &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
);
}
class SidebarBuildChip extends OpenClawLightDomContentsElement {
@property({ attribute: false }) basePath = "";
@property({ attribute: false }) gatewayVersion: string | null = null;
+1 -1
View File
@@ -1,5 +1,5 @@
import type { QueueMode } from "../../../../packages/gateway-protocol/src/schema/logs-chat.js";
import { normalizeQueueMode } from "../../../../src/auto-reply/reply/queue/normalize.js";
import type { QueueMode } from "../../../../src/auto-reply/reply/queue/types.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../../../src/utils/message-channel-constants.js";
import { normalizeChatFollowUpModeOverride, type ChatFollowUpMode } from "../../app/settings.js";
+21
View File
@@ -0,0 +1,21 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { formatUiError } from "./format-error.ts";
describe("formatUiError", () => {
it("formats structured causes through the browser-safe redactor", () => {
const cause = Object.assign(new Error("OPENAI_API_KEY=sk-1234567890abcdef"), {
code: "AUTH_FAILED",
});
expect(formatUiError(new Error("request failed", { cause }))).toBe(
"request failed | OPENAI_API_KEY=sk-123...cdef | AUTH_FAILED",
);
});
it("uses the fallback only when formatting produces an empty message", () => {
expect(formatUiError("", "Request failed")).toBe("Request failed");
expect(formatUiError("")).toBe("");
expect(formatUiError(undefined, "Request failed")).toBe("undefined");
});
});
+6
View File
@@ -0,0 +1,6 @@
import { formatErrorMessage } from "@openclaw/normalization-core";
import { redactToolDetail } from "./browser-redact.ts";
export function formatUiError(error: unknown, fallback = ""): string {
return formatErrorMessage(error, { redact: redactToolDetail }) || fallback;
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { shouldHandleNavigationClick } from "./navigation-click.ts";
function clickEvent(overrides: Partial<MouseEvent> = {}): MouseEvent {
return {
defaultPrevented: false,
button: 0,
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
...overrides,
} as MouseEvent;
}
const nativeBehaviorCases = [
["a prevented click", { defaultPrevented: true }],
["a non-primary click", { button: 1 }],
["a Meta-modified click", { metaKey: true }],
["a Control-modified click", { ctrlKey: true }],
["a Shift-modified click", { shiftKey: true }],
["an Alt-modified click", { altKey: true }],
] satisfies Array<[string, Partial<MouseEvent>]>;
describe("shouldHandleNavigationClick", () => {
it("handles an ordinary primary click", () => {
expect(shouldHandleNavigationClick(clickEvent())).toBe(true);
});
it.each(nativeBehaviorCases)("preserves native behavior for %s", (_, event) => {
expect(shouldHandleNavigationClick(clickEvent(event))).toBe(false);
});
});
+11
View File
@@ -0,0 +1,11 @@
/** Ordinary primary click without modifiers; anything else keeps native link behavior. */
export function shouldHandleNavigationClick(event: MouseEvent): boolean {
return (
!event.defaultPrevented &&
event.button === 0 &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
);
}
+14 -7
View File
@@ -102,7 +102,7 @@ describe("buildNodesInventory", () => {
}),
],
nodes: [],
presence: [{ instanceId: "BROWSER-1", reason: "disconnect" }],
presence: [{ instanceId: "BROWSER-1", reason: "disconnect", ts: 1_000 }],
});
expect(firstGroup(groups).primary.connected).toBe(true);
@@ -304,8 +304,10 @@ describe("listStaleInventoryEntries", () => {
describe("findGatewayPresence", () => {
it("returns the Gateway self beacon", () => {
const gateway = { instanceId: "gateway-1", mode: " GATEWAY " };
expect(findGatewayPresence([{ instanceId: "node-1", mode: "node" }, gateway])).toBe(gateway);
const gateway = { instanceId: "gateway-1", mode: " GATEWAY ", ts: 2_000 };
expect(findGatewayPresence([{ instanceId: "node-1", mode: "node", ts: 1_000 }, gateway])).toBe(
gateway,
);
});
});
@@ -315,11 +317,16 @@ describe("listUnpairedPresence", () => {
paired: [device({ deviceId: "node-1", displayName: "megaclaw" })],
nodes: [],
});
const joined = { deviceId: "NODE-1", mode: "node" };
const gateway = { instanceId: "gateway-1", mode: "gateway" };
const disconnected = { instanceId: "left-1", mode: "webchat", reason: "disconnect" };
const joined = { deviceId: "NODE-1", mode: "node", ts: 1_000 };
const gateway = { instanceId: "gateway-1", mode: "gateway", ts: 2_000 };
const disconnected = {
instanceId: "left-1",
mode: "webchat",
reason: "disconnect",
ts: 3_000,
};
const textOnly = { text: "note from test", ts: 1_000 };
const live = { instanceId: "webchat-1", mode: "webchat", host: "browser" };
const live = { instanceId: "webchat-1", mode: "webchat", host: "browser", ts: 4_000 };
expect(listUnpairedPresence([joined, gateway, disconnected, textOnly, live], groups)).toEqual([
live,
+1 -1
View File
@@ -1385,7 +1385,7 @@ describe("reconcileSkillsAgentId", () => {
reconcileSkillsAgentId(state, {
defaultId: "main",
mainKey: "main",
scope: "project",
scope: "per-sender",
agents: [{ id: "main" }],
});
+7 -11
View File
@@ -1,4 +1,3 @@
import { formatErrorMessage } from "@openclaw/normalization-core";
import {
ClawHubTrustErrorCodes,
readClawHubTrustErrorDetails,
@@ -10,7 +9,7 @@ import type {
SkillStatusEntry,
SkillStatusReport,
} from "../../api/types.ts";
import { redactToolDetail } from "../browser-redact.ts";
import { formatUiError } from "../format-error.ts";
import type { ClawHubSearchResult } from "./clawhub-search.ts";
import {
normalizeSkillApiKeyReplacement,
@@ -335,7 +334,7 @@ export async function loadSkills(
if (!isCurrent()) {
return;
}
state.skillsError = formatErrorMessage(err, { redact: redactToolDetail });
state.skillsError = formatUiError(err);
} finally {
// A transient disconnect invalidates the result, not this invocation's
// loading ownership. Source/scope identity still protects newer loads.
@@ -448,7 +447,7 @@ export async function loadSkillCard(state: SkillsState, skillKey: string) {
if (isSkillsAgentScopeCurrent(state, agentScope)) {
state.skillCardErrors = {
...state.skillCardErrors,
[skillKey]: formatErrorMessage(err, { redact: redactToolDetail }),
[skillKey]: formatUiError(err),
};
}
} finally {
@@ -493,7 +492,7 @@ async function loadClawHubSecurityVerdicts(state: SkillsState, report: SkillStat
return;
}
state.clawhubVerdicts = {};
state.clawhubVerdictsError = formatErrorMessage(err, { redact: redactToolDetail });
state.clawhubVerdictsError = formatUiError(err);
} finally {
if (isSkillsAgentScopeCurrent(state, agentScope)) {
state.clawhubVerdictsLoading = false;
@@ -546,7 +545,7 @@ async function runSkillMutation(
) {
return;
}
const message = formatErrorMessage(err, { redact: redactToolDetail });
const message = formatUiError(err);
state.skillsError = message;
setSkillMessage(state, skillKey, {
kind: "error",
@@ -659,7 +658,7 @@ export async function loadClawHubDetail(state: SkillsState, slug: string) {
state.clawhubDetail = res ?? null;
},
(err) => {
state.clawhubDetailError = formatErrorMessage(err, { redact: redactToolDetail });
state.clawhubDetailError = formatUiError(err);
},
() => {
state.clawhubDetailLoading = false;
@@ -725,10 +724,7 @@ export async function installFromClawHub(
kind: "error",
text: needsAcknowledgement
? formatClawHubAcknowledgementMessage(trustDetails?.warning)
: formatClawHubInstallMessage(
formatErrorMessage(err, { redact: redactToolDetail }),
trustDetails?.warning,
),
: formatClawHubInstallMessage(formatUiError(err), trustDetails?.warning),
...(needsAcknowledgement ? { acknowledgeSlug: slug } : {}),
...(needsAcknowledgement && trustDetails?.version
? { acknowledgeVersion: trustDetails.version }
+1 -1
View File
@@ -680,7 +680,7 @@ describe("AgentsPage gateway lifecycle", () => {
it("preserves matching initial route data, then resets it on provider replacement", () => {
const client = {} as GatewayBrowserClient;
const currentGateway = gateway(snapshot(client, false));
const preloadedAgents = {
const preloadedAgents: AgentsListResult = {
defaultId: "main",
mainKey: "main",
scope: "per-sender",
@@ -21,7 +21,7 @@ export function createAgentViewTestProps(
agentsList: {
defaultId: "alpha",
mainKey: "main",
scope: "workspace",
scope: "per-sender",
agents: [{ id: "alpha", name: "Alpha" } as never, { id: "beta", name: "Beta" } as never],
},
selectedAgentId: "beta",
+3 -4
View File
@@ -1,10 +1,9 @@
// Agent identity draft state and persistence, split out of agents-page.ts.
import { formatErrorMessage } from "@openclaw/normalization-core";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ApplicationContext, ApplicationNavigationPreferences } from "../../app/context.ts";
import { t } from "../../i18n/index.ts";
import { updateAgentIdentity } from "../../lib/agents/index.ts";
import { redactToolDetail } from "../../lib/browser-redact.ts";
import { formatUiError } from "../../lib/format-error.ts";
import { fileToAvatarDataUrl } from "./avatar-image.ts";
import type { AgentIdentityDraft } from "./panels-overview.ts";
@@ -104,14 +103,14 @@ export async function saveIdentityDraft(params: {
await agents.refreshList();
} catch (error) {
refreshErrors.push(
`Agent identity was saved, but the agent list refresh failed: ${formatErrorMessage(error, { redact: redactToolDetail })}`,
`Agent identity was saved, but the agent list refresh failed: ${formatUiError(error)}`,
);
}
try {
await agentIdentity.ensure([agentId]);
} catch (error) {
refreshErrors.push(
`Agent identity was saved, but the identity refresh failed: ${formatErrorMessage(error, { redact: redactToolDetail })}`,
`Agent identity was saved, but the identity refresh failed: ${formatUiError(error)}`,
);
}
if (params.isCurrent()) {
+1 -1
View File
@@ -549,7 +549,7 @@ describe("renderAgents", () => {
agentsList: {
defaultId: "alpha",
mainKey: "main",
scope: "workspace",
scope: "per-sender",
agents: [
{ id: "alpha", name: "Alpha", thinkingDefault: "off" } as never,
{ id: "beta", name: "Beta", thinkingDefault: "xhigh" } as never,
@@ -1,4 +1,3 @@
import { formatErrorMessage } from "@openclaw/normalization-core";
import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import { html, nothing } from "lit";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
@@ -18,7 +17,6 @@ import {
buildToolsEffectiveRequestKey,
loadToolsEffective,
} from "../../lib/agents/tools-effective.ts";
import { redactToolDetail } from "../../lib/browser-redact.ts";
import {
buildAddMcpServerPatch,
MCP_SERVER_NAME_PATTERN,
@@ -26,6 +24,7 @@ import {
patchMcpServers,
summarizeMcpServers,
} from "../../lib/config/mcp-servers.ts";
import { formatUiError } from "../../lib/format-error.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import { readSessionMethodAccess } from "../../lib/session-method-access.ts";
import {
@@ -39,22 +38,8 @@ import { loadSkillStatusReport } from "../../lib/skills/index.ts";
import { refreshCurrentChatSessionList } from "./chat-session.ts";
import { patchChatSessionSettings } from "./chat-settings-patches.ts";
import type { ChatPageHost } from "./chat-state-host.ts";
import type {
ChatComposerMenuSkill,
ChatComposerPlusMenuProps,
} from "./components/chat-composer-plus-menu.ts";
type CapabilityMenuProps = Omit<
ChatComposerPlusMenuProps,
| "attachments"
| "disabled"
| "open"
| "view"
| "toolOverrides"
| "onOpenChange"
| "onViewChange"
| "showCapabilities"
>;
import type { ChatComposerMenuSkill } from "./components/chat-composer-plus-menu.ts";
import type { CapabilityMenuProps } from "./components/chat-composer-types.ts";
type ComposerMcpServerScope = "session" | "everywhere";
@@ -130,7 +115,7 @@ export class ChatComposerCapabilityHost {
} catch (error) {
return {
ok: false,
error: formatErrorMessage(error, { redact: redactToolDetail }),
error: formatUiError(error),
stage: "config",
};
}
@@ -146,7 +131,7 @@ export class ChatComposerCapabilityHost {
} catch (error) {
return {
ok: false,
error: formatErrorMessage(error, { redact: redactToolDetail }),
error: formatUiError(error),
stage: "session",
};
}
@@ -166,7 +151,7 @@ export class ChatComposerCapabilityHost {
} catch (error) {
return {
ok: false,
error: formatErrorMessage(error, { redact: redactToolDetail }),
error: formatUiError(error),
stage: "session",
};
}
@@ -410,7 +395,7 @@ export class ChatComposerCapabilityHost {
} catch (error) {
return {
ok: false as const,
error: formatErrorMessage(error, { redact: redactToolDetail }),
error: formatUiError(error),
};
}
if (!identityMatches()) {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { QueueMode } from "../../../../src/auto-reply/reply/queue/types.js";
import type { QueueMode } from "../../../../packages/gateway-protocol/src/schema/logs-chat.js";
import { GatewayRequestError } from "../../api/gateway.ts";
import type { ChatAttachment } from "../../lib/chat/chat-types.ts";
import { canCallGatewayMethod } from "../../lib/gateway-methods.ts";
+4 -4
View File
@@ -5,7 +5,7 @@ import { expectDefined } from "@openclaw/normalization-core";
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { GatewayRequestError } from "../../api/gateway.ts";
import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts";
import type { AgentsListResult, GatewaySessionRow, SessionsListResult } from "../../api/types.ts";
import { SLASH_COMMANDS } from "../../lib/chat/commands.ts";
import { createResolvedModelPatch } from "../../test-helpers/chat-model.ts";
import { createStorageMock } from "../../test-helpers/storage.ts";
@@ -1916,7 +1916,7 @@ describe("handleSendChat", () => {
])("gates $sendKey on its default-main alias patch", async ({ patchKey, sendKey }) => {
const settingsPatch = createDeferred<boolean>();
const agentsList = {
const agentsList: AgentsListResult = {
defaultId: "ops",
mainKey: "work",
scope: "per-sender",
@@ -1951,7 +1951,7 @@ describe("handleSendChat", () => {
it("keeps a real main agent patch separate from a non-main default agent", async () => {
const settingsPatch = createDeferred<boolean>();
const agentsList = {
const agentsList: AgentsListResult = {
defaultId: "ops",
mainKey: "work",
scope: "per-sender",
@@ -8909,7 +8909,7 @@ describe("handleAbortChat", () => {
agentId: "work",
},
},
])("$name", async ({ scope, expected }) => {
] as const)("$name", async ({ scope, expected }) => {
const request = vi.fn(async () => ({ abortedRunId: null, status: "aborted" }));
const sessionKey = "agent:work:main";
const host = makeChatHost({
+5 -13
View File
@@ -42,8 +42,10 @@ import {
renderBackgroundTasksRail,
type BackgroundTasksProps,
} from "./components/chat-background-tasks.ts";
import type { ChatComposerPlusMenuProps } from "./components/chat-composer-plus-menu.ts";
import type { ChatComposerDisabledBanner } from "./components/chat-composer-types.ts";
import type {
CapabilityMenuProps,
ChatComposerDisabledBanner,
} from "./components/chat-composer-types.ts";
import { isChatRunWorking, renderChatComposer } from "./components/chat-composer.ts";
import { inlineChatImageFromEvent, openInlineChatImage } from "./components/chat-image-lightbox.ts";
import type { ArtifactDownloadResolver } from "./components/chat-message-media.ts";
@@ -166,17 +168,7 @@ export type ChatProps = {
onDismissWorkspaceConflict?: () => void;
sessions: SessionsListResult | null;
toolOverrides?: SessionToolOverrides;
capabilityMenu?: Omit<
ChatComposerPlusMenuProps,
| "attachments"
| "disabled"
| "open"
| "view"
| "toolOverrides"
| "onOpenChange"
| "onViewChange"
| "showCapabilities"
>;
capabilityMenu?: CapabilityMenuProps;
swarmSessions?: readonly GatewaySessionRow[];
/** Host context resolving global-alias session keys (scope=global fleets). */
sessionHost?: UiSessionDefaultsHost | null;
@@ -21,6 +21,18 @@ import type {
ChatComposerPlusMenuView,
} from "./chat-composer-plus-menu.ts";
export type CapabilityMenuProps = Omit<
ChatComposerPlusMenuProps,
| "attachments"
| "disabled"
| "open"
| "view"
| "toolOverrides"
| "onOpenChange"
| "onViewChange"
| "showCapabilities"
>;
type ChatComposerDisabledBannerContent = {
text: string;
actionLabel: string;
@@ -56,17 +68,7 @@ export type ChatComposerProps = {
draft: string;
sessions: SessionsListResult | null;
toolOverrides?: SessionToolOverrides;
capabilityMenu?: Omit<
ChatComposerPlusMenuProps,
| "attachments"
| "disabled"
| "open"
| "view"
| "toolOverrides"
| "onOpenChange"
| "onViewChange"
| "showCapabilities"
>;
capabilityMenu?: CapabilityMenuProps;
providerUsage?: ProviderUsageDisplayProps;
assistantName: string;
sendShortcut?: ChatSendShortcut;
+1 -1
View File
@@ -1,4 +1,4 @@
import type { QueueMode } from "../../../../src/auto-reply/reply/queue/types.js";
import type { QueueMode } from "../../../../packages/gateway-protocol/src/schema/logs-chat.js";
import type { SessionsListResult } from "../../api/types.ts";
import { setLastActiveSessionKey } from "../../app/settings.ts";
import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts";
+3 -4
View File
@@ -1,11 +1,10 @@
import { formatErrorMessage } from "@openclaw/normalization-core";
import { html, nothing, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import type { AgentsWorkspaceGetResult } from "../../../../packages/gateway-protocol/src/index.js";
import type { MemorySearchResponse } from "../../../../src/gateway/server-methods/memory-search.ts";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { t } from "../../i18n/index.ts";
import { redactToolDetail } from "../../lib/browser-redact.ts";
import { formatUiError } from "../../lib/format-error.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import "../../styles/memory-memories.css";
@@ -114,7 +113,7 @@ class MemoryMemoriesElement extends OpenClawLightDomElement {
this.searchState = {
kind: "error",
query: normalizedQuery,
message: formatErrorMessage(error, { redact: redactToolDetail }),
message: formatUiError(error),
};
}
}
@@ -159,7 +158,7 @@ class MemoryMemoriesElement extends OpenClawLightDomElement {
}
this.details = new Map(this.details).set(key, {
kind: "error",
message: formatErrorMessage(error, { redact: redactToolDetail }),
message: formatUiError(error),
});
} finally {
if (this.detailRequests.get(key) === request) {
+4 -8
View File
@@ -2,7 +2,6 @@
// this element owns the shared agent selection, Overview status, and global
// configuration controllers used by Settings.
import { consume } from "@lit/context";
import { formatErrorMessage } from "@openclaw/normalization-core";
import { asNullableRecord as asConfigRecord } from "@openclaw/normalization-core/record-coerce";
import { html, type PropertyValues, type TemplateResult } from "lit";
import { property, state } from "lit/decorators.js";
@@ -15,8 +14,8 @@ import type { AgentSelectOption } from "../../components/agent-select.ts";
import { renderDocsLink } from "../../components/settings-ui.ts";
import { t } from "../../i18n/index.ts";
import { listSelectableAgents, normalizeAgentLabel } from "../../lib/agents/display.ts";
import { redactToolDetail } from "../../lib/browser-redact.ts";
import { currentConfigObject } from "../../lib/config/index.ts";
import { formatUiError } from "../../lib/format-error.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import {
loadPluginCatalog,
@@ -383,7 +382,7 @@ class MemorySettingsPage extends OpenClawLightDomElement {
}
this.overviewStatus = {
kind: "error",
message: formatErrorMessage(error, { redact: redactToolDetail }),
message: formatUiError(error),
};
} finally {
if (this.overviewRequest === request) {
@@ -497,10 +496,7 @@ class MemorySettingsPage extends OpenClawLightDomElement {
}
} catch (error) {
if (this.connection === connection) {
this.addonErrors = new Map(this.addonErrors).set(
pluginId,
formatErrorMessage(error, { redact: redactToolDetail }),
);
this.addonErrors = new Map(this.addonErrors).set(pluginId, formatUiError(error));
}
} finally {
if (this.addonNoticeOperations.get(pluginId) === noticeOperation) {
@@ -553,7 +549,7 @@ class MemorySettingsPage extends OpenClawLightDomElement {
if (this.connection === connection) {
this.engineOutcome = {
kind: "error",
message: formatErrorMessage(error, { redact: redactToolDetail }),
message: formatUiError(error),
};
}
} finally {
@@ -24,6 +24,7 @@ import {
renderSettingsToggleRow,
} from "../../components/settings-ui.ts";
import { t } from "../../i18n/index.ts";
import { shouldHandleNavigationClick } from "../../lib/navigation-click.ts";
import { languageLabel, renderLanguageSelect } from "./language-select.ts";
import { renderSessionObserverSettings } from "./session-observer-settings.ts";
import { renderSettingsSelectRow } from "./settings-select-row.ts";
@@ -416,16 +417,11 @@ export function renderLobsterPetSection(props: ConfigProps) {
class="btn btn--sm lobsterdex__open"
href=${props.lobsterdexHref}
@click=${(event: MouseEvent) => {
if (
event.button === 0 &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
) {
event.preventDefault();
props.onOpenLobsterdex?.();
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
props.onOpenLobsterdex?.();
}}
>${t("quickSettings.appearance.lobsterdexOpen")}</a
>`
+1 -1
View File
@@ -1,5 +1,5 @@
import type { SystemInfoResult } from "../../../../packages/gateway-protocol/src/index.js";
import type { QueueMode } from "../../../../src/auto-reply/reply/queue/types.js";
import type { QueueMode } from "../../../../packages/gateway-protocol/src/schema/logs-chat.js";
import type { ConfigUiHints, ModelCatalogEntry } from "../../api/types.ts";
import type { NativeNotificationsPermission } from "../../app/native-notifications.ts";
import type { ServerUiPrefProvenance } from "../../app/server-prefs.ts";
+9 -1
View File
@@ -2169,7 +2169,15 @@ describe("config view", () => {
unseen?.closest("openclaw-tooltip")?.querySelector('[slot="content"]')?.textContent,
).toContain("Ripe when thumped.");
container.querySelector<HTMLAnchorElement>(".lobsterdex__open")?.click();
const openLink = container.querySelector<HTMLAnchorElement>(".lobsterdex__open");
openLink?.addEventListener("click", (event) => event.preventDefault(), {
capture: true,
once: true,
});
openLink?.click();
expect(onOpenLobsterdex).not.toHaveBeenCalled();
openLink?.click();
expect(onOpenLobsterdex).toHaveBeenCalledOnce();
} finally {
localStorage.removeItem("openclaw.control.lobsterdex.v1");
+2 -8
View File
@@ -16,6 +16,7 @@ import {
formatMs,
formatTokens,
} from "../../lib/format.ts";
import { shouldHandleNavigationClick } from "../../lib/navigation-click.ts";
import { sessionNavigationTarget } from "../../lib/sessions/route-navigation.ts";
// Leaf contract: the slice of the cron view props this module needs. Keeping
@@ -349,14 +350,7 @@ function renderRun(
class="session-link"
href=${chatUrl}
@click=${(e: MouseEvent) => {
if (
e.defaultPrevented ||
e.button !== 0 ||
e.metaKey ||
e.ctrlKey ||
e.shiftKey ||
e.altKey
) {
if (!shouldHandleNavigationClick(e)) {
return;
}
if (onNavigateToChat && entry.sessionKey) {
@@ -88,7 +88,7 @@ describe("CustodianSessionStore", () => {
agentsList: {
defaultId: "main",
mainKey: "main",
scope: "agent",
scope: "per-sender",
agents: [{ id: "main" }],
},
});
+44 -69
View File
@@ -6,13 +6,10 @@ import type { EventLogEntry } from "../../api/event-log.ts";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { HealthSnapshot, StatusSummary } from "../../api/types.ts";
import { titleForRoute } from "../../app-navigation.ts";
import {
applicationContext,
type ApplicationContext,
type ApplicationGatewaySnapshot,
} from "../../app/context.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { loadGatewayDiagnostics } from "../../lib/gateway-diagnostics.ts";
import { GatewayPageController } from "../../lit/gateway-page-controller.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { PollController } from "../../lit/poll-controller.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
@@ -24,8 +21,6 @@ class DebugPage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
@state() private client: GatewayBrowserClient | null = null;
@state() private connected = false;
@state() private debugStatus: StatusSummary | null = null;
@state() private debugHealth: HealthSnapshot | null = null;
@state() private debugModels: unknown[] = [];
@@ -45,13 +40,11 @@ class DebugPage extends OpenClawLightDomElement {
},
false,
);
private hasBoundGatewaySource = false;
private gatewaySource: ApplicationContext["gateway"] | null = null;
private callEpoch = 0;
private diagnosticsTaskActiveClient: GatewayBrowserClient | null = null;
private readonly diagnosticsTask = new Task(this, {
autoRun: false,
args: () => [this.connected ? this.client : null] as const,
args: () => [this.gateway.connected ? this.gateway.client : null] as const,
task: ([client], { signal }) =>
client ? loadGatewayDiagnostics(client, signal) : initialState,
onComplete: (result) => {
@@ -67,68 +60,45 @@ class DebugPage extends OpenClawLightDomElement {
this.debugDiagnosticsError = String(error);
},
});
private readonly subscriptions = new SubscriptionsController(this)
.effect(
() => this.context?.gateway,
(gateway) => {
const resetForSourceBind = this.hasBoundGatewaySource;
this.hasBoundGatewaySource = true;
this.gatewaySource = gateway;
const cleanup = gateway.subscribe((snapshot) => {
if (this.gatewaySource === gateway && this.context.gateway === gateway) {
this.applyGatewaySnapshot(snapshot);
}
});
this.applyGatewaySnapshot(gateway.snapshot, resetForSourceBind);
return cleanup;
},
)
.watch(
() => this.context?.gateway,
(gateway, notify) => gateway.subscribeEventLog(notify),
(gateway) => {
this.eventLog = gateway.eventLog;
},
);
private readonly gateway = new GatewayPageController(this, {
getGateway: () => this.context?.gateway,
onIdentityChange: () => {
this.debugStatus = null;
this.debugHealth = null;
this.debugModels = [];
this.debugHeartbeat = null;
this.debugCallResult = null;
this.debugCallError = null;
this.debugDiagnosticsError = null;
},
invalidateRequests: () => {
void this.diagnosticsTask.run([null]);
this.diagnosticsTaskActiveClient = null;
this.callEpoch += 1;
},
onSnapshot: () => {
this.syncPolling();
this.ensureInitialDebug();
},
});
private readonly subscriptions = new SubscriptionsController(this).watch(
() => this.context?.gateway,
(gateway, notify) => gateway.subscribeEventLog(notify),
(gateway) => {
this.eventLog = gateway.eventLog;
},
);
override disconnectedCallback() {
this.subscriptions.clear();
void this.diagnosticsTask.run([null]);
this.diagnosticsTaskActiveClient = null;
this.callEpoch += 1;
this.gatewaySource = null;
super.disconnectedCallback();
}
private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot, resetForSourceBind = false) {
const connectionChanged = (snapshot.phase === "connected") !== this.connected;
const clientChanged = resetForSourceBind || snapshot.client !== this.client;
if (clientChanged || connectionChanged) {
void this.diagnosticsTask.run([null]);
this.diagnosticsTaskActiveClient = null;
this.callEpoch += 1;
}
this.client = snapshot.client;
this.connected = snapshot.phase === "connected";
if (clientChanged) {
this.resetServerState();
}
this.syncPolling();
this.ensureInitialDebug();
}
private resetServerState() {
this.debugStatus = null;
this.debugHealth = null;
this.debugModels = [];
this.debugHeartbeat = null;
this.debugCallResult = null;
this.debugCallError = null;
this.debugDiagnosticsError = null;
}
private syncPolling() {
if (!this.connected || !this.client) {
if (!this.gateway.connected || !this.gateway.client) {
this.polling.stop();
return;
}
@@ -136,14 +106,19 @@ class DebugPage extends OpenClawLightDomElement {
}
private ensureInitialDebug() {
if (!this.connected || !this.client || this.debugStatus || this.diagnosticsTaskActiveClient) {
if (
!this.gateway.connected ||
!this.gateway.client ||
this.debugStatus ||
this.diagnosticsTaskActiveClient
) {
return;
}
void this.loadDiagnostics();
}
private loadDiagnostics(): Promise<void> {
const client = this.connected ? this.client : null;
const client = this.gateway.connected ? this.gateway.client : null;
if (!client || this.diagnosticsTaskActiveClient) {
return Promise.resolve();
}
@@ -152,18 +127,18 @@ class DebugPage extends OpenClawLightDomElement {
}
private async callDebugMethod() {
const client = this.connected ? this.client : null;
const client = this.gateway.connected ? this.gateway.client : null;
if (!client) {
return;
}
this.debugCallError = null;
this.debugCallResult = null;
const gateway = this.gatewaySource;
const gateway = this.gateway.gateway;
const epoch = ++this.callEpoch;
const isCurrent = () =>
this.connected &&
this.client === client &&
this.gatewaySource === gateway &&
this.gateway.connected &&
this.gateway.client === client &&
this.gateway.gateway === gateway &&
this.context.gateway === gateway &&
this.callEpoch === epoch;
try {
@@ -633,16 +633,15 @@ describe("gateway source replacement across reconnect with a reused client", ()
const pending = deferred<unknown>();
const request = vi.fn(() => pending.promise);
const client = { request } as unknown as GatewayBrowserClient;
const context = contextWithClient(client);
const context = contextWithClient(client, { connected: true });
const page = createPage("openclaw-debug-page", context) as TestPage & {
connected: boolean;
debugStatus: unknown;
diagnosticsTask: { run: () => Promise<void>; status: TaskStatus };
};
page.debugStatus = { seeded: true };
document.body.append(page);
await page.updateComplete;
(context.gateway.snapshot as ApplicationGatewaySnapshot).phase = "connected";
page.connected = true;
page.debugStatus = null;
const load = page.diagnosticsTask.run();
await waitForFast(() => expect(request).toHaveBeenCalledTimes(4));
+59 -21
View File
@@ -7,7 +7,6 @@ import "./logs-page.ts";
type TestLogsPage = HTMLElement & {
context: ApplicationContext;
connected: boolean;
logsAutoFollow: boolean;
logsEntries: unknown[];
logsStatus: { error: string | null; hasLoaded: boolean; stale: boolean };
@@ -16,11 +15,16 @@ type TestLogsPage = HTMLElement & {
schedule: (force?: boolean) => void;
};
readonly updateComplete: Promise<boolean>;
applyGatewaySnapshot: (snapshot: ApplicationGatewaySnapshot) => void;
loadLogs: (opts?: { reset?: boolean; quiet?: boolean }) => Promise<boolean>;
requestUpdate: () => void;
};
type TestGateway = ApplicationContext["gateway"] & {
publish: (snapshot: ApplicationGatewaySnapshot) => void;
};
type TestApplicationContext = ApplicationContext & { gateway: TestGateway };
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
@@ -29,16 +33,35 @@ function deferred<T>() {
return { promise, resolve };
}
function contextWithClient(client: GatewayBrowserClient): ApplicationContext {
function contextWithClient(
client: GatewayBrowserClient,
connected = false,
): TestApplicationContext {
let snapshot = {
client,
phase: connected ? "connected" : "stopped",
} as ApplicationGatewaySnapshot;
const listeners = new Set<(snapshot: ApplicationGatewaySnapshot) => void>();
return {
basePath: "",
gateway: {
snapshot: { client, phase: "stopped" },
subscribe: () => () => undefined,
get snapshot() {
return snapshot;
},
subscribe: (listener: (snapshot: ApplicationGatewaySnapshot) => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
publish: (next: ApplicationGatewaySnapshot) => {
snapshot = next;
for (const listener of listeners) {
listener(next);
}
},
},
navigate: vi.fn(),
preload: vi.fn(async () => undefined),
} as unknown as ApplicationContext;
} as unknown as TestApplicationContext;
}
describe("LogsPage lifecycle", () => {
@@ -103,10 +126,13 @@ describe("LogsPage lifecycle", () => {
request: vi.fn(() => pending.promise),
} as unknown as GatewayBrowserClient;
const page = document.createElement("openclaw-logs-page") as TestLogsPage;
page.context = contextWithClient(client);
const context = contextWithClient(client);
page.context = context;
page.logsEntries = [{ raw: "seed" }];
document.body.append(page);
await page.updateComplete;
page.connected = true;
context.gateway.publish({ client, phase: "connected" } as ApplicationGatewaySnapshot);
page.logsEntries = [];
const load = page.loadLogs({ reset: true });
page.context = contextWithClient(client);
@@ -124,10 +150,13 @@ describe("LogsPage lifecycle", () => {
request: vi.fn(() => pending.promise),
} as unknown as GatewayBrowserClient;
const page = document.createElement("openclaw-logs-page") as TestLogsPage;
page.context = contextWithClient(client);
const context = contextWithClient(client);
page.context = context;
page.logsEntries = [{ raw: "seed" }];
document.body.append(page);
await page.updateComplete;
page.connected = true;
context.gateway.publish({ client, phase: "connected" } as ApplicationGatewaySnapshot);
page.logsEntries = [];
const load = page.loadLogs({ reset: true });
page.remove();
@@ -143,13 +172,16 @@ describe("LogsPage lifecycle", () => {
request: vi.fn(() => pending.promise),
} as unknown as GatewayBrowserClient;
const page = document.createElement("openclaw-logs-page") as TestLogsPage;
page.context = contextWithClient(client);
const context = contextWithClient(client);
page.context = context;
page.logsEntries = [{ raw: "seed" }];
document.body.append(page);
await page.updateComplete;
page.connected = true;
context.gateway.publish({ client, phase: "connected" } as ApplicationGatewaySnapshot);
page.logsEntries = [];
const load = page.loadLogs({ reset: true });
page.applyGatewaySnapshot({ client, phase: "stopped" } as ApplicationGatewaySnapshot);
context.gateway.publish({ client, phase: "stopped" } as ApplicationGatewaySnapshot);
pending.resolve({ cursor: 1, lines: ["stale"], reset: true });
await load;
@@ -163,10 +195,12 @@ describe("LogsPage lifecycle", () => {
request,
} as unknown as GatewayBrowserClient;
const page = document.createElement("openclaw-logs-page") as TestLogsPage;
page.context = contextWithClient(client);
const context = contextWithClient(client, true);
page.context = context;
page.logsEntries = [{ raw: "seed" }];
document.body.append(page);
await page.updateComplete;
page.connected = true;
page.logsEntries = [];
const first = page.loadLogs({ quiet: true });
const second = page.loadLogs({ quiet: true });
@@ -186,10 +220,13 @@ describe("LogsPage lifecycle", () => {
.mockResolvedValueOnce({ cursor: 2, lines: ["fresh"], reset: true });
const client = { request } as unknown as GatewayBrowserClient;
const page = document.createElement("openclaw-logs-page") as TestLogsPage;
page.context = contextWithClient(client);
const context = contextWithClient(client);
page.context = context;
page.logsEntries = [{ raw: "seed" }];
document.body.append(page);
await page.updateComplete;
page.connected = true;
context.gateway.publish({ client, phase: "connected" } as ApplicationGatewaySnapshot);
page.logsEntries = [];
await page.loadLogs({ reset: true });
await page.loadLogs({ reset: true });
@@ -215,16 +252,17 @@ describe("LogsPage lifecycle", () => {
),
} as unknown as GatewayBrowserClient;
const page = document.createElement("openclaw-logs-page") as TestLogsPage;
page.context = contextWithClient(client);
const context = contextWithClient(client);
page.context = context;
const requestFrame = vi.spyOn(window, "requestAnimationFrame").mockReturnValue(1);
document.body.append(page);
await page.updateComplete;
page.applyGatewaySnapshot({ client, phase: "connected" } as ApplicationGatewaySnapshot);
context.gateway.publish({ client, phase: "connected" } as ApplicationGatewaySnapshot);
requestFrame.mockClear();
page.streamFollow.schedule();
page.applyGatewaySnapshot({ client, phase: "stopped" } as ApplicationGatewaySnapshot);
page.applyGatewaySnapshot({ client, phase: "connected" } as ApplicationGatewaySnapshot);
context.gateway.publish({ client, phase: "stopped" } as ApplicationGatewaySnapshot);
context.gateway.publish({ client, phase: "connected" } as ApplicationGatewaySnapshot);
await Promise.resolve();
expect(requestFrame).not.toHaveBeenCalled();
+35 -65
View File
@@ -3,13 +3,8 @@ import { consume } from "@lit/context";
import { initialState, Task, TaskStatus } from "@lit/task";
import { html, type PropertyValues } from "lit";
import { state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { titleForRoute } from "../../app-navigation.ts";
import {
applicationContext,
type ApplicationContext,
type ApplicationGatewaySnapshot,
} from "../../app/context.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import {
beginPanelRefresh,
completePanelRefresh,
@@ -21,10 +16,10 @@ import {
formatMissingOperatorReadScopeMessage,
isMissingOperatorReadScopeError,
} from "../../lib/gateway-errors.ts";
import { GatewayPageController } from "../../lit/gateway-page-controller.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { PollController } from "../../lit/poll-controller.ts";
import { StreamAutoFollowController } from "../../lit/stream-auto-follow-controller.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import {
DEFAULT_LOG_LEVEL_FILTERS,
parseLogLine,
@@ -40,8 +35,6 @@ class LogsPage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
@state() private client: GatewayBrowserClient | null = null;
@state() private connected = false;
@state() private logsStatus = createPanelRefreshStatus();
@state() private logsFile: string | null = null;
@state() private logsEntries: LogEntry[] = [];
@@ -62,13 +55,11 @@ class LogsPage extends OpenClawLightDomElement {
false,
);
private contentScrollFrame: number | null = null;
private hasBoundGatewaySource = false;
private gatewaySource: ApplicationContext["gateway"] | null = null;
private logsTaskQuiet = false;
private logsTaskArgs(opts?: { reset?: boolean; quiet?: boolean }) {
return [
this.connected ? this.gatewaySource : null,
this.connected ? this.client : null,
this.gateway.connected ? this.gateway.gateway : null,
this.gateway.connected ? this.gateway.client : null,
opts?.reset ? null : this.logsCursor,
opts?.reset === true,
opts?.quiet === true,
@@ -131,35 +122,40 @@ class LogsPage extends OpenClawLightDomElement {
this.logsStatus = completePanelRefresh();
},
});
private readonly subscriptions = new SubscriptionsController(this).effect(
() => this.context?.gateway,
(gateway) => {
const resetForSourceBind = this.hasBoundGatewaySource;
this.hasBoundGatewaySource = true;
this.gatewaySource = gateway;
const cleanup = gateway.subscribe((snapshot) => {
if (this.gatewaySource === gateway && this.context.gateway === gateway) {
this.applyGatewaySnapshot(snapshot);
}
});
this.applyGatewaySnapshot(gateway.snapshot, resetForSourceBind);
private readonly gateway = new GatewayPageController(this, {
getGateway: () => this.context?.gateway,
onIdentityChange: () => {
this.logsStatus = createPanelRefreshStatus();
this.logsFile = null;
this.logsEntries = [];
this.logsTruncated = false;
this.logsCursor = null;
this.streamFollow.atBottom = true;
return cleanup;
},
);
invalidateRequests: () => {
this.logsTaskQuiet = false;
void this.logsTask.run([null, null, null, false, false]);
},
onSnapshot: () => {
this.syncPolling();
this.ensureInitialLogs();
},
});
private readonly streamFollow = new StreamAutoFollowController(this, {
selector: ".log-stream",
isEnabled: () => this.logsAutoFollow,
captureCurrent: () => {
const gateway = this.gatewaySource;
const client = this.client;
const gateway = this.gateway.gateway;
const epoch = this.gateway.epoch;
// Same-client reconnects retain object identity; the epoch keeps queued
// scroll work bound to the connection that scheduled it.
return () =>
this.isConnected &&
this.connected &&
this.gateway.connected &&
gateway !== null &&
this.gatewaySource === gateway &&
this.gateway.gateway === gateway &&
this.context.gateway === gateway &&
this.client === client;
this.gateway.epoch === epoch;
},
});
@@ -182,10 +178,8 @@ class LogsPage extends OpenClawLightDomElement {
}
override disconnectedCallback() {
this.subscriptions.clear();
this.logsTaskQuiet = false;
void this.logsTask.run([null, null, null, false, false]);
this.gatewaySource = null;
if (this.contentScrollFrame !== null) {
cancelAnimationFrame(this.contentScrollFrame);
this.contentScrollFrame = null;
@@ -201,33 +195,8 @@ class LogsPage extends OpenClawLightDomElement {
}
}
private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot, resetForSourceBind = false) {
const connectionChanged = (snapshot.phase === "connected") !== this.connected;
const clientChanged = resetForSourceBind || snapshot.client !== this.client;
if (clientChanged || connectionChanged) {
this.logsTaskQuiet = false;
void this.logsTask.run([null, null, null, false, false]);
}
this.client = snapshot.client;
this.connected = snapshot.phase === "connected";
if (clientChanged) {
this.resetServerState();
}
this.syncPolling();
this.ensureInitialLogs();
}
private resetServerState() {
this.logsStatus = createPanelRefreshStatus();
this.logsFile = null;
this.logsEntries = [];
this.logsTruncated = false;
this.logsCursor = null;
this.streamFollow.atBottom = true;
}
private syncPolling() {
if (!this.connected || !this.client) {
if (!this.gateway.connected || !this.gateway.client) {
this.polling.stop();
return;
}
@@ -235,7 +204,7 @@ class LogsPage extends OpenClawLightDomElement {
}
private ensureInitialLogs() {
if (!this.connected || !this.client || this.logsEntries.length > 0) {
if (!this.gateway.connected || !this.gateway.client || this.logsEntries.length > 0) {
return;
}
void this.loadLogs({ reset: true }).then((current) => {
@@ -247,11 +216,12 @@ class LogsPage extends OpenClawLightDomElement {
private async loadLogs(opts?: { reset?: boolean; quiet?: boolean }): Promise<boolean> {
const quiet = opts?.quiet === true;
const gateway = this.gateway.gateway;
if (
!this.gatewaySource ||
!this.client ||
!this.connected ||
this.context.gateway !== this.gatewaySource ||
!gateway ||
!this.gateway.client ||
!this.gateway.connected ||
this.context.gateway !== gateway ||
(this.logsTask.status === TaskStatus.PENDING && opts?.reset !== true)
) {
return false;
+7 -8
View File
@@ -1,4 +1,3 @@
import { formatErrorMessage } from "@openclaw/normalization-core";
import { html, nothing } from "lit";
import type {
EnvironmentsListResult,
@@ -7,7 +6,7 @@ import type {
import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts";
import { icons } from "../../components/icons.ts";
import { t } from "../../i18n/index.ts";
import { redactToolDetail } from "../../lib/browser-redact.ts";
import { formatUiError } from "../../lib/format-error.ts";
import { generateUUID } from "../../lib/uuid.ts";
import type { DraftCloudProfile } from "./discovery.ts";
import { readDraftCloudProfiles } from "./discovery.ts";
@@ -70,7 +69,7 @@ async function readPlacement(
if (!isAmbiguousDispatchError(error)) {
return {
status: "rejected",
error: formatErrorMessage(error, { redact: redactToolDetail }),
error: formatUiError(error),
};
}
return { status: "unavailable" };
@@ -96,7 +95,7 @@ async function cancelActivePlacement(
await client.request("environments.destroy", { environmentId });
return undefined;
} catch (error) {
return formatErrorMessage(error, { redact: redactToolDetail });
return formatUiError(error);
}
}
@@ -232,7 +231,7 @@ export async function deleteCloudDraftSession(
await client.request("sessions.delete", { key, agentId, deleteTranscript: true });
return undefined;
} catch (error) {
return formatErrorMessage(error, { redact: redactToolDetail });
return formatUiError(error);
}
}
@@ -334,7 +333,7 @@ export async function startCloudInitialTurn(
isCurrent,
);
} catch (error) {
dispatchError = formatErrorMessage(error, { redact: redactToolDetail });
dispatchError = formatUiError(error);
if (!isAmbiguousDispatchError(error)) {
return { status: "dispatch-rejected", error: dispatchError };
}
@@ -433,13 +432,13 @@ export async function startCloudInitialTurn(
? { status: "cleanup-rejected", error: cleanupError, messageId }
: {
status: "send-definitive-rejected",
error: formatErrorMessage(error, { redact: redactToolDetail }),
error: formatUiError(error),
messageId,
};
}
return {
status: "send-rejected",
error: formatErrorMessage(error, { redact: redactToolDetail }),
error: formatUiError(error),
messageId,
};
}
+3 -3
View File
@@ -74,7 +74,7 @@ describe("new-session route catalog target", () => {
agentsList: {
defaultId: "roboclaw",
mainKey: "main",
scope: "agent",
scope: "per-sender",
agents: [{ id: "roboclaw" }],
},
});
@@ -104,7 +104,7 @@ describe("new-session route catalog target", () => {
agentsList: {
defaultId: "main",
mainKey: "main",
scope: "agent",
scope: "per-sender",
agents: [{ id: "main" }],
},
staleRosterClient: true,
@@ -119,7 +119,7 @@ describe("new-session route catalog target", () => {
agentsState.agentsList = {
defaultId: "roboclaw",
mainKey: "main",
scope: "agent",
scope: "per-sender",
agents: [{ id: "roboclaw" }, { id: "research" }],
};
const data = await loadNewSessionData(context, "?agent=research&catalog=claude");
+1 -1
View File
@@ -118,7 +118,7 @@ describe("NodesPage gateway lifecycle", () => {
expect(page.nodeState.nodes).toBe(preloadedNodes);
page.context = { gateway: gateway(client) } as unknown as ApplicationContext;
page.presence = [{ instanceId: "stale" }];
page.presence = [{ instanceId: "stale", ts: 1_000 }];
applyGatewaySnapshot(page, page.context.gateway.snapshot, true);
expect(page.nodeState.nodes).toEqual([]);
expect(page.presence).toEqual([]);
+10 -2
View File
@@ -236,6 +236,7 @@ describe("nodes inventory rendering", () => {
platform: "linux",
version: "2026.7.11",
lastInputSeconds: 5,
ts: 1_000,
},
],
devicesList: {
@@ -269,7 +270,7 @@ describe("nodes inventory rendering", () => {
],
paired: [],
},
presence: [{ instanceId: "probe-1", host: "laptop", mode: "probe" }],
presence: [{ instanceId: "probe-1", host: "laptop", mode: "probe", ts: 1_000 }],
});
const section = getInventorySection(container);
@@ -539,8 +540,15 @@ describe("nodes inventory rendering", () => {
roles: ["operator"],
platform: "macos 26.5.2",
lastInputSeconds: 90,
ts: 1_000,
},
{
instanceId: "left-1",
host: "gone",
mode: "webchat",
reason: "disconnect",
ts: 2_000,
},
{ instanceId: "left-1", host: "gone", mode: "webchat", reason: "disconnect" },
],
});
const section = getSection(container, "Connected without pairing");
+7 -8
View File
@@ -1,6 +1,5 @@
import { consume } from "@lit/context";
import { initialState, Task, TaskStatus } from "@lit/task";
import { formatErrorMessage } from "@openclaw/normalization-core";
import type { RouteLocation } from "@openclaw/uirouter";
import { html, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
@@ -19,7 +18,6 @@ import type { McpServerForm } from "../../components/mcp-server-form.ts";
import { renderDocsLink } from "../../components/settings-ui.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { t } from "../../i18n/index.ts";
import { redactToolDetail } from "../../lib/browser-redact.ts";
import { resolveEditableSnapshotConfig } from "../../lib/config/index.ts";
import {
buildAddMcpServerPatch,
@@ -32,6 +30,7 @@ import {
type McpServerSummary,
type McpServersPatchBuildResult,
} from "../../lib/config/mcp-servers.ts";
import { formatUiError } from "../../lib/format-error.ts";
import {
installPlugin,
pluginInstallNeedsRiskAcknowledgement,
@@ -175,7 +174,7 @@ class PluginsPage extends OpenClawLightDomElement {
this.replaceResult(result);
},
onError: (error) => {
this.error = formatErrorMessage(error, { redact: redactToolDetail });
this.error = formatUiError(error);
},
});
@@ -531,14 +530,14 @@ class PluginsPage extends OpenClawLightDomElement {
private get searchError(): string | null {
return this.searchTask.status === TaskStatus.ERROR &&
this.debouncedSearchQuery === this.query.trim()
? formatErrorMessage(this.searchTask.error, { redact: redactToolDetail })
? formatUiError(this.searchTask.error)
: null;
}
private get configRefreshError(): string | null {
const failure =
this.configTask.status === TaskStatus.ERROR
? formatErrorMessage(this.configTask.error, { redact: redactToolDetail })
? formatUiError(this.configTask.error)
: this.configTask.status === TaskStatus.COMPLETE
? this.configTask.value
: null;
@@ -734,7 +733,7 @@ class PluginsPage extends OpenClawLightDomElement {
onError: (error: unknown) => void = (error) => {
this.setMessage(rowKey, {
kind: "error",
text: formatErrorMessage(error, { redact: redactToolDetail }),
text: formatUiError(error),
});
},
): Promise<void> {
@@ -798,7 +797,7 @@ class PluginsPage extends OpenClawLightDomElement {
}
this.setMessage(rowKey, {
kind: "error",
text: formatErrorMessage(error, { redact: redactToolDetail }),
text: formatUiError(error),
});
},
);
@@ -893,7 +892,7 @@ class PluginsPage extends OpenClawLightDomElement {
this.mcpMessage = { kind: "success", text: params.successText };
return true;
} catch (error) {
return fail(formatErrorMessage(error, { redact: redactToolDetail }));
return fail(formatUiError(error));
} finally {
this.mcpBusy = false;
if (params.busyKey) {
+2 -3
View File
@@ -1,9 +1,8 @@
import { formatErrorMessage } from "@openclaw/normalization-core";
import { definePage, type RouteLoaderOptions, type RouteLocation } from "@openclaw/uirouter";
import { html } from "lit";
import { routePageSpec } from "../../app-route-paths.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { redactToolDetail } from "../../lib/browser-redact.ts";
import { formatUiError } from "../../lib/format-error.ts";
import { loadPluginCatalog } from "../../lib/plugins/index.ts";
import type { PluginsRouteData } from "./plugins-page.ts";
import { pluginsRouteLocation } from "./route-data.ts";
@@ -27,7 +26,7 @@ async function loadPluginsRouteData(
gateway,
gatewaySnapshot,
result: null,
error: formatErrorMessage(error, { redact: redactToolDetail }),
error: formatUiError(error),
location,
};
}
+2 -8
View File
@@ -32,6 +32,7 @@ import {
formatRelativeTimestamp,
formatTokens,
} from "../../lib/format.ts";
import { shouldHandleNavigationClick } from "../../lib/navigation-click.ts";
import { formatSessionTokens } from "../../lib/presenter.ts";
import { isCronSessionKey } from "../../lib/session-display.ts";
import { formatGoalDetail, formatGoalSummary } from "../../lib/session-goal.ts";
@@ -1486,14 +1487,7 @@ function renderRows(row: GatewaySessionRow, props: SessionsProps) {
href=${chatUrl}
class="session-link"
@click=${(e: MouseEvent) => {
if (
e.defaultPrevented ||
e.button !== 0 ||
e.metaKey ||
e.ctrlKey ||
e.shiftKey ||
e.altKey
) {
if (!shouldHandleNavigationClick(e)) {
return;
}
if (props.onNavigateToChat) {
+3 -4
View File
@@ -1,8 +1,7 @@
import { formatErrorMessage } from "@openclaw/normalization-core";
import { html, nothing } from "lit";
import type { ApplicationGateway } from "../../app/context.ts";
import { t } from "../../i18n/index.ts";
import { redactToolDetail } from "../../lib/browser-redact.ts";
import { formatUiError } from "../../lib/format-error.ts";
import { canCallGatewayMethod } from "../../lib/gateway-methods.ts";
import type { SkillWorkshopHistoryScanResult, SkillWorkshopHistoryScanState } from "./state.ts";
@@ -69,7 +68,7 @@ export async function loadSkillWorkshopHistoryScanStatus(
);
current.state.loaded = true;
} catch (error) {
current.state.error = formatErrorMessage(error, { redact: redactToolDetail });
current.state.error = formatUiError(error);
// Loaded means this scope attempted a read. A scan action can still
// force a retry because the result remains absent.
current.state.loaded = true;
@@ -144,7 +143,7 @@ export async function runSkillWorkshopHistoryScan(params: {
params.state.loaded = true;
return true;
} catch (error) {
const scanError = formatErrorMessage(error, { redact: redactToolDetail });
const scanError = formatUiError(error);
try {
params.state.result = await client.request<SkillWorkshopHistoryScanResult>(
"skills.proposals.historyStatus",
+6 -7
View File
@@ -1,10 +1,9 @@
// Control UI controller manages skill workshop gateway state.
import { formatErrorMessage } from "@openclaw/normalization-core";
import type { AgentSelectionCapability } from "../../app/agent-selection.ts";
import type { ApplicationGateway } from "../../app/context.ts";
import { t } from "../../i18n/index.ts";
import { formatBytes } from "../../lib/agents/display.ts";
import { redactToolDetail } from "../../lib/browser-redact.ts";
import { formatUiError } from "../../lib/format-error.ts";
import { canCallGatewayMethod } from "../../lib/gateway-methods.ts";
import {
normalizeAgentId,
@@ -406,7 +405,7 @@ export async function loadSkillWorkshopProposals(
await loadSkillWorkshopProposalDetail(state, context, state.skillWorkshopSelectedKey);
}
} catch (err) {
state.skillWorkshopError = formatErrorMessage(err, { redact: redactToolDetail });
state.skillWorkshopError = formatUiError(err);
} finally {
state.skillWorkshopLoading = false;
if (skillWorkshopAgentParams(context).agentId !== requestAgentId) {
@@ -456,7 +455,7 @@ async function loadSkillWorkshopProposalDetail(
return true;
} catch (err) {
if (state.skillWorkshopAgentId === requestAgentId) {
state.skillWorkshopError = formatErrorMessage(err, { redact: redactToolDetail });
state.skillWorkshopError = formatUiError(err);
}
return false;
} finally {
@@ -524,7 +523,7 @@ export async function runSkillWorkshopLifecycleAction(
t(action === "apply" ? "skillWorkshop.notices.applied" : "skillWorkshop.notices.rejected"),
);
} catch (err) {
state.skillWorkshopError = formatErrorMessage(err, { redact: redactToolDetail });
state.skillWorkshopError = formatUiError(err);
} finally {
if (
state.skillWorkshopActionBusy?.key === proposalId &&
@@ -599,7 +598,7 @@ export async function runSkillWorkshopEvaluation(
return true;
} catch (err) {
if (state.skillWorkshopAgentId === requestAgentId) {
state.skillWorkshopError = formatErrorMessage(err, { redact: redactToolDetail });
state.skillWorkshopError = formatUiError(err);
}
return false;
} finally {
@@ -668,7 +667,7 @@ export async function requestSkillWorkshopRevision(
showActionNotice(state, proposal, t("skillWorkshop.notices.revisionRequested"));
return true;
} catch (err) {
state.skillWorkshopError = formatErrorMessage(err, { redact: redactToolDetail });
state.skillWorkshopError = formatUiError(err);
return false;
} finally {
if (
+3 -4
View File
@@ -1,9 +1,8 @@
import { formatErrorMessage } from "@openclaw/normalization-core";
import { definePage } from "@openclaw/uirouter";
import { html } from "lit";
import { routePageSpec } from "../../app-route-paths.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { redactToolDetail } from "../../lib/browser-redact.ts";
import { formatUiError } from "../../lib/format-error.ts";
import { loadSkillStatusReport } from "../../lib/skills/index.ts";
import type { SkillsRouteData } from "./skills-page.ts";
@@ -30,12 +29,12 @@ async function loadSkillsRouteData(context: ApplicationContext): Promise<SkillsR
try {
agentsList = await agents.ensureList();
} catch (err) {
error = formatErrorMessage(err, { redact: redactToolDetail });
error = formatUiError(err);
}
try {
report = (await loadSkillStatusReport(client, null)) ?? null;
} catch (err) {
error ??= formatErrorMessage(err, { redact: redactToolDetail });
error ??= formatUiError(err);
}
return {
gateway,
+1 -1
View File
@@ -63,7 +63,7 @@ function createProps(overrides: Partial<SkillsProps> = {}): SkillsProps {
const agentsList: AgentsListResult = {
defaultId: "main",
mainKey: "main",
scope: "project",
scope: "per-sender",
agents: [
{ id: "main", name: "Main" },
{ id: "research", identity: { name: "Research", avatar: "R" } },
+2 -8
View File
@@ -4,6 +4,7 @@ import type { GatewaySessionRow } from "../../api/types.ts";
import { icon, type IconName } from "../../components/icons.ts";
import { t } from "../../i18n/index.ts";
import { formatMs, formatRelativeTimestamp } from "../../lib/format.ts";
import { shouldHandleNavigationClick } from "../../lib/navigation-click.ts";
import {
resolveSessionPreferredFace,
sessionNavigationTarget,
@@ -56,14 +57,7 @@ function renderSessionLink(task: TaskSummary, props: TasksProps) {
class="session-link"
href=${href}
@click=${(event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
+7 -7
View File
@@ -877,7 +877,7 @@ describe("renderWorkboard", () => {
agentsList: {
defaultId: "main",
mainKey: "agent:main:main",
scope: "test",
scope: "per-sender",
agents: [{ id: "main", name: "Main" }],
},
});
@@ -907,7 +907,7 @@ describe("renderWorkboard", () => {
agentsList: {
defaultId: "main",
mainKey: "agent:main:main",
scope: "test",
scope: "per-sender",
agents: [{ id: "main" }, { id: "writer" }, { id: "ops" }],
},
scopeAgentId: "writer",
@@ -1808,7 +1808,7 @@ describe("renderWorkboard", () => {
agentsList: {
defaultId: "main",
mainKey: "agent:main:main",
scope: "test",
scope: "per-sender",
agents: [
{ id: "main", name: "Main" },
{ id: "writer", name: "Writer" },
@@ -2141,10 +2141,10 @@ describe("renderWorkboard", () => {
});
it("filters cards by linked agent", () => {
const agentsList = {
const agentsList: NonNullable<WorkboardRenderProps["agentsList"]> = {
defaultId: "main",
mainKey: "agent:main:main",
scope: "test",
scope: "per-sender",
agents: [
{ id: "main", name: "Main" },
{ id: "ops", name: "Ops" },
@@ -2228,7 +2228,7 @@ describe("renderWorkboard", () => {
agentsList: {
defaultId: "main",
mainKey: "agent:main:main",
scope: "test",
scope: "per-sender",
agents: [
{ id: "main", name: "Main" },
{ id: "main", name: "Main duplicate" },
@@ -2298,7 +2298,7 @@ describe("renderWorkboard", () => {
agentsList: {
defaultId: "main",
mainKey: "agent:main:main",
scope: "test",
scope: "per-sender",
agents: [{ id: "main", name: "Main", agentRuntime: { id: "codex", source: "agent" } }],
},
});
+1 -1
View File
@@ -6,7 +6,6 @@ import type { WorktreeRecord } from "../../../../packages/gateway-protocol/src/i
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import { shouldHandleNavigationClick } from "../../components/app-sidebar-nav-menus.ts";
import { showConfirmDialog } from "../../components/confirm-dialog.ts";
import { renderSessionsHubHeader } from "../../components/sessions-hub-header.ts";
import {
@@ -20,6 +19,7 @@ import {
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { t } from "../../i18n/index.ts";
import { formatRelativeTimestamp } from "../../lib/format.ts";
import { shouldHandleNavigationClick } from "../../lib/navigation-click.ts";
import {
resolveSessionPreferredFaceForKey,
sessionNavigationTarget,
@@ -33,7 +33,7 @@ describe("AppSidebar agent chip", () => {
{
defaultId: "main",
mainKey: "main",
scope: "agent",
scope: "per-sender",
agents: [{ id: "main" }],
},
[],
@@ -66,7 +66,7 @@ describe("AppSidebar agent chip", () => {
{
defaultId: "main",
mainKey: "main",
scope: "agent",
scope: "per-sender",
agents: [{ id: "main" }],
},
[],
@@ -262,7 +262,7 @@ describe("AppSidebar agent chip", () => {
{
defaultId: "main",
mainKey: "main",
scope: "agent",
scope: "per-sender",
agents: [{ id: "main", identity: { name: "Molty", emoji: "🦞" } }],
},
);
@@ -343,7 +343,7 @@ describe("AppSidebar brand actions", () => {
const agentsList = {
defaultId: "main",
mainKey: "main",
scope: "agent",
scope: "per-sender",
agents: [{ id: "main" }, { id: "research" }],
} as AgentsListResult;
const { sidebar } = await mountSidebar(
@@ -465,7 +465,7 @@ describe("AppSidebar agent chip", () => {
const agents = {
defaultId: "main",
mainKey: "main",
scope: "agent",
scope: "per-sender",
agents: [{ id: "main" }, { id: "settings" }],
} as AgentsListResult;
const { sidebar } = await mountSidebar(
@@ -22,7 +22,7 @@ describe("AppSidebar session catalog pagination", () => {
{
defaultId: "main",
mainKey: "main",
scope: "agent",
scope: "per-sender",
agents: [{ id: "main" }, { id: "research" }],
},
);
@@ -78,7 +78,7 @@ describe("AppSidebar session catalog request errors", () => {
{
defaultId: "roboclaw",
mainKey: "main",
scope: "agent",
scope: "per-sender",
agents: [{ id: "roboclaw" }],
},
);
@@ -117,7 +117,7 @@ describe("AppSidebar session catalog request errors", () => {
{
defaultId: "roboclaw",
mainKey: "main",
scope: "agent",
scope: "per-sender",
agents: [{ id: "main" }, { id: "roboclaw" }],
},
);
@@ -155,7 +155,7 @@ describe("AppSidebar session catalog request errors", () => {
{
defaultId: "main",
mainKey: "main",
scope: "agent",
scope: "per-sender",
agents: [{ id: "main" }],
},
);
@@ -34,7 +34,7 @@ describe("AppSidebar outbox badges", () => {
{
defaultId: "main",
mainKey: "main",
scope: "agent",
scope: "per-sender",
agents: [{ id: "main" }],
},
);
+2 -2
View File
@@ -489,7 +489,7 @@ export async function mountSidebar(
export const TWO_AGENTS = {
defaultId: "main",
mainKey: "main",
scope: "agent",
scope: "per-sender",
agents: [{ id: "main", identity: { name: "Molty" } }, { id: "research" }],
} as AgentsListResult;
@@ -497,7 +497,7 @@ export const manyAgents = (count: number) =>
({
defaultId: "agent-1",
mainKey: "main",
scope: "agent",
scope: "per-sender",
agents: Array.from({ length: count }, (_, index) => ({ id: `agent-${index + 1}` })),
}) as AgentsListResult;