From 8bf62e70075bfcfd14c213dedd9aedf1a25fc963 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 7 Aug 2026 15:25:07 -0700 Subject: [PATCH] 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 --- .../.generated/plugin-sdk-api-baseline.sha256 | 2 +- packages/gateway-protocol/src/index.ts | 1 + .../gateway-protocol/src/schema/logs-chat.ts | 5 +- .../src/schema/sessions-row.ts | 1 + .../gateway-protocol/src/schema/sessions.ts | 1 + ...s.subagents.sessions-spawn.test-harness.ts | 3 +- src/agents/tools/sessions-helpers.ts | 4 +- src/agents/tools/sessions-list-tool.ts | 2 +- .../reply/directive-handling.parse.ts | 3 +- src/auto-reply/reply/get-reply.types.ts | 3 +- src/auto-reply/reply/queue/directive.ts | 3 +- src/auto-reply/reply/queue/normalize.ts | 3 +- src/auto-reply/reply/queue/state.ts | 2 +- src/auto-reply/reply/queue/types.ts | 3 +- ...session-transcript-turn-lifecycle.types.ts | 3 +- src/config/sessions/types.ts | 6 +- src/config/types.messages.ts | 3 +- src/config/types.queue.ts | 5 +- .../server-methods/chat-send-request.ts | 2 +- src/gateway/session-lifecycle-state.ts | 3 +- src/gateway/session-utils.types.ts | 6 +- src/state/openclaw-agent-db-schema.ts | 5 +- ui/src/api/gateway.ts | 7 +- ui/src/api/types.ts | 111 ++--------------- ui/src/app/native-link-routing.ts | 10 +- ui/src/app/user-profile.test.ts | 10 +- ui/src/app/web-push.ts | 5 +- ui/src/components/app-sidebar-nav-menus.ts | 14 +-- ui/src/components/app-sidebar-render.ts | 3 +- .../app-sidebar-session-catalog-render.ts | 2 +- .../app-sidebar-session-navigation.ts | 21 ++-- .../app-sidebar-workboard.runtime.ts | 10 +- ui/src/components/lobster-pet-contract.ts | 4 +- ui/src/components/settings-sidebar.ts | 19 +-- ui/src/components/sidebar-build-chip.ts | 13 +- ui/src/lib/chat/follow-up-mode.ts | 2 +- ui/src/lib/format-error.test.ts | 21 ++++ ui/src/lib/format-error.ts | 6 + ui/src/lib/navigation-click.test.ts | 33 +++++ ui/src/lib/navigation-click.ts | 11 ++ ui/src/lib/nodes/inventory.test.ts | 21 ++-- ui/src/lib/skills/index.test.ts | 2 +- ui/src/lib/skills/index.ts | 18 ++- ui/src/pages/agents/agents-page.test.ts | 2 +- .../pages/agents/agents-view.test-helpers.ts | 2 +- ui/src/pages/agents/identity-actions.ts | 7 +- ui/src/pages/agents/view.test.ts | 2 +- .../chat/chat-composer-capability-host.ts | 29 ++--- ui/src/pages/chat/chat-send-request.ts | 2 +- ui/src/pages/chat/chat-send.test.ts | 8 +- ui/src/pages/chat/chat-view.ts | 18 +-- .../chat/components/chat-composer-types.ts | 24 ++-- ui/src/pages/chat/steer-lifecycle.ts | 2 +- ui/src/pages/config/memory-memories.ts | 7 +- ui/src/pages/config/memory-page.ts | 12 +- .../config/view-appearance-preferences.ts | 14 +-- ui/src/pages/config/view-types.ts | 2 +- ui/src/pages/config/view.browser.test.ts | 10 +- ui/src/pages/cron/view-runs.ts | 10 +- .../custodian/custodian-session-store.test.ts | 2 +- ui/src/pages/debug/debug-page.ts | 113 +++++++----------- .../pages/gateway-source-replacement.test.ts | 7 +- ui/src/pages/logs/logs-page.test.ts | 80 +++++++++---- ui/src/pages/logs/logs-page.ts | 100 ++++++---------- ui/src/pages/new-session/cloud-target.ts | 15 ++- ui/src/pages/new-session/route.test.ts | 6 +- ui/src/pages/nodes/nodes-page.test.ts | 2 +- ui/src/pages/nodes/view.devices.test.ts | 12 +- ui/src/pages/plugins/plugins-page.ts | 15 ++- ui/src/pages/plugins/route.ts | 5 +- ui/src/pages/sessions/view.ts | 10 +- ui/src/pages/skill-workshop/history-scan.ts | 7 +- ui/src/pages/skill-workshop/proposals.ts | 13 +- ui/src/pages/skills/route.ts | 7 +- ui/src/pages/skills/view.test.ts | 2 +- ui/src/pages/tasks/view.ts | 10 +- ui/src/pages/workboard/view.test.ts | 14 +-- ui/src/pages/worktrees/worktrees-page.ts | 2 +- .../app-sidebar-cases/agent-menu.ts | 6 +- .../test-helpers/app-sidebar-cases/basics.ts | 4 +- .../app-sidebar-cases/catalog-compat.ts | 2 +- .../app-sidebar-cases/catalog-live-errors.ts | 6 +- .../app-sidebar-cases/outbox-badges.ts | 2 +- ui/src/test-helpers/app-sidebar.ts | 4 +- 84 files changed, 447 insertions(+), 562 deletions(-) create mode 100644 ui/src/lib/format-error.test.ts create mode 100644 ui/src/lib/format-error.ts create mode 100644 ui/src/lib/navigation-click.test.ts create mode 100644 ui/src/lib/navigation-click.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 03352c38294b..f283706572df 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index cf48c01a78d6..876149c96649 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -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"; diff --git a/packages/gateway-protocol/src/schema/logs-chat.ts b/packages/gateway-protocol/src/schema/logs-chat.ts index d37978fc9d68..90171716e3a3 100644 --- a/packages/gateway-protocol/src/schema/logs-chat.ts +++ b/packages/gateway-protocol/src/schema/logs-chat.ts @@ -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()), diff --git a/packages/gateway-protocol/src/schema/sessions-row.ts b/packages/gateway-protocol/src/schema/sessions-row.ts index e314bd57f57c..119e4fe9cffb 100644 --- a/packages/gateway-protocol/src/schema/sessions-row.ts +++ b/packages/gateway-protocol/src/schema/sessions-row.ts @@ -135,3 +135,4 @@ export const SessionRowSchema = Type.Object( export type SessionCreatedActor = Static; export type SessionToolOverrides = Static; export type SessionRow = Static; +export type SessionRunStatus = NonNullable; diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index ed32af118e4b..814aefe4e09a 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -17,6 +17,7 @@ export { SessionToolOverridesSchema, type SessionCreatedActor, type SessionRow, + type SessionRunStatus, type SessionToolOverrides, } from "./sessions-row.js"; diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts index f2034668d918..cd35c2d98d6b 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts @@ -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; diff --git a/src/agents/tools/sessions-helpers.ts b/src/agents/tools/sessions-helpers.ts index b0eea9b4c363..22cd23ee8ca4 100644 --- a/src/agents/tools/sessions-helpers.ts +++ b/src/agents/tools/sessions-helpers.ts @@ -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; diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index ddc0b5ae04a6..157507cf90aa 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -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({ diff --git a/src/auto-reply/reply/directive-handling.parse.ts b/src/auto-reply/reply/directive-handling.parse.ts index 147196c15971..a40d36895196 100644 --- a/src/auto-reply/reply/directive-handling.parse.ts +++ b/src/auto-reply/reply/directive-handling.parse.ts @@ -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, diff --git a/src/auto-reply/reply/get-reply.types.ts b/src/auto-reply/reply/get-reply.types.ts index 638383907682..29d05b48fe2c 100644 --- a/src/auto-reply/reply/get-reply.types.ts +++ b/src/auto-reply/reply/get-reply.types.ts @@ -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"; diff --git a/src/auto-reply/reply/queue/directive.ts b/src/auto-reply/reply/queue/directive.ts index d28effe5e148..2758e8395151 100644 --- a/src/auto-reply/reply/queue/directive.ts +++ b/src/auto-reply/reply/queue/directive.ts @@ -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 { diff --git a/src/auto-reply/reply/queue/normalize.ts b/src/auto-reply/reply/queue/normalize.ts index bbdbd604cc73..8c479997a6c2 100644 --- a/src/auto-reply/reply/queue/normalize.ts +++ b/src/auto-reply/reply/queue/normalize.ts @@ -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 { diff --git a/src/auto-reply/reply/queue/state.ts b/src/auto-reply/reply/queue/state.ts index f6a8e5b0a2a6..b75c7452434c 100644 --- a/src/auto-reply/reply/queue/state.ts +++ b/src/auto-reply/reply/queue/state.ts @@ -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"; diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index b5911191e5ff..318a9c9548e0 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -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 = { diff --git a/src/config/sessions/session-transcript-turn-lifecycle.types.ts b/src/config/sessions/session-transcript-turn-lifecycle.types.ts index cf5213f9d2be..0e040556cacc 100644 --- a/src/config/sessions/session-transcript-turn-lifecycle.types.ts +++ b/src/config/sessions/session-transcript-turn-lifecycle.types.ts @@ -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; diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 24779bf74599..1c22ece67eef 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -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"; diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts index 91c2dc9b86c3..92205bac5f8b 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -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"; diff --git a/src/config/types.queue.ts b/src/config/types.queue.ts index 8ea328a12f0c..c50f7f0973dd 100644 --- a/src/config/types.queue.ts +++ b/src/config/types.queue.ts @@ -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 = { diff --git a/src/gateway/server-methods/chat-send-request.ts b/src/gateway/server-methods/chat-send-request.ts index cc2d6e471c92..89bc471e2906 100644 --- a/src/gateway/server-methods/chat-send-request.ts +++ b/src/gateway/server-methods/chat-send-request.ts @@ -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"; diff --git a/src/gateway/session-lifecycle-state.ts b/src/gateway/session-lifecycle-state.ts index 31821dbaf841..9b8ec6079824 100644 --- a/src/gateway/session-lifecycle-state.ts +++ b/src/gateway/session-lifecycle-state.ts @@ -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"; diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 417892557d47..e7b2b4e3da52 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -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< diff --git a/src/state/openclaw-agent-db-schema.ts b/src/state/openclaw-agent-db-schema.ts index 14995026437d..861ad90877cd 100644 --- a/src/state/openclaw-agent-db-schema.ts +++ b/src/state/openclaw-agent-db-schema.ts @@ -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" || diff --git a/ui/src/api/gateway.ts b/ui/src/api/gateway.ts index 7f3e7090eaa5..1d9811e3d29e 100644 --- a/ui/src/api/gateway.ts +++ b/ui/src/api/gateway.ts @@ -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 diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 80276a0c8ef5..88f4e3bb0ae1 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -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[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; diff --git a/ui/src/app/native-link-routing.ts b/ui/src/app/native-link-routing.ts index d53f4a85a091..7ff3bfb98eae 100644 --- a/ui/src/app/native-link-routing.ts +++ b/ui/src/app/native-link-routing.ts @@ -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); diff --git a/ui/src/app/user-profile.test.ts b/ui/src/app/user-profile.test.ts index cd4d61c9961c..e0f57d2e6927 100644 --- a/ui/src/app/user-profile.test.ts +++ b/ui/src/app/user-profile.test.ts @@ -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( diff --git a/ui/src/app/web-push.ts b/ui/src/app/web-push.ts index 547004144c21..a10766e5d3fe 100644 --- a/ui/src/app/web-push.ts +++ b/ui/src/app/web-push.ts @@ -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; diff --git a/ui/src/components/app-sidebar-nav-menus.ts b/ui/src/components/app-sidebar-nav-menus.ts index fc71c3992b81..4e29ee23f7d3 100644 --- a/ui/src/components/app-sidebar-nav-menus.ts +++ b/ui/src/components/app-sidebar-nav-menus.ts @@ -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; } diff --git a/ui/src/components/app-sidebar-render.ts b/ui/src/components/app-sidebar-render.ts index 701b7f1883a4..49d91edc8dee 100644 --- a/ui/src/components/app-sidebar-render.ts +++ b/ui/src/components/app-sidebar-render.ts @@ -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"; diff --git a/ui/src/components/app-sidebar-session-catalog-render.ts b/ui/src/components/app-sidebar-session-catalog-render.ts index 6b161a32733e..8216e6f60790 100644 --- a/ui/src/components/app-sidebar-session-catalog-render.ts +++ b/ui/src/components/app-sidebar-session-catalog-render.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, diff --git a/ui/src/components/app-sidebar-session-navigation.ts b/ui/src/components/app-sidebar-session-navigation.ts index 0e8154281399..0194fc0c4dc0 100644 --- a/ui/src/components/app-sidebar-session-navigation.ts +++ b/ui/src/components/app-sidebar-session-navigation.ts @@ -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(); 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); diff --git a/ui/src/components/app-sidebar-workboard.runtime.ts b/ui/src/components/app-sidebar-workboard.runtime.ts index ba261d344140..5b235ba05a80 100644 --- a/ui/src/components/app-sidebar-workboard.runtime.ts +++ b/ui/src/components/app-sidebar-workboard.runtime.ts @@ -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(); diff --git a/ui/src/components/lobster-pet-contract.ts b/ui/src/components/lobster-pet-contract.ts index ccb0c079edda..6bff875b7f59 100644 --- a/ui/src/components/lobster-pet-contract.ts +++ b/ui/src/components/lobster-pet-contract.ts @@ -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; diff --git a/ui/src/components/settings-sidebar.ts b/ui/src/components/settings-sidebar.ts index cce1370bb0e9..1ad3ca6bfece 100644 --- a/ui/src/components/settings-sidebar.ts +++ b/ui/src/components/settings-sidebar.ts @@ -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(); diff --git a/ui/src/components/sidebar-build-chip.ts b/ui/src/components/sidebar-build-chip.ts index 463577c24e37..855e25bda6f5 100644 --- a/ui/src/components/sidebar-build-chip.ts +++ b/ui/src/components/sidebar-build-chip.ts @@ -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; diff --git a/ui/src/lib/chat/follow-up-mode.ts b/ui/src/lib/chat/follow-up-mode.ts index e87cb3bd3fa1..7c689bab57f2 100644 --- a/ui/src/lib/chat/follow-up-mode.ts +++ b/ui/src/lib/chat/follow-up-mode.ts @@ -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"; diff --git a/ui/src/lib/format-error.test.ts b/ui/src/lib/format-error.test.ts new file mode 100644 index 000000000000..ddb1139aba91 --- /dev/null +++ b/ui/src/lib/format-error.test.ts @@ -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"); + }); +}); diff --git a/ui/src/lib/format-error.ts b/ui/src/lib/format-error.ts new file mode 100644 index 000000000000..9d9aacfe51af --- /dev/null +++ b/ui/src/lib/format-error.ts @@ -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; +} diff --git a/ui/src/lib/navigation-click.test.ts b/ui/src/lib/navigation-click.test.ts new file mode 100644 index 000000000000..f8143e42f111 --- /dev/null +++ b/ui/src/lib/navigation-click.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { shouldHandleNavigationClick } from "./navigation-click.ts"; + +function clickEvent(overrides: Partial = {}): 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]>; + +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); + }); +}); diff --git a/ui/src/lib/navigation-click.ts b/ui/src/lib/navigation-click.ts new file mode 100644 index 000000000000..3ab94438af8e --- /dev/null +++ b/ui/src/lib/navigation-click.ts @@ -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 + ); +} diff --git a/ui/src/lib/nodes/inventory.test.ts b/ui/src/lib/nodes/inventory.test.ts index 9adb714b3032..f5157c941857 100644 --- a/ui/src/lib/nodes/inventory.test.ts +++ b/ui/src/lib/nodes/inventory.test.ts @@ -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, diff --git a/ui/src/lib/skills/index.test.ts b/ui/src/lib/skills/index.test.ts index 4c664bccff2e..4b0e544791ff 100644 --- a/ui/src/lib/skills/index.test.ts +++ b/ui/src/lib/skills/index.test.ts @@ -1385,7 +1385,7 @@ describe("reconcileSkillsAgentId", () => { reconcileSkillsAgentId(state, { defaultId: "main", mainKey: "main", - scope: "project", + scope: "per-sender", agents: [{ id: "main" }], }); diff --git a/ui/src/lib/skills/index.ts b/ui/src/lib/skills/index.ts index 354123a8fee4..a3455fec73b1 100644 --- a/ui/src/lib/skills/index.ts +++ b/ui/src/lib/skills/index.ts @@ -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 } diff --git a/ui/src/pages/agents/agents-page.test.ts b/ui/src/pages/agents/agents-page.test.ts index 6c9fbefba925..a49c472545f0 100644 --- a/ui/src/pages/agents/agents-page.test.ts +++ b/ui/src/pages/agents/agents-page.test.ts @@ -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", diff --git a/ui/src/pages/agents/agents-view.test-helpers.ts b/ui/src/pages/agents/agents-view.test-helpers.ts index 00a8096092c4..8aba6a4b5987 100644 --- a/ui/src/pages/agents/agents-view.test-helpers.ts +++ b/ui/src/pages/agents/agents-view.test-helpers.ts @@ -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", diff --git a/ui/src/pages/agents/identity-actions.ts b/ui/src/pages/agents/identity-actions.ts index 51a2eb0a11f3..669757da7b29 100644 --- a/ui/src/pages/agents/identity-actions.ts +++ b/ui/src/pages/agents/identity-actions.ts @@ -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()) { diff --git a/ui/src/pages/agents/view.test.ts b/ui/src/pages/agents/view.test.ts index 66affd6f125d..33ed251c6be6 100644 --- a/ui/src/pages/agents/view.test.ts +++ b/ui/src/pages/agents/view.test.ts @@ -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, diff --git a/ui/src/pages/chat/chat-composer-capability-host.ts b/ui/src/pages/chat/chat-composer-capability-host.ts index ba8b1be9628d..28e0f9af8052 100644 --- a/ui/src/pages/chat/chat-composer-capability-host.ts +++ b/ui/src/pages/chat/chat-composer-capability-host.ts @@ -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()) { diff --git a/ui/src/pages/chat/chat-send-request.ts b/ui/src/pages/chat/chat-send-request.ts index 8386a3f566ba..067a065ed165 100644 --- a/ui/src/pages/chat/chat-send-request.ts +++ b/ui/src/pages/chat/chat-send-request.ts @@ -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"; diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index 2b0cffd29d81..b8cfa3264649 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -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(); - 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(); - 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({ diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 36920e951ca0..fafc05992910 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -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; diff --git a/ui/src/pages/chat/components/chat-composer-types.ts b/ui/src/pages/chat/components/chat-composer-types.ts index 9cbd762cd1a2..60c563343b36 100644 --- a/ui/src/pages/chat/components/chat-composer-types.ts +++ b/ui/src/pages/chat/components/chat-composer-types.ts @@ -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; diff --git a/ui/src/pages/chat/steer-lifecycle.ts b/ui/src/pages/chat/steer-lifecycle.ts index a7a95ca5d90b..eba71bfe4f0c 100644 --- a/ui/src/pages/chat/steer-lifecycle.ts +++ b/ui/src/pages/chat/steer-lifecycle.ts @@ -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"; diff --git a/ui/src/pages/config/memory-memories.ts b/ui/src/pages/config/memory-memories.ts index 02a1e3c54123..b396cf2027e3 100644 --- a/ui/src/pages/config/memory-memories.ts +++ b/ui/src/pages/config/memory-memories.ts @@ -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) { diff --git a/ui/src/pages/config/memory-page.ts b/ui/src/pages/config/memory-page.ts index cb09c16db5e2..ea66f5affe13 100644 --- a/ui/src/pages/config/memory-page.ts +++ b/ui/src/pages/config/memory-page.ts @@ -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 { diff --git a/ui/src/pages/config/view-appearance-preferences.ts b/ui/src/pages/config/view-appearance-preferences.ts index d948072d3436..0c28c85663e8 100644 --- a/ui/src/pages/config/view-appearance-preferences.ts +++ b/ui/src/pages/config/view-appearance-preferences.ts @@ -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")}` diff --git a/ui/src/pages/config/view-types.ts b/ui/src/pages/config/view-types.ts index 570ec2c2cc84..d69996e9d62b 100644 --- a/ui/src/pages/config/view-types.ts +++ b/ui/src/pages/config/view-types.ts @@ -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"; diff --git a/ui/src/pages/config/view.browser.test.ts b/ui/src/pages/config/view.browser.test.ts index 5b84e9d80e75..0fde9452b829 100644 --- a/ui/src/pages/config/view.browser.test.ts +++ b/ui/src/pages/config/view.browser.test.ts @@ -2169,7 +2169,15 @@ describe("config view", () => { unseen?.closest("openclaw-tooltip")?.querySelector('[slot="content"]')?.textContent, ).toContain("Ripe when thumped."); - container.querySelector(".lobsterdex__open")?.click(); + const openLink = container.querySelector(".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"); diff --git a/ui/src/pages/cron/view-runs.ts b/ui/src/pages/cron/view-runs.ts index aabeda8ff41c..c7fe5c9e9f98 100644 --- a/ui/src/pages/cron/view-runs.ts +++ b/ui/src/pages/cron/view-runs.ts @@ -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) { diff --git a/ui/src/pages/custodian/custodian-session-store.test.ts b/ui/src/pages/custodian/custodian-session-store.test.ts index 8f7108ed5419..caff401e3620 100644 --- a/ui/src/pages/custodian/custodian-session-store.test.ts +++ b/ui/src/pages/custodian/custodian-session-store.test.ts @@ -88,7 +88,7 @@ describe("CustodianSessionStore", () => { agentsList: { defaultId: "main", mainKey: "main", - scope: "agent", + scope: "per-sender", agents: [{ id: "main" }], }, }); diff --git a/ui/src/pages/debug/debug-page.ts b/ui/src/pages/debug/debug-page.ts index 519e37225957..a8f49a7bafa0 100644 --- a/ui/src/pages/debug/debug-page.ts +++ b/ui/src/pages/debug/debug-page.ts @@ -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 { - 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 { diff --git a/ui/src/pages/gateway-source-replacement.test.ts b/ui/src/pages/gateway-source-replacement.test.ts index f88e5d0d573e..4f6be3b74a00 100644 --- a/ui/src/pages/gateway-source-replacement.test.ts +++ b/ui/src/pages/gateway-source-replacement.test.ts @@ -633,16 +633,15 @@ describe("gateway source replacement across reconnect with a reused client", () const pending = deferred(); 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; 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)); diff --git a/ui/src/pages/logs/logs-page.test.ts b/ui/src/pages/logs/logs-page.test.ts index 6da8381a1280..405646fe03d1 100644 --- a/ui/src/pages/logs/logs-page.test.ts +++ b/ui/src/pages/logs/logs-page.test.ts @@ -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; - applyGatewaySnapshot: (snapshot: ApplicationGatewaySnapshot) => void; loadLogs: (opts?: { reset?: boolean; quiet?: boolean }) => Promise; requestUpdate: () => void; }; +type TestGateway = ApplicationContext["gateway"] & { + publish: (snapshot: ApplicationGatewaySnapshot) => void; +}; + +type TestApplicationContext = ApplicationContext & { gateway: TestGateway }; + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((resolvePromise) => { @@ -29,16 +33,35 @@ function deferred() { 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(); diff --git a/ui/src/pages/logs/logs-page.ts b/ui/src/pages/logs/logs-page.ts index 38528bf585f3..637dbe11403b 100644 --- a/ui/src/pages/logs/logs-page.ts +++ b/ui/src/pages/logs/logs-page.ts @@ -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 { 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; diff --git a/ui/src/pages/new-session/cloud-target.ts b/ui/src/pages/new-session/cloud-target.ts index 611a2b683c7a..6e8f46e1cd60 100644 --- a/ui/src/pages/new-session/cloud-target.ts +++ b/ui/src/pages/new-session/cloud-target.ts @@ -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, }; } diff --git a/ui/src/pages/new-session/route.test.ts b/ui/src/pages/new-session/route.test.ts index 11fb006d5754..4547b8bc6f3a 100644 --- a/ui/src/pages/new-session/route.test.ts +++ b/ui/src/pages/new-session/route.test.ts @@ -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"); diff --git a/ui/src/pages/nodes/nodes-page.test.ts b/ui/src/pages/nodes/nodes-page.test.ts index 6ba0c3a73e98..4420eaa4e813 100644 --- a/ui/src/pages/nodes/nodes-page.test.ts +++ b/ui/src/pages/nodes/nodes-page.test.ts @@ -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([]); diff --git a/ui/src/pages/nodes/view.devices.test.ts b/ui/src/pages/nodes/view.devices.test.ts index 2154ccef555a..94d14a715308 100644 --- a/ui/src/pages/nodes/view.devices.test.ts +++ b/ui/src/pages/nodes/view.devices.test.ts @@ -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"); diff --git a/ui/src/pages/plugins/plugins-page.ts b/ui/src/pages/plugins/plugins-page.ts index b2019664471f..181d9113ecfd 100644 --- a/ui/src/pages/plugins/plugins-page.ts +++ b/ui/src/pages/plugins/plugins-page.ts @@ -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 { @@ -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) { diff --git a/ui/src/pages/plugins/route.ts b/ui/src/pages/plugins/route.ts index 0c3bcfea14cb..78bde2613810 100644 --- a/ui/src/pages/plugins/route.ts +++ b/ui/src/pages/plugins/route.ts @@ -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, }; } diff --git a/ui/src/pages/sessions/view.ts b/ui/src/pages/sessions/view.ts index 3879b67ab26d..fac9a7c2f1f9 100644 --- a/ui/src/pages/sessions/view.ts +++ b/ui/src/pages/sessions/view.ts @@ -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) { diff --git a/ui/src/pages/skill-workshop/history-scan.ts b/ui/src/pages/skill-workshop/history-scan.ts index d276dbba778b..2c421736cbdf 100644 --- a/ui/src/pages/skill-workshop/history-scan.ts +++ b/ui/src/pages/skill-workshop/history-scan.ts @@ -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( "skills.proposals.historyStatus", diff --git a/ui/src/pages/skill-workshop/proposals.ts b/ui/src/pages/skill-workshop/proposals.ts index f8d686a25084..cc0262373e24 100644 --- a/ui/src/pages/skill-workshop/proposals.ts +++ b/ui/src/pages/skill-workshop/proposals.ts @@ -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 ( diff --git a/ui/src/pages/skills/route.ts b/ui/src/pages/skills/route.ts index 362657351cf9..cb022c97e1e9 100644 --- a/ui/src/pages/skills/route.ts +++ b/ui/src/pages/skills/route.ts @@ -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 = {}): 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" } }, diff --git a/ui/src/pages/tasks/view.ts b/ui/src/pages/tasks/view.ts index bb6de9e04c86..55684f33065c 100644 --- a/ui/src/pages/tasks/view.ts +++ b/ui/src/pages/tasks/view.ts @@ -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(); diff --git a/ui/src/pages/workboard/view.test.ts b/ui/src/pages/workboard/view.test.ts index 6e264b9f69a3..e8c09f575821 100644 --- a/ui/src/pages/workboard/view.test.ts +++ b/ui/src/pages/workboard/view.test.ts @@ -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 = { 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" } }], }, }); diff --git a/ui/src/pages/worktrees/worktrees-page.ts b/ui/src/pages/worktrees/worktrees-page.ts index 32672e9bad91..6e4c8a6971a3 100644 --- a/ui/src/pages/worktrees/worktrees-page.ts +++ b/ui/src/pages/worktrees/worktrees-page.ts @@ -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, diff --git a/ui/src/test-helpers/app-sidebar-cases/agent-menu.ts b/ui/src/test-helpers/app-sidebar-cases/agent-menu.ts index 558d100491f5..317acdda20f9 100644 --- a/ui/src/test-helpers/app-sidebar-cases/agent-menu.ts +++ b/ui/src/test-helpers/app-sidebar-cases/agent-menu.ts @@ -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: "🦞" } }], }, ); diff --git a/ui/src/test-helpers/app-sidebar-cases/basics.ts b/ui/src/test-helpers/app-sidebar-cases/basics.ts index 656f44c97b4b..b3377d6a7a98 100644 --- a/ui/src/test-helpers/app-sidebar-cases/basics.ts +++ b/ui/src/test-helpers/app-sidebar-cases/basics.ts @@ -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( diff --git a/ui/src/test-helpers/app-sidebar-cases/catalog-compat.ts b/ui/src/test-helpers/app-sidebar-cases/catalog-compat.ts index da80cfba930f..aee98399e89a 100644 --- a/ui/src/test-helpers/app-sidebar-cases/catalog-compat.ts +++ b/ui/src/test-helpers/app-sidebar-cases/catalog-compat.ts @@ -22,7 +22,7 @@ describe("AppSidebar session catalog pagination", () => { { defaultId: "main", mainKey: "main", - scope: "agent", + scope: "per-sender", agents: [{ id: "main" }, { id: "research" }], }, ); diff --git a/ui/src/test-helpers/app-sidebar-cases/catalog-live-errors.ts b/ui/src/test-helpers/app-sidebar-cases/catalog-live-errors.ts index a9d8e56bea29..02bce812e5a7 100644 --- a/ui/src/test-helpers/app-sidebar-cases/catalog-live-errors.ts +++ b/ui/src/test-helpers/app-sidebar-cases/catalog-live-errors.ts @@ -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" }], }, ); diff --git a/ui/src/test-helpers/app-sidebar-cases/outbox-badges.ts b/ui/src/test-helpers/app-sidebar-cases/outbox-badges.ts index 7a5a3710b081..a44af8540de9 100644 --- a/ui/src/test-helpers/app-sidebar-cases/outbox-badges.ts +++ b/ui/src/test-helpers/app-sidebar-cases/outbox-badges.ts @@ -34,7 +34,7 @@ describe("AppSidebar outbox badges", () => { { defaultId: "main", mainKey: "main", - scope: "agent", + scope: "per-sender", agents: [{ id: "main" }], }, ); diff --git a/ui/src/test-helpers/app-sidebar.ts b/ui/src/test-helpers/app-sidebar.ts index 33f5b8121d72..f471e43eeff7 100644 --- a/ui/src/test-helpers/app-sidebar.ts +++ b/ui/src/test-helpers/app-sidebar.ts @@ -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;