From fc5920fb51346a682b269fdbeeec0971ae4ae723 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 25 Apr 2026 06:27:22 -0500 Subject: [PATCH] fix(ui): polish assistant identity settings Polishes the basic config identity layout, aligns assistant avatar rendering with chat, and adds a Control UI assistant avatar override with IDENTITY.md fallback. --- .../OpenClawProtocol/GatewayModels.swift | 12 + .../OpenClawProtocol/GatewayModels.swift | 12 + src/agents/identity-avatar.test.ts | 54 +- src/agents/identity-avatar.ts | 62 +- src/agents/pi-tools.safe-bins.test.ts | 1 + .../heartbeat-filter.browser-import.test.ts | 25 + src/auto-reply/heartbeat.ts | 2 +- src/auto-reply/tokens.ts | 2 +- src/gateway/control-ui-contract.ts | 3 + src/gateway/control-ui.http.test.ts | 32 +- src/gateway/control-ui.ts | 63 +- src/gateway/protocol/schema/agent.ts | 3 + src/gateway/server-methods/agent.test.ts | 30 + src/gateway/server-methods/agent.ts | 17 +- src/node-host/invoke-system-run.test.ts | 56 +- src/shared/regexp.ts | 3 + src/utils.ts | 8 +- ui/src/styles/config-quick.css | 501 +++++++++++++-- ui/src/styles/config-quick.test.ts | 32 +- ui/src/ui/app-chat.test.ts | 33 +- ui/src/ui/app-chat.ts | 54 +- ui/src/ui/app-gateway.sessions.node.test.ts | 1 + ui/src/ui/app-gateway.ts | 2 + ui/src/ui/app-lifecycle.ts | 3 + ui/src/ui/app-render.helpers.ts | 3 + ui/src/ui/app-render.ts | 159 +++-- ui/src/ui/app-view-state.ts | 8 + ui/src/ui/app.ts | 8 + ui/src/ui/assistant-identity.ts | 17 +- ui/src/ui/chat/grouped-render.test.ts | 80 ++- ui/src/ui/chat/grouped-render.ts | 41 +- .../ui/controllers/assistant-identity.test.ts | 45 ++ ui/src/ui/controllers/assistant-identity.ts | 34 ++ ui/src/ui/controllers/config.test.ts | 109 ++++ ui/src/ui/controllers/config.ts | 37 +- .../controllers/control-ui-bootstrap.test.ts | 9 + ui/src/ui/controllers/control-ui-bootstrap.ts | 9 + ui/src/ui/types.ts | 3 + ui/src/ui/views/agents-utils.test.ts | 8 + ui/src/ui/views/agents-utils.ts | 32 + ui/src/ui/views/chat.test.ts | 48 ++ ui/src/ui/views/chat.ts | 9 +- ui/src/ui/views/config-presets.test.ts | 50 ++ ui/src/ui/views/config-presets.ts | 37 +- ui/src/ui/views/config-quick.test.ts | 197 +++++- ui/src/ui/views/config-quick.ts | 569 ++++++++++++++++-- 46 files changed, 2249 insertions(+), 274 deletions(-) create mode 100644 src/auto-reply/heartbeat-filter.browser-import.test.ts create mode 100644 src/shared/regexp.ts create mode 100644 ui/src/ui/controllers/assistant-identity.test.ts create mode 100644 ui/src/ui/views/config-presets.test.ts diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 009266b2ecc..c0e02d76b79 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -723,17 +723,26 @@ public struct AgentIdentityResult: Codable, Sendable { public let agentid: String public let name: String? public let avatar: String? + public let avatarsource: String? + public let avatarstatus: String? + public let avatarreason: String? public let emoji: String? public init( agentid: String, name: String?, avatar: String?, + avatarsource: String?, + avatarstatus: String?, + avatarreason: String?, emoji: String?) { self.agentid = agentid self.name = name self.avatar = avatar + self.avatarsource = avatarsource + self.avatarstatus = avatarstatus + self.avatarreason = avatarreason self.emoji = emoji } @@ -741,6 +750,9 @@ public struct AgentIdentityResult: Codable, Sendable { case agentid = "agentId" case name case avatar + case avatarsource = "avatarSource" + case avatarstatus = "avatarStatus" + case avatarreason = "avatarReason" case emoji } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 009266b2ecc..c0e02d76b79 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -723,17 +723,26 @@ public struct AgentIdentityResult: Codable, Sendable { public let agentid: String public let name: String? public let avatar: String? + public let avatarsource: String? + public let avatarstatus: String? + public let avatarreason: String? public let emoji: String? public init( agentid: String, name: String?, avatar: String?, + avatarsource: String?, + avatarstatus: String?, + avatarreason: String?, emoji: String?) { self.agentid = agentid self.name = name self.avatar = avatar + self.avatarsource = avatarsource + self.avatarstatus = avatarstatus + self.avatarreason = avatarreason self.emoji = emoji } @@ -741,6 +750,9 @@ public struct AgentIdentityResult: Codable, Sendable { case agentid = "agentId" case name case avatar + case avatarsource = "avatarSource" + case avatarstatus = "avatarStatus" + case avatarreason = "avatarReason" case emoji } } diff --git a/src/agents/identity-avatar.test.ts b/src/agents/identity-avatar.test.ts index 9cefafe51a1..ff023d04b63 100644 --- a/src/agents/identity-avatar.test.ts +++ b/src/agents/identity-avatar.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js"; -import { resolveAgentAvatar } from "./identity-avatar.js"; +import { resolveAgentAvatar, resolvePublicAgentAvatarSource } from "./identity-avatar.js"; async function writeFile(filePath: string, contents = "avatar") { await fs.mkdir(path.dirname(filePath), { recursive: true }); @@ -138,9 +138,55 @@ describe("resolveAgentAvatar", () => { expect(resolved.kind).toBe("none"); if (resolved.kind === "none") { expect(resolved.reason).toBe("missing"); + expect(resolved.source).toBe("avatars/missing.png"); + expect(resolvePublicAgentAvatarSource(resolved)).toBe("avatars/missing.png"); } }); + it("redacts unsafe public avatar sources", async () => { + const root = await createTempAvatarRoot(); + const workspace = path.join(root, "work"); + await fs.mkdir(workspace, { recursive: true }); + const outsidePath = path.join(root, "outside.png"); + await writeFile(outsidePath); + + const absolute = resolveAgentAvatar( + { + agents: { + list: [{ id: "main", workspace, identity: { avatar: outsidePath } }], + }, + }, + "main", + ); + expect(absolute.kind).toBe("none"); + expect(resolvePublicAgentAvatarSource(absolute)).toBeUndefined(); + + expect( + resolvePublicAgentAvatarSource({ + kind: "remote", + source: "https://example.com/avatar.png?token=secret", + }), + ).toBe("remote URL"); + expect( + resolvePublicAgentAvatarSource({ + kind: "data", + source: "data:image/png;base64,aaaaaaaa", + }), + ).toBe("data:image/png;base64,..."); + expect( + resolvePublicAgentAvatarSource({ + kind: "none", + source: "../secret.png", + }), + ).toBeUndefined(); + expect( + resolvePublicAgentAvatarSource({ + kind: "none", + source: "file:///Users/test/private/avatar.png", + }), + ).toBeUndefined(); + }); + it("rejects local avatars larger than max bytes", async () => { const root = await createTempAvatarRoot(); const workspace = path.join(root, "work"); @@ -173,9 +219,15 @@ describe("resolveAgentAvatar", () => { const remote = resolveAgentAvatar(cfg, "main"); expect(remote.kind).toBe("remote"); + if (remote.kind === "remote") { + expect(remote.source).toBe("https://example.com/avatar.png"); + } const data = resolveAgentAvatar(cfg, "data"); expect(data.kind).toBe("data"); + if (data.kind === "data") { + expect(data.source).toBe("data:image/png;base64,aaaa"); + } }); it("resolves local avatar from ui.assistant.avatar when no agents.list identity is set", async () => { diff --git a/src/agents/identity-avatar.ts b/src/agents/identity-avatar.ts index 2960c8ba6ec..9f04d63f30e 100644 --- a/src/agents/identity-avatar.ts +++ b/src/agents/identity-avatar.ts @@ -3,8 +3,10 @@ import path from "node:path"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { AVATAR_MAX_BYTES, + hasAvatarUriScheme, isAvatarDataUrl, isAvatarHttpUrl, + isWindowsAbsolutePath, isPathWithinRoot, isSupportedLocalAvatarExtension, } from "../shared/avatar-policy.js"; @@ -15,10 +17,18 @@ import { loadAgentIdentityFromWorkspace } from "./identity-file.js"; import { resolveAgentIdentity } from "./identity.js"; export type AgentAvatarResolution = - | { kind: "none"; reason: string } - | { kind: "local"; filePath: string } - | { kind: "remote"; url: string } - | { kind: "data"; url: string }; + | { kind: "none"; reason: string; source?: string } + | { kind: "local"; filePath: string; source: string } + | { kind: "remote"; url: string; source: string } + | { kind: "data"; url: string; source: string }; + +type AgentAvatarPublicSourceInput = { + kind: AgentAvatarResolution["kind"]; + source?: string | null; +}; + +const PUBLIC_AVATAR_SOURCE_MAX_CHARS = 256; +const PUBLIC_DATA_AVATAR_HEADER_MAX_CHARS = 64; function resolveAvatarSource( cfg: OpenClawConfig, @@ -80,6 +90,42 @@ function resolveLocalAvatarPath(params: { return { ok: true, filePath: realPath }; } +function isSafeRelativeAvatarSource(source: string): boolean { + if ( + source.length > PUBLIC_AVATAR_SOURCE_MAX_CHARS || + source.startsWith("~") || + path.isAbsolute(source) || + isWindowsAbsolutePath(source) || + (hasAvatarUriScheme(source) && !isWindowsAbsolutePath(source)) || + source.includes("\0") + ) { + return false; + } + const parts = source.replace(/\\/g, "/").split("/"); + return parts.every((part) => part !== ".."); +} + +export function resolvePublicAgentAvatarSource( + resolved: AgentAvatarPublicSourceInput, +): string | undefined { + const source = normalizeOptionalString(resolved.source) ?? null; + if (!source) { + return undefined; + } + if (isAvatarDataUrl(source)) { + const commaIndex = source.indexOf(","); + const header = + commaIndex > 0 + ? source.slice(0, Math.min(commaIndex, PUBLIC_DATA_AVATAR_HEADER_MAX_CHARS)) + : source.slice(0, PUBLIC_DATA_AVATAR_HEADER_MAX_CHARS); + return `${header},...`; + } + if (isAvatarHttpUrl(source)) { + return "remote URL"; + } + return isSafeRelativeAvatarSource(source) ? source : undefined; +} + export function resolveAgentAvatar( cfg: OpenClawConfig, agentId: string, @@ -90,15 +136,15 @@ export function resolveAgentAvatar( return { kind: "none", reason: "missing" }; } if (isAvatarHttpUrl(source)) { - return { kind: "remote", url: source }; + return { kind: "remote", url: source, source }; } if (isAvatarDataUrl(source)) { - return { kind: "data", url: source }; + return { kind: "data", url: source, source }; } const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const resolved = resolveLocalAvatarPath({ raw: source, workspaceDir }); if (!resolved.ok) { - return { kind: "none", reason: resolved.reason }; + return { kind: "none", reason: resolved.reason, source }; } - return { kind: "local", filePath: resolved.filePath }; + return { kind: "local", filePath: resolved.filePath, source }; } diff --git a/src/agents/pi-tools.safe-bins.test.ts b/src/agents/pi-tools.safe-bins.test.ts index a9f40eb0b25..e375b15c467 100644 --- a/src/agents/pi-tools.safe-bins.test.ts +++ b/src/agents/pi-tools.safe-bins.test.ts @@ -230,6 +230,7 @@ async function withSafeBinsExecTool( await withEnvAsync( { OPENCLAW_SHELL_ENV_TIMEOUT_MS: "1", + PATH: "/usr/bin:/bin", SHELL: "/bin/sh", }, async () => { diff --git a/src/auto-reply/heartbeat-filter.browser-import.test.ts b/src/auto-reply/heartbeat-filter.browser-import.test.ts new file mode 100644 index 00000000000..f6644bd21d1 --- /dev/null +++ b/src/auto-reply/heartbeat-filter.browser-import.test.ts @@ -0,0 +1,25 @@ +import { build } from "esbuild"; +import { describe, expect, it } from "vitest"; + +describe("heartbeat-filter browser import", () => { + it("does not pull node-only utils into browser bundles", async () => { + const bundled = await build({ + bundle: true, + format: "esm", + metafile: true, + platform: "browser", + stdin: { + contents: [ + 'import { isHeartbeatOkResponse } from "./src/auto-reply/heartbeat-filter.ts";', + "globalThis.__heartbeatOk = isHeartbeatOkResponse;", + ].join("\n"), + loader: "ts", + resolveDir: process.cwd(), + sourcefile: "heartbeat-filter-browser-entry.ts", + }, + write: false, + }); + + expect(Object.keys(bundled.metafile.inputs)).not.toContain("src/utils.ts"); + }); +}); diff --git a/src/auto-reply/heartbeat.ts b/src/auto-reply/heartbeat.ts index 8c3bda3043e..c9906c052dd 100644 --- a/src/auto-reply/heartbeat.ts +++ b/src/auto-reply/heartbeat.ts @@ -1,6 +1,6 @@ import { parseDurationMs } from "../cli/parse-duration.js"; +import { escapeRegExp } from "../shared/regexp.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; -import { escapeRegExp } from "../utils.js"; import { HEARTBEAT_TOKEN } from "./tokens.js"; export type HeartbeatTask = { diff --git a/src/auto-reply/tokens.ts b/src/auto-reply/tokens.ts index 3385fde95d8..d6f32fabeb6 100644 --- a/src/auto-reply/tokens.ts +++ b/src/auto-reply/tokens.ts @@ -1,4 +1,4 @@ -import { escapeRegExp } from "../utils.js"; +import { escapeRegExp } from "../shared/regexp.js"; export const HEARTBEAT_TOKEN = "HEARTBEAT_OK"; export const SILENT_REPLY_TOKEN = "NO_REPLY"; diff --git a/src/gateway/control-ui-contract.ts b/src/gateway/control-ui-contract.ts index 7a0164fbd0c..7e0ba402725 100644 --- a/src/gateway/control-ui-contract.ts +++ b/src/gateway/control-ui-contract.ts @@ -6,6 +6,9 @@ export type ControlUiBootstrapConfig = { basePath: string; assistantName: string; assistantAvatar: string; + assistantAvatarSource?: string | null; + assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null; + assistantAvatarReason?: string | null; assistantAgentId: string; serverVersion?: string; localMediaPreviewRoots?: string[]; diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index 8aee0c18e02..c864845006e 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -98,7 +98,7 @@ describe("handleControlUiHttpRequest", () => { async function runAvatarRequest(params: { url: string; - method: "GET" | "HEAD"; + method: "GET" | "HEAD" | "POST"; resolveAvatar: Parameters[2]["resolveAvatar"]; basePath?: string; auth?: ResolvedGatewayAuth; @@ -791,13 +791,41 @@ describe("handleControlUiHttpRequest", () => { headers: { authorization: "Bearer test-token", }, - resolveAvatar: () => ({ kind: "remote", url: "https://example.com/avatar.png" }), + resolveAvatar: () => ({ + kind: "remote", + url: "https://example.com/avatar.png", + source: "https://example.com/avatar.png", + }), }); expect(handled).toBe(true); expect(res.statusCode).toBe(200); expect(JSON.parse(String(end.mock.calls[0]?.[0] ?? ""))).toEqual({ avatarUrl: "https://example.com/avatar.png", + avatarSource: "remote URL", + avatarStatus: "remote", + avatarReason: null, + }); + }); + + it("redacts unsafe avatar source values from metadata", async () => { + const { res, end, handled } = await runAvatarRequest({ + url: "/avatar/main?meta=1", + method: "GET", + resolveAvatar: () => ({ + kind: "none", + reason: "outside_workspace", + source: "/Users/test/private/avatar.png", + }), + }); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(JSON.parse(String(end.mock.calls[0]?.[0] ?? ""))).toEqual({ + avatarUrl: null, + avatarSource: null, + avatarStatus: "none", + avatarReason: "outside_workspace", }); }); diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index a939e4f282f..959c5fb1a73 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import type { IncomingMessage, ServerResponse } from "node:http"; import path from "node:path"; +import { resolveAgentAvatar, resolvePublicAgentAvatarSource } from "../agents/identity-avatar.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { @@ -134,15 +135,33 @@ const STATIC_ASSET_EXTENSIONS = new Set([ ]); export type ControlUiAvatarResolution = - | { kind: "none"; reason: string } - | { kind: "local"; filePath: string } - | { kind: "remote"; url: string } - | { kind: "data"; url: string }; + | { kind: "none"; reason: string; source?: string | null } + | { kind: "local"; filePath: string; source?: string | null } + | { kind: "remote"; url: string; source?: string | null } + | { kind: "data"; url: string; source?: string | null }; type ControlUiAvatarMeta = { avatarUrl: string | null; + avatarSource: string | null; + avatarStatus: ControlUiAvatarResolution["kind"]; + avatarReason: string | null; }; +function controlUiAvatarResolutionMeta(resolved: ControlUiAvatarResolution | null): { + avatarSource: string | null; + avatarStatus: ControlUiAvatarResolution["kind"] | null; + avatarReason: string | null; +} { + if (!resolved) { + return { avatarSource: null, avatarStatus: null, avatarReason: null }; + } + return { + avatarSource: resolvePublicAgentAvatarSource(resolved) ?? null, + avatarStatus: resolved.kind, + avatarReason: resolved.kind === "none" ? resolved.reason : null, + }; +} + function applyControlUiSecurityHeaders(res: ServerResponse) { res.setHeader("X-Frame-Options", "DENY"); res.setHeader("Content-Security-Policy", buildControlUiCspHeader()); @@ -251,6 +270,7 @@ async function authorizeControlUiReadRequest( allowRealIpFallback?: boolean; rateLimiter?: AuthRateLimiter; allowQueryToken?: boolean; + requiredOperatorMethod?: string; }, ): Promise { if (!opts?.auth) { @@ -313,7 +333,10 @@ async function authorizeControlUiReadRequest( const requestedScopes = resolveTrustedHttpOperatorScopes(req, { trustDeclaredOperatorScopes, }); - const scopeAuth = authorizeOperatorScopesForMethod("assistant.media.get", requestedScopes); + const scopeAuth = authorizeOperatorScopesForMethod( + opts.requiredOperatorMethod ?? "assistant.media.get", + requestedScopes, + ); if (!scopeAuth.allowed) { sendJson(res, 403, { ok: false, @@ -550,6 +573,13 @@ export async function handleControlUiAvatarRequest( } applyControlUiSecurityHeaders(res); + const agentIdParts = pathname.slice(pathWithBase.length).split("/").filter(Boolean); + const agentId = agentIdParts[0] ?? ""; + if (agentIdParts.length !== 1 || !agentId || !isValidAgentId(agentId)) { + respondControlUiNotFound(res); + return true; + } + if ( !(await authorizeControlUiReadRequest(req, res, { auth: opts.auth, @@ -561,22 +591,21 @@ export async function handleControlUiAvatarRequest( return true; } - const agentIdParts = pathname.slice(pathWithBase.length).split("/").filter(Boolean); - const agentId = agentIdParts[0] ?? ""; - if (agentIdParts.length !== 1 || !agentId || !isValidAgentId(agentId)) { - respondControlUiNotFound(res); - return true; - } - if (url.searchParams.get("meta") === "1") { const resolved = opts.resolveAvatar(agentId); + const meta = controlUiAvatarResolutionMeta(resolved); const avatarUrl = resolved.kind === "local" ? buildControlUiAvatarUrl(basePath, agentId) : resolved.kind === "remote" || resolved.kind === "data" ? resolved.url : null; - sendJson(res, 200, { avatarUrl } satisfies ControlUiAvatarMeta); + sendJson(res, 200, { + avatarUrl, + avatarSource: meta.avatarSource, + avatarStatus: meta.avatarStatus ?? resolved.kind, + avatarReason: meta.avatarReason, + } satisfies ControlUiAvatarMeta); return true; } @@ -747,6 +776,11 @@ export async function handleControlUiHttpRequest( agentId: identity.agentId, basePath, }); + const avatarMeta = config + ? controlUiAvatarResolutionMeta( + resolveAgentAvatar(config, identity.agentId, { includeUiOverride: true }), + ) + : controlUiAvatarResolutionMeta(null); if (req.method === "HEAD") { res.statusCode = 200; res.setHeader("Content-Type", "application/json; charset=utf-8"); @@ -758,6 +792,9 @@ export async function handleControlUiHttpRequest( basePath, assistantName: identity.name, assistantAvatar: avatarValue ?? identity.avatar, + assistantAvatarSource: avatarMeta.avatarSource, + assistantAvatarStatus: avatarMeta.avatarStatus, + assistantAvatarReason: avatarMeta.avatarReason, assistantAgentId: identity.agentId, serverVersion: resolveRuntimeServiceVersion(process.env), localMediaPreviewRoots: [...getAgentScopedMediaLocalRoots(config ?? {}, identity.agentId)], diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index 6c70b7a87c2..09fae0517af 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -181,6 +181,9 @@ export const AgentIdentityResultSchema = Type.Object( agentId: NonEmptyString, name: Type.Optional(NonEmptyString), avatar: Type.Optional(NonEmptyString), + avatarSource: Type.Optional(NonEmptyString), + avatarStatus: Type.Optional(Type.String({ enum: ["none", "local", "remote", "data"] })), + avatarReason: Type.Optional(NonEmptyString), emoji: Type.Optional(NonEmptyString), }, { additionalProperties: false }, diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 5c5cc03208c..a6fa587ca4c 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -75,6 +75,8 @@ vi.mock("../../config/config.js", async () => { vi.mock("../../agents/agent-scope.js", () => ({ listAgentIds: mocks.listAgentIds, resolveDefaultAgentId: () => "main", + resolveAgentConfig: (cfg: { agents?: { list?: Array<{ id?: string }> } }, agentId: string) => + cfg.agents?.list?.find((agent) => agent.id === agentId), resolveAgentWorkspaceDir: (cfg: { agents?: { defaults?: { workspace?: string } } }) => cfg?.agents?.defaults?.workspace ?? "/tmp/workspace", resolveAgentEffectiveModelPrimary: () => undefined, @@ -347,6 +349,7 @@ describe("gateway agent handler", () => { } resetDetachedTaskLifecycleRuntimeForTests(); resetTaskRegistryForTests(); + mocks.loadConfigReturn = {}; mocks.resolveExplicitAgentSessionKey.mockReset().mockReturnValue(undefined); mocks.resolveBareResetBootstrapFileAccess.mockReset().mockReturnValue(true); mocks.listAgentIds.mockReset().mockReturnValue(["main"]); @@ -1553,6 +1556,33 @@ describe("gateway agent handler", () => { }), ); }); + + it("redacts unsafe avatar sources in agent.identity.get", async () => { + mocks.loadConfigReturn = { + agents: { + defaults: { workspace: "/tmp/workspace" }, + list: [{ id: "main", identity: { avatar: "/Users/test/private/avatar.png" } }], + }, + }; + + const respond = await invokeAgentIdentityGet( + { + sessionKey: "agent:main:main", + }, + { reqId: "5-avatar-source" }, + ); + + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + agentId: "main", + avatarSource: undefined, + avatarStatus: "none", + avatarReason: "outside_workspace", + }), + undefined, + ); + }); }); describe("gateway agent handler chat.abort integration", () => { diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 47d6c105d6b..c61f2fda4ab 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -1,5 +1,9 @@ import { randomUUID } from "node:crypto"; import { listAgentIds, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; +import { + resolveAgentAvatar, + resolvePublicAgentAvatarSource, +} from "../../agents/identity-avatar.js"; import type { AgentInternalEvent } from "../../agents/internal-events.js"; import { normalizeSpawnedRunMetadata, @@ -1074,7 +1078,18 @@ export const agentHandlers: GatewayRequestHandlers = { agentId: identity.agentId, basePath: cfg.gateway?.controlUi?.basePath, }) ?? identity.avatar; - respond(true, { ...identity, avatar: avatarValue }, undefined); + const avatarResolution = resolveAgentAvatar(cfg, identity.agentId, { includeUiOverride: true }); + respond( + true, + { + ...identity, + avatar: avatarValue, + avatarSource: resolvePublicAgentAvatarSource(avatarResolution), + avatarStatus: avatarResolution.kind, + avatarReason: avatarResolution.kind === "none" ? avatarResolution.reason : undefined, + }, + undefined, + ); }, "agent.wait": async ({ params, respond, context }) => { if (!validateAgentWaitParams(params)) { diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 3415f74132d..5d5c89c9bda 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -607,29 +607,43 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { ); it("handles transparent and semantic env wrappers in allowlist mode", async () => { - const transparent = await runSystemInvoke({ - preferMacAppExecHost: false, - security: "allowlist", - command: ["env", "tr", "a", "b"], - }); - if (process.platform === "win32") { - expect(transparent.runCommand).not.toHaveBeenCalled(); - expectInvokeErrorMessage(transparent.sendInvokeResult, { message: "allowlist miss" }); - } else { - const runArgs = vi.mocked(transparent.runCommand).mock.calls[0]?.[0] as string[] | undefined; - expect(runArgs).toBeDefined(); - expect(runArgs?.[0]).toMatch(/(^|[/\\])tr$/); - expect(runArgs?.slice(1)).toEqual(["a", "b"]); - expectInvokeOk(transparent.sendInvokeResult); + const oldPath = process.env.PATH; + if (process.platform !== "win32") { + process.env.PATH = "/usr/bin:/bin"; } + try { + const transparent = await runSystemInvoke({ + preferMacAppExecHost: false, + security: "allowlist", + command: ["env", "tr", "a", "b"], + }); + if (process.platform === "win32") { + expect(transparent.runCommand).not.toHaveBeenCalled(); + expectInvokeErrorMessage(transparent.sendInvokeResult, { message: "allowlist miss" }); + } else { + const runArgs = vi.mocked(transparent.runCommand).mock.calls[0]?.[0] as + | string[] + | undefined; + expect(runArgs).toBeDefined(); + expect(runArgs?.[0]).toMatch(/(^|[/\\])tr$/); + expect(runArgs?.slice(1)).toEqual(["a", "b"]); + expectInvokeOk(transparent.sendInvokeResult); + } - const semantic = await runSystemInvoke({ - preferMacAppExecHost: false, - security: "allowlist", - command: ["env", "FOO=bar", "tr", "a", "b"], - }); - expect(semantic.runCommand).not.toHaveBeenCalled(); - expectInvokeErrorMessage(semantic.sendInvokeResult, { message: "allowlist miss" }); + const semantic = await runSystemInvoke({ + preferMacAppExecHost: false, + security: "allowlist", + command: ["env", "FOO=bar", "tr", "a", "b"], + }); + expect(semantic.runCommand).not.toHaveBeenCalled(); + expectInvokeErrorMessage(semantic.sendInvokeResult, { message: "allowlist miss" }); + } finally { + if (oldPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = oldPath; + } + } }); it("denies shell payload carriers in allowlist mode without explicit approval", async () => { diff --git a/src/shared/regexp.ts b/src/shared/regexp.ts new file mode 100644 index 00000000000..e235b74766d --- /dev/null +++ b/src/shared/regexp.ts @@ -0,0 +1,3 @@ +export function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/src/utils.ts b/src/utils.ts index 9ede64b6d45..de63ef58974 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,6 +7,7 @@ import { resolveRequiredHomeDir, } from "./infra/home-dir.js"; import { isPlainObject } from "./infra/plain-object.js"; +export { escapeRegExp } from "./shared/regexp.js"; export async function ensureDir(dir: string) { await fs.promises.mkdir(dir, { recursive: true }); @@ -35,13 +36,6 @@ export function clampInt(value: number, min: number, max: number): number { /** Alias for clampNumber (shorter, more common name) */ export const clamp = clampNumber; -/** - * Escapes special regex characters in a string so it can be used in a RegExp constructor. - */ -export function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - /** * Safely parse JSON, returning null on error instead of throwing. */ diff --git a/ui/src/styles/config-quick.css b/ui/src/styles/config-quick.css index 5028d92e228..ea70b8358f4 100644 --- a/ui/src/styles/config-quick.css +++ b/ui/src/styles/config-quick.css @@ -44,7 +44,7 @@ .qs-grid { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: start; gap: 14px; } @@ -56,6 +56,10 @@ min-width: 0; } +.qs-stack--wide { + grid-column: span 2; +} + /* ── Card ── */ .qs-card { @@ -78,6 +82,10 @@ grid-column: 1 / -1; } +.qs-card--personal { + grid-column: span 2; +} + .qs-card__header { display: flex; align-items: center; @@ -180,6 +188,12 @@ border-color: var(--border-strong); } +.qs-row__value--action:focus-visible { + outline: none; + border-color: color-mix(in srgb, var(--accent) 55%, var(--border) 45%); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); +} + .qs-row__value--action code { font-family: var(--mono); font-size: 0.75rem; @@ -208,35 +222,144 @@ color: var(--muted); } -.qs-personal-preview { - display: flex; - align-items: center; - gap: 12px; +.qs-identity-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); + gap: 10px; padding: 14px 16px 10px; } -.qs-personal-preview__copy { +.qs-identity-card { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-content: start; + align-items: start; + gap: 10px; + min-width: 0; + padding: 12px; + border: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + border-radius: var(--radius-md); + background: + radial-gradient( + circle at 18% 18%, + color-mix(in srgb, var(--accent) 10%, transparent), + transparent 46% + ), + color-mix(in srgb, var(--bg-elevated) 42%, var(--card) 58%); +} + +.qs-identity-card--assistant { + background: + radial-gradient( + circle at 82% 12%, + color-mix(in srgb, var(--accent) 14%, transparent), + transparent 48% + ), + color-mix(in srgb, var(--bg-elevated) 52%, var(--card) 48%); +} + +.qs-identity-card__copy { min-width: 0; } -.qs-personal-preview__title { - font-size: 0.95rem; - font-weight: 650; - color: var(--text-strong); +.qs-identity-card__eyebrow { + margin-bottom: 2px; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); } -.qs-user-avatar { - width: 40px; - height: 40px; +.qs-identity-card__title { + color: var(--text-strong); + font-size: 0.95rem; + font-weight: 650; + line-height: 1.2; + overflow-wrap: anywhere; +} + +.qs-identity-card__sub { + margin-top: 4px; + color: var(--muted); + font-size: 0.75rem; + line-height: 1.35; +} + +.qs-identity-card__source { + display: grid; + gap: 3px; + margin-top: 10px; + padding-top: 9px; + border-top: 1px solid color-mix(in srgb, var(--border) 45%, transparent); + color: var(--muted); + font-size: 0.64rem; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.qs-identity-card__source code { + color: var(--text); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.68rem; + font-weight: 650; + letter-spacing: normal; + text-transform: none; + overflow-wrap: anywhere; +} + +.qs-identity-card__issue { + display: inline-flex; + width: fit-content; + max-width: 100%; + margin-top: 7px; + padding: 4px 7px; + border: 1px solid color-mix(in srgb, var(--warning, #f7b955) 35%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--warning, #f7b955) 10%, transparent); + color: color-mix(in srgb, var(--warning, #f7b955) 82%, var(--text) 18%); + font-size: 0.68rem; + font-weight: 650; + line-height: 1.2; +} + +.qs-identity-card__repair { + display: grid; + gap: 6px; + margin-top: 10px; +} + +.qs-identity-card__repair .btn { + width: fit-content; +} + +.qs-identity-card__repair .muted { + font-size: 0.68rem; + line-height: 1.35; +} + +.qs-identity-card__error { + margin-top: 8px; + color: var(--danger, #ff6b78); + font-size: 0.7rem; + line-height: 1.35; +} + +.qs-user-avatar, +.qs-assistant-avatar { + width: 48px; + height: 48px; flex: 0 0 auto; - border-radius: var(--radius-md); + border-radius: calc(var(--radius-md) + 2px); border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.16); object-fit: cover; object-position: center; } .qs-user-avatar--text, -.qs-user-avatar--default { +.qs-user-avatar--default, +.qs-assistant-avatar--text { display: grid; place-items: center; background: var(--accent-subtle); @@ -245,6 +368,12 @@ font-weight: 650; } +.qs-assistant-avatar--fallback { + padding: 7px; + background: color-mix(in srgb, var(--bg) 84%, var(--bg-elevated) 16%); + object-fit: contain; +} + .qs-user-avatar--default svg { width: 18px; height: 18px; @@ -283,9 +412,14 @@ background: transparent; color: var(--muted); cursor: pointer; - transition: all var(--duration-normal) var(--ease-out); + transition: + color var(--duration-normal) var(--ease-out), + background var(--duration-normal) var(--ease-out), + box-shadow var(--duration-normal) var(--ease-out), + transform var(--duration-normal) var(--ease-out); white-space: nowrap; position: relative; + touch-action: manipulation; } .qs-segmented__btn--compact { @@ -296,6 +430,12 @@ color: var(--text); } +.qs-segmented__btn:focus-visible { + outline: none; + color: var(--text); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 22%, transparent); +} + .qs-segmented__btn--active { background: var(--bg-elevated); color: var(--text-strong); @@ -429,6 +569,12 @@ background: var(--accent-subtle); } +.qs-link-btn:focus-visible { + outline: none; + background: var(--accent-subtle); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); +} + /* ── Empty state ── */ .qs-empty { @@ -588,78 +734,342 @@ .qs-presets-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 8px; - padding: 12px 16px !important; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding: 0; +} + +.qs-profiles { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(280px, 0.9fr); + gap: 18px; + padding: 18px 16px 16px; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--accent-subtle) 18%, transparent) 0%, + transparent 36% + ); +} + +.qs-profiles__copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 14px; +} + +.qs-profiles__eyebrow { + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: color-mix(in srgb, var(--accent) 68%, var(--muted) 32%); +} + +.qs-profiles__intro { + margin: 0; + max-width: 62ch; + font-size: 0.8125rem; + line-height: 1.55; + color: var(--muted); + text-wrap: pretty; +} + +.qs-profile-state { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--bg-elevated) 72%, var(--card) 28%); +} + +.qs-profile-state--pending { + border-color: color-mix(in srgb, var(--accent) 28%, var(--border) 72%); + background: color-mix(in srgb, var(--accent-subtle) 42%, var(--card) 58%); +} + +.qs-profile-state--pending .qs-status-dot { + background: var(--accent); + box-shadow: 0 0 10px color-mix(in srgb, var(--accent) 24%, transparent); +} + +.qs-profile-state--ok { + border-color: color-mix(in srgb, var(--ok) 24%, var(--border) 76%); + background: color-mix(in srgb, var(--ok-subtle) 58%, var(--card) 42%); +} + +.qs-profile-state__text { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.qs-profile-state__title { + font-size: 0.8125rem; + font-weight: 650; + color: var(--text-strong); +} + +.qs-profile-state__copy { + font-size: 0.75rem; + line-height: 1.45; + color: var(--muted); } .qs-preset { display: flex; flex-direction: column; - align-items: flex-start; - gap: 6px; - padding: 12px; + gap: 12px; + padding: 14px; border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); - border-radius: var(--radius-md); + border-radius: var(--radius-lg); background: var(--card); cursor: pointer; transition: border-color var(--duration-normal) var(--ease-out), background var(--duration-normal) var(--ease-out), - box-shadow var(--duration-normal) var(--ease-out); + box-shadow var(--duration-normal) var(--ease-out), + transform var(--duration-normal) var(--ease-out); text-align: left; + touch-action: manipulation; } .qs-preset:hover { border-color: color-mix(in srgb, var(--accent) 40%, var(--border) 60%); background: color-mix(in srgb, var(--accent-subtle) 40%, var(--card) 60%); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 10%, transparent); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16); + transform: translateY(-1px); +} + +.qs-preset:focus-visible { + outline: none; + border-color: color-mix(in srgb, var(--accent) 55%, var(--border) 45%); + box-shadow: + 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent), + 0 8px 24px rgba(0, 0, 0, 0.18); } .qs-preset--active { border-color: color-mix(in srgb, var(--accent) 50%, var(--border) 50%); background: color-mix(in srgb, var(--accent-subtle) 60%, var(--card) 40%); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 15%, transparent); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--accent) 15%, transparent), + 0 12px 28px rgba(0, 0, 0, 0.2); + transform: translateY(-1px); } .qs-preset--active:hover { background: color-mix(in srgb, var(--accent-subtle) 80%, var(--card) 20%); } +.qs-preset__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.qs-preset__identity { + min-width: 0; + display: flex; + align-items: flex-start; + gap: 10px; +} + +.qs-preset__identity-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.qs-preset__badges { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + .qs-preset__icon { - font-size: 1.35rem; + font-size: 1.45rem; line-height: 1; + flex: 0 0 auto; } .qs-preset__label { - font-size: 0.8125rem; - font-weight: 650; + font-size: 0.875rem; + font-weight: 700; letter-spacing: -0.01em; color: var(--text-strong); } .qs-preset__desc { + font-size: 0.75rem; + line-height: 1.45; + color: var(--muted); + text-wrap: pretty; +} + +.qs-preset__meta { + display: flex; + flex-wrap: wrap; + gap: 8px; font-size: 0.6875rem; - line-height: 1.4; color: var(--muted); } -@media (max-width: 1380px) { - .qs-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } +.qs-preset__meta span { + display: inline-flex; + align-items: center; + padding: 4px 8px; + border-radius: var(--radius-full); + background: color-mix(in srgb, var(--bg-elevated) 84%, transparent); +} + +.qs-profile-panel { + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border-radius: var(--radius-lg); + border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + background: color-mix(in srgb, var(--bg-elevated) 76%, var(--card) 24%); +} + +.qs-profile-panel__eyebrow { + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: color-mix(in srgb, var(--accent) 68%, var(--muted) 32%); +} + +.qs-profile-panel__title { + margin: 0; + font-size: 1.08rem; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text-strong); + text-wrap: balance; +} + +.qs-profile-panel__copy { + margin: 0; + font-size: 0.8125rem; + line-height: 1.55; + color: var(--text); +} + +.qs-profile-panel__impact { + font-size: 0.75rem; + line-height: 1.5; + color: var(--muted); +} + +.qs-profile-panel__stats { + display: grid; + gap: 10px; +} + +.qs-profile-stat { + display: grid; + gap: 4px; + padding: 12px; + border: 1px solid color-mix(in srgb, var(--border) 62%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--card) 84%, transparent); +} + +.qs-profile-stat--changed { + border-color: color-mix(in srgb, var(--accent) 32%, var(--border) 68%); + background: color-mix(in srgb, var(--accent-subtle) 44%, var(--card) 56%); +} + +.qs-profile-stat__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.qs-profile-stat__label { + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); +} + +.qs-profile-stat__value { + font-size: 0.875rem; + font-weight: 650; + color: var(--text-strong); + text-align: right; +} + +.qs-profile-stat__sub { + font-size: 0.75rem; + font-weight: 600; + color: var(--muted); +} + +.qs-profile-stat--changed .qs-profile-stat__sub { + color: var(--accent); +} + +.qs-profile-stat__note { + font-size: 0.75rem; + line-height: 1.45; +} + +.qs-profile-panel__actions { + display: grid; + gap: 10px; + padding-top: 4px; + border-top: 1px solid color-mix(in srgb, var(--border) 55%, transparent); +} + +.qs-profile-panel__actions-copy, +.qs-profile-panel__footer { + font-size: 0.75rem; + line-height: 1.5; +} + +.qs-profile-panel__actions-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.qs-profile-panel__footer { + padding-top: 4px; + border-top: 1px solid color-mix(in srgb, var(--border) 55%, transparent); } @media (max-width: 1100px) { .qs-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .qs-card--personal, + .qs-stack--wide { + grid-column: span 1; + } } @media (max-width: 760px) { .qs-grid { grid-template-columns: 1fr; } + + .qs-card--personal, + .qs-stack--wide { + grid-column: 1 / -1; + } } @media (max-width: 480px) { @@ -667,7 +1077,7 @@ padding: 20px 0 40px; } - .qs-presets-grid { + .qs-identity-grid { grid-template-columns: 1fr; } @@ -679,3 +1089,24 @@ font-size: 1.15rem; } } + +@media (max-width: 920px) { + .qs-profiles { + grid-template-columns: 1fr; + } +} + +@media (max-width: 720px) { + .qs-presets-grid { + grid-template-columns: 1fr; + } + + .qs-profile-panel__actions-row { + flex-direction: column; + } + + .qs-profile-panel__actions-row .btn { + width: 100%; + justify-content: center; + } +} diff --git a/ui/src/styles/config-quick.test.ts b/ui/src/styles/config-quick.test.ts index dfbaf1d4c54..256b4aaebd1 100644 --- a/ui/src/styles/config-quick.test.ts +++ b/ui/src/styles/config-quick.test.ts @@ -1,20 +1,36 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -describe("config-quick personal identity styles", () => { - it("includes the local user identity quick-settings styles", () => { - const css = readFileSync(new URL("./config-quick.css", import.meta.url), "utf8"); +const css = readFileSync(new URL("./config-quick.css", import.meta.url), "utf8"); - expect(css).toContain(".qs-personal-preview"); +describe("config-quick styles", () => { + it("includes the local user identity quick-settings styles", () => { + expect(css).toContain(".qs-identity-grid"); + expect(css).toContain(".qs-identity-card__source"); + expect(css).toContain(".qs-identity-card__issue"); + expect(css).toContain(".qs-identity-card__repair"); + expect(css).toContain(".qs-identity-card__error"); + expect(css).toContain(".qs-assistant-avatar"); expect(css).toContain(".qs-user-avatar"); expect(css).toContain(".qs-personal-actions"); + expect(css).toContain(".qs-card--personal"); }); it("includes the stacked quick-settings density layout", () => { - const css = readFileSync(new URL("./config-quick.css", import.meta.url), "utf8"); - expect(css).toContain(".qs-stack"); - expect(css).toContain("grid-template-columns: repeat(4, minmax(0, 1fr));"); - expect(css).toContain("@media (max-width: 1380px)"); + expect(css).toContain(".qs-stack--wide"); + expect(css).toContain("grid-template-columns: repeat(3, minmax(0, 1fr));"); + expect(css).toContain("grid-template-columns: repeat(2, minmax(0, 1fr));"); + expect(css).toContain("@media (max-width: 760px)"); + }); + + it("includes explicit context profile layout hooks", () => { + expect(css).toContain(".qs-profiles"); + expect(css).toContain(".qs-profile-state--pending"); + expect(css).toContain(".qs-profile-panel__actions-row"); + }); + + it("avoids transition-all in the quick settings surface", () => { + expect(css).not.toContain("transition: all"); }); }); diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 69d4efa8a96..1c6734b91b1 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -54,6 +54,9 @@ function makeHost(overrides?: Partial): ChatHost { basePath: "", hello: null, chatAvatarUrl: null, + chatAvatarSource: null, + chatAvatarStatus: null, + chatAvatarReason: null, chatSideResult: null, chatSideResultTerminalRuns: new Set(), chatModelOverrides: {}, @@ -281,7 +284,12 @@ describe("refreshChatAvatar", () => { it("drops remote avatar metadata so the control UI can rely on same-origin images only", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ avatarUrl: "https://example.com/avatar.png" }), + json: async () => ({ + avatarUrl: "https://example.com/avatar.png", + avatarSource: "https://example.com/avatar.png", + avatarStatus: "remote", + avatarReason: null, + }), }); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); @@ -289,6 +297,29 @@ describe("refreshChatAvatar", () => { await refreshChatAvatar(host); expect(host.chatAvatarUrl).toBeNull(); + expect(host.chatAvatarSource).toBe("https://example.com/avatar.png"); + expect(host.chatAvatarStatus).toBe("remote"); + }); + + it("keeps unresolved IDENTITY.md avatar metadata when falling back to the logo", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + avatarUrl: null, + avatarSource: "assets/avatars/nova-portrait.png", + avatarStatus: "none", + avatarReason: "missing", + }), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const host = makeHost({ basePath: "", sessionKey: "agent:main" }); + await refreshChatAvatar(host); + + expect(host.chatAvatarUrl).toBeNull(); + expect(host.chatAvatarSource).toBe("assets/avatars/nova-portrait.png"); + expect(host.chatAvatarStatus).toBe("none"); + expect(host.chatAvatarReason).toBe("missing"); }); it("ignores stale avatar responses after switching sessions", async () => { diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 55eefff0a1a..66f21d01283 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -42,6 +42,9 @@ export type ChatHost = { password?: string | null; hello: GatewayHelloOk | null; chatAvatarUrl: string | null; + chatAvatarSource?: string | null; + chatAvatarStatus?: "none" | "local" | "remote" | "data" | null; + chatAvatarReason?: string | null; chatSideResult?: ChatSideResult | null; chatSideResultTerminalRuns?: Set; chatModelOverrides: Record; @@ -596,6 +599,13 @@ function clearChatAvatarUrl(host: ChatHost) { host.chatAvatarUrl = null; } +function clearChatAvatarState(host: ChatHost) { + clearChatAvatarUrl(host); + host.chatAvatarSource = null; + host.chatAvatarStatus = null; + host.chatAvatarReason = null; +} + function setChatAvatarUrl(host: ChatHost, nextUrl: string | null) { const key = host as object; const previousBlobUrl = chatAvatarObjectUrls.get(key); @@ -609,6 +619,32 @@ function setChatAvatarUrl(host: ChatHost, nextUrl: string | null) { host.chatAvatarUrl = nextUrl; } +function setChatAvatarMeta( + host: ChatHost, + data: { + avatarSource?: unknown; + avatarStatus?: unknown; + avatarReason?: unknown; + }, +) { + const status = + data.avatarStatus === "none" || + data.avatarStatus === "local" || + data.avatarStatus === "remote" || + data.avatarStatus === "data" + ? data.avatarStatus + : null; + host.chatAvatarSource = + typeof data.avatarSource === "string" && data.avatarSource.trim() + ? data.avatarSource.trim() + : null; + host.chatAvatarStatus = status; + host.chatAvatarReason = + typeof data.avatarReason === "string" && data.avatarReason.trim() + ? data.avatarReason.trim() + : null; +} + function buildControlUiAuthHeaders(authHeader: string | null): Record | undefined { return authHeader ? { Authorization: authHeader } : undefined; } @@ -619,7 +655,7 @@ function isLocalControlUiAvatarUrl(avatarUrl: string): boolean { export async function refreshChatAvatar(host: ChatHost) { if (!host.connected) { - clearChatAvatarUrl(host); + clearChatAvatarState(host); return; } const sessionKey = host.sessionKey; @@ -627,11 +663,11 @@ export async function refreshChatAvatar(host: ChatHost) { const agentId = resolveAgentIdForSession(host); if (!agentId) { if (shouldApplyChatAvatarResult(host, requestVersion, sessionKey)) { - clearChatAvatarUrl(host); + clearChatAvatarState(host); } return; } - clearChatAvatarUrl(host); + clearChatAvatarState(host); const authHeader = resolveControlUiAuthHeader(host); const headers = buildControlUiAuthHeaders(authHeader); const url = buildAvatarMetaUrl(host.basePath, agentId); @@ -641,13 +677,19 @@ export async function refreshChatAvatar(host: ChatHost) { return; } if (!res.ok) { - clearChatAvatarUrl(host); + clearChatAvatarState(host); return; } - const data = (await res.json()) as { avatarUrl?: unknown }; + const data = (await res.json()) as { + avatarUrl?: unknown; + avatarSource?: unknown; + avatarStatus?: unknown; + avatarReason?: unknown; + }; if (!shouldApplyChatAvatarResult(host, requestVersion, sessionKey)) { return; } + setChatAvatarMeta(host, data); const avatarUrl = typeof data.avatarUrl === "string" ? data.avatarUrl.trim() : ""; if (!avatarUrl || !isRenderableControlUiAvatarUrl(avatarUrl)) { clearChatAvatarUrl(host); @@ -675,7 +717,7 @@ export async function refreshChatAvatar(host: ChatHost) { setChatAvatarUrl(host, blobUrl); } catch { if (shouldApplyChatAvatarResult(host, requestVersion, sessionKey)) { - clearChatAvatarUrl(host); + clearChatAvatarState(host); } } } diff --git a/ui/src/ui/app-gateway.sessions.node.test.ts b/ui/src/ui/app-gateway.sessions.node.test.ts index 29f163483bb..9c23ae80a60 100644 --- a/ui/src/ui/app-gateway.sessions.node.test.ts +++ b/ui/src/ui/app-gateway.sessions.node.test.ts @@ -7,6 +7,7 @@ const loadChatHistoryMock = vi.fn(); vi.mock("./app-chat.ts", () => ({ CHAT_SESSIONS_ACTIVE_MINUTES: 10, flushChatQueueForEvent: vi.fn(), + refreshChatAvatar: vi.fn(), })); vi.mock("./app-settings.ts", () => ({ applySettings: vi.fn(), diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index c5abbd4f3b6..52e81e50fb0 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -7,6 +7,7 @@ import { CHAT_SESSIONS_ACTIVE_MINUTES, clearPendingQueueItemsForRun, flushChatQueueForEvent, + refreshChatAvatar, } from "./app-chat.ts"; import type { EventLogEntry } from "./app-events.ts"; import { @@ -335,6 +336,7 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption } void subscribeSessions(host as unknown as SessionsState); void loadAssistantIdentity(host as unknown as AssistantIdentityState); + void refreshChatAvatar(host as unknown as Parameters[0]); void loadAgents(host as unknown as AgentsState); void loadHealthState(host as unknown as HealthState); void loadNodes(host as unknown as NodesState, { quiet: true }); diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index 67e25af88f3..784b9101e59 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -26,6 +26,9 @@ type LifecycleHost = { tab: Tab; assistantName: string; assistantAvatar: string | null; + assistantAvatarSource?: string | null; + assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null; + assistantAvatarReason?: string | null; assistantAgentId: string | null; serverVersion: string | null; localMediaPreviewRoots: string[]; diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index f0b4128705c..721016bf5ce 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -80,6 +80,9 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string) state.compactionStatus = null; state.fallbackStatus = null; state.chatAvatarUrl = null; + state.chatAvatarSource = null; + state.chatAvatarStatus = null; + state.chatAvatarReason = null; state.chatQueue = []; host.chatStreamStartedAt = null; state.chatRunId = null; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 3e2b795ff0b..a6c1edeadaf 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1,8 +1,7 @@ import { html, nothing } from "lit"; -import { applyMergePatch } from "../../../src/config/merge-patch.ts"; import { t } from "../i18n/index.ts"; import { getSafeLocalStorage } from "../local-storage.ts"; -import { refreshChat } from "./app-chat.ts"; +import { refreshChat, refreshChatAvatar } from "./app-chat.ts"; import { DEFAULT_CRON_FORM } from "./app-defaults.ts"; import { renderUsageTab } from "./app-render-usage-tab.ts"; import { @@ -29,6 +28,7 @@ import { refreshVisibleToolsEffectiveForCurrentSession, saveAgentsConfig, } from "./controllers/agents.ts"; +import { setAssistantAvatarOverride } from "./controllers/assistant-identity.ts"; import { loadChannels } from "./controllers/channels.ts"; import { loadChatHistory } from "./controllers/chat.ts"; import { @@ -40,10 +40,10 @@ import { resetConfigPendingChanges, runUpdate, saveConfig, + stageConfigPreset, updateConfigFormValue, removeConfigFormValue, } from "./controllers/config.ts"; -import { cloneConfigObject, serializeConfigForm } from "./controllers/config/form-utils.ts"; import { loadCronJobsPage, loadCronRuns, @@ -123,6 +123,7 @@ import { parseAgentSessionKey, resolveAgentIdFromSessionKey, } from "./session-key.ts"; +import { normalizeOptionalString } from "./string-coerce.ts"; import { isRenderableControlUiAvatarUrl } from "./views/agents-utils.ts"; import { agentLogoUrl } from "./views/agents-utils.ts"; import { @@ -134,7 +135,7 @@ import { } from "./views/agents-utils.ts"; import { renderChat } from "./views/chat.ts"; import { renderCommandPalette } from "./views/command-palette.ts"; -import { getPresetById, type ConfigPresetId } from "./views/config-presets.ts"; +import { getPresetById } from "./views/config-presets.ts"; import { renderQuickSettings, type QuickSettingsChannel } from "./views/config-quick.ts"; import { renderConfig, type ConfigProps } from "./views/config.ts"; import { @@ -426,6 +427,27 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { return undefined; } +function resolveAssistantAvatarOverride(config: unknown): string | null { + if (!config || typeof config !== "object" || Array.isArray(config)) { + return null; + } + const ui = (config as { ui?: unknown }).ui; + if (!ui || typeof ui !== "object" || Array.isArray(ui)) { + return null; + } + const assistant = (ui as { assistant?: unknown }).assistant; + if (!assistant || typeof assistant !== "object" || Array.isArray(assistant)) { + return null; + } + return normalizeOptionalString((assistant as { avatar?: unknown }).avatar) ?? null; +} + +function buildAssistantAvatarRoute(basePathValue: string | null | undefined, agentId: string) { + const basePath = normalizeBasePath(basePathValue ?? ""); + const encoded = encodeURIComponent(agentId); + return basePath ? `${basePath}/avatar/${encoded}` : `/avatar/${encoded}`; +} + // ── Quick Settings data extraction helpers ── const KNOWN_CHANNEL_IDS = [ @@ -563,35 +585,6 @@ function resolveQuickSettingsSessionRow(state: AppViewState) { return state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey); } -async function applyQuickSettingsPreset(state: AppViewState, presetId: ConfigPresetId) { - if (!state.client || !state.connected) { - return; - } - const preset = getPresetById(presetId); - if (!preset) { - return; - } - state.configApplying = true; - state.lastError = null; - try { - if (!state.configSnapshot?.hash) { - await loadConfig(state); - } - const baseHash = state.configSnapshot?.hash?.trim(); - if (!baseHash) { - throw new Error("Config base hash unavailable. Reload config and retry."); - } - const baseConfig = cloneConfigObject(state.configForm ?? state.configSnapshot?.config ?? {}); - const merged = applyMergePatch(baseConfig, preset.patch) as Record; - await state.client.request("config.patch", { raw: serializeConfigForm(merged), baseHash }); - await loadConfig(state); - } catch (err) { - state.lastError = `Failed to apply preset: ${String(err)}`; - } finally { - state.configApplying = false; - } -} - function renderCronQuickCreateForTab( state: AppViewState, requestHostUpdate: (() => void) | undefined, @@ -663,7 +656,27 @@ export function renderApp(state: AppViewState) { const showThinking = state.onboarding ? false : state.settings.chatShowThinking; const showToolCalls = state.onboarding ? true : state.settings.chatShowToolCalls; const assistantAvatarUrl = resolveAssistantAvatarUrl(state); - const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null; + const chatAssistantAvatarStatus = state.chatAvatarStatus ?? state.assistantAvatarStatus ?? null; + const chatAssistantAvatarReason = state.chatAvatarReason ?? state.assistantAvatarReason ?? null; + const chatAssistantAvatarMissing = + chatAssistantAvatarStatus === "none" && chatAssistantAvatarReason === "missing"; + const effectiveAssistantAvatar = chatAssistantAvatarMissing ? null : state.assistantAvatar; + const chatAvatarUrl = + state.chatAvatarUrl ?? (chatAssistantAvatarMissing ? null : (assistantAvatarUrl ?? null)); + const configAssistantAvatarStatus = state.assistantAvatarStatus ?? state.chatAvatarStatus ?? null; + const configAssistantAvatarReason = state.assistantAvatarReason ?? state.chatAvatarReason ?? null; + const configAssistantAvatarSource = state.assistantAvatarSource ?? state.chatAvatarSource ?? null; + const configAssistantAvatarMissing = + configAssistantAvatarStatus === "none" && configAssistantAvatarReason === "missing"; + const configAssistantAvatar = + configAssistantAvatarMissing || configAssistantAvatarStatus === "local" + ? null + : state.assistantAvatar; + const configAssistantAvatarUrl = + configAssistantAvatarStatus === "local" && state.assistantAgentId + ? buildAssistantAvatarRoute(state.basePath, state.assistantAgentId) + : (state.chatAvatarUrl ?? + (configAssistantAvatarMissing ? null : (assistantAvatarUrl ?? null))); const configValue = state.configForm ?? (state.configSnapshot?.config as Record | null); const configuredDreaming = resolveConfiguredDreaming(configValue); @@ -928,6 +941,7 @@ export function renderApp(state: AppViewState) { // Quick Settings mode — opinionated card layout if (state.configSettingsMode === "quick") { const configObj = state.configForm ?? state.configSnapshot?.config ?? {}; + const assistantAvatarOverride = resolveAssistantAvatarOverride(configObj); const agentsDefaults = ((configObj.agents as Record | undefined) ?.defaults ?? {}) as Record; const activeSession = resolveQuickSettingsSessionRow(state); @@ -1014,14 +1028,79 @@ export function renderApp(state: AppViewState) { }, setThemeMode: (mode, context) => state.setThemeMode(mode, context), setBorderRadius: (value) => state.setBorderRadius(value), - userName: state.userName ?? null, userAvatar: state.userAvatar ?? null, - onUserNameChange: (name) => state.applyLocalUserIdentity?.({ name }), onUserAvatarChange: (avatar) => state.applyLocalUserIdentity?.({ avatar }), - configObject: configObj, - onApplyPreset: (presetId) => { - void applyQuickSettingsPreset(state, presetId).then(() => requestHostUpdate?.()); + assistantAvatar: configAssistantAvatar, + assistantAvatarUrl: configAssistantAvatarUrl, + assistantAvatarSource: configAssistantAvatarSource, + assistantAvatarStatus: configAssistantAvatarStatus, + assistantAvatarReason: configAssistantAvatarReason, + assistantAvatarOverride, + assistantAvatarUploadBusy: state.assistantAvatarUploadBusy, + assistantAvatarUploadError: state.assistantAvatarUploadError, + onAssistantAvatarOverrideChange: async (dataUrl) => { + state.assistantAvatarUploadBusy = true; + state.assistantAvatarUploadError = null; + requestHostUpdate?.(); + try { + await setAssistantAvatarOverride(state, dataUrl); + state.assistantAvatar = dataUrl; + state.assistantAvatarSource = dataUrl; + state.assistantAvatarStatus = "data"; + state.assistantAvatarReason = null; + state.chatAvatarUrl = dataUrl; + state.chatAvatarSource = dataUrl; + state.chatAvatarStatus = "data"; + state.chatAvatarReason = null; + await loadConfig(state); + await state.loadAssistantIdentity(); + await refreshChatAvatar(state); + } catch (err) { + state.assistantAvatarUploadError = err instanceof Error ? err.message : String(err); + } finally { + state.assistantAvatarUploadBusy = false; + requestHostUpdate?.(); + } }, + onAssistantAvatarClearOverride: async () => { + state.assistantAvatarUploadBusy = true; + state.assistantAvatarUploadError = null; + requestHostUpdate?.(); + try { + await setAssistantAvatarOverride(state, null); + state.chatAvatarUrl = null; + state.chatAvatarSource = null; + state.chatAvatarStatus = null; + state.chatAvatarReason = null; + await loadConfig(state); + await state.loadAssistantIdentity(); + await refreshChatAvatar(state); + } catch (err) { + state.assistantAvatarUploadError = err instanceof Error ? err.message : String(err); + } finally { + state.assistantAvatarUploadBusy = false; + requestHostUpdate?.(); + } + }, + basePath: state.basePath ?? "", + configObject: configObj, + savedConfigObject: + (state.configSnapshot?.config as Record | null) ?? {}, + configDirty: state.configFormDirty, + configSaving: state.configSaving, + configApplying: state.configApplying, + configReady: Boolean(state.configSnapshot?.hash), + onSelectPreset: (presetId) => { + const preset = getPresetById(presetId); + if (!preset) { + return; + } + stageConfigPreset(state, preset.patch); + requestHostUpdate?.(); + }, + onResetConfig: () => resetConfigPendingChanges(state), + onSaveConfig: () => saveConfig(state), + onApplyConfig: () => applyConfig(state), onAdvancedSettings: () => { state.configSettingsMode = "advanced"; requestHostUpdate?.(); @@ -2322,7 +2401,7 @@ export function renderApp(state: AppViewState) { onCloseSidebar: () => state.handleCloseSidebar(), onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio), assistantName: state.assistantName, - assistantAvatar: state.assistantAvatar, + assistantAvatar: effectiveAssistantAvatar, userName: state.userName ?? null, userAvatar: state.userAvatar ?? null, localMediaPreviewRoots: state.localMediaPreviewRoots, diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 377b18e32cd..a2b2e3a4bff 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -71,6 +71,11 @@ export type AppViewState = { eventLog: EventLogEntry[]; assistantName: string; assistantAvatar: string | null; + assistantAvatarSource?: string | null; + assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null; + assistantAvatarReason?: string | null; + assistantAvatarUploadBusy: boolean; + assistantAvatarUploadError: string | null; assistantAgentId: string | null; userName?: string | null; userAvatar?: string | null; @@ -93,6 +98,9 @@ export type AppViewState = { compactionStatus: CompactionStatus | null; fallbackStatus: FallbackStatus | null; chatAvatarUrl: string | null; + chatAvatarSource?: string | null; + chatAvatarStatus?: "none" | "local" | "remote" | "data" | null; + chatAvatarReason?: string | null; chatThinkingLevel: string | null; chatModelOverrides: Record; chatModelsLoading: boolean; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 3f3f67f987c..f0c6a25500a 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -171,6 +171,11 @@ export class OpenClawApp extends LitElement { @state() assistantName = bootAssistantIdentity.name; @state() assistantAvatar = bootAssistantIdentity.avatar; + @state() assistantAvatarSource = bootAssistantIdentity.avatarSource ?? null; + @state() assistantAvatarStatus = bootAssistantIdentity.avatarStatus ?? null; + @state() assistantAvatarReason = bootAssistantIdentity.avatarReason ?? null; + @state() assistantAvatarUploadBusy = false; + @state() assistantAvatarUploadError: string | null = null; @state() assistantAgentId = bootAssistantIdentity.agentId ?? null; @state() userName = bootLocalUserIdentity.name; @state() userAvatar = bootLocalUserIdentity.avatar; @@ -193,6 +198,9 @@ export class OpenClawApp extends LitElement { @state() compactionStatus: CompactionStatus | null = null; @state() fallbackStatus: FallbackStatus | null = null; @state() chatAvatarUrl: string | null = null; + @state() chatAvatarSource: string | null = null; + @state() chatAvatarStatus: "none" | "local" | "remote" | "data" | null = null; + @state() chatAvatarReason: string | null = null; @state() chatThinkingLevel: string | null = null; @state() chatModelOverrides: Record = {}; @state() chatModelsLoading = false; diff --git a/ui/src/ui/assistant-identity.ts b/ui/src/ui/assistant-identity.ts index 83543bf3a2f..a529fba986e 100644 --- a/ui/src/ui/assistant-identity.ts +++ b/ui/src/ui/assistant-identity.ts @@ -2,6 +2,7 @@ import { coerceIdentityValue } from "../../../src/shared/assistant-identity-valu const MAX_ASSISTANT_NAME = 50; const MAX_ASSISTANT_AVATAR = 200; +const MAX_ASSISTANT_AVATAR_SOURCE = 500; export const DEFAULT_ASSISTANT_NAME = "Assistant"; export const DEFAULT_ASSISTANT_AVATAR = "A"; @@ -10,6 +11,9 @@ export type AssistantIdentity = { agentId?: string | null; name: string; avatar: string | null; + avatarSource?: string | null; + avatarStatus?: "none" | "local" | "remote" | "data" | null; + avatarReason?: string | null; }; export function normalizeAssistantIdentity( @@ -17,7 +21,18 @@ export function normalizeAssistantIdentity( ): AssistantIdentity { const name = coerceIdentityValue(input?.name, MAX_ASSISTANT_NAME) ?? DEFAULT_ASSISTANT_NAME; const avatar = coerceIdentityValue(input?.avatar ?? undefined, MAX_ASSISTANT_AVATAR) ?? null; + const avatarSource = + coerceIdentityValue(input?.avatarSource ?? undefined, MAX_ASSISTANT_AVATAR_SOURCE) ?? null; + const avatarStatus = + input?.avatarStatus === "none" || + input?.avatarStatus === "local" || + input?.avatarStatus === "remote" || + input?.avatarStatus === "data" + ? input.avatarStatus + : null; + const avatarReason = + coerceIdentityValue(input?.avatarReason ?? undefined, MAX_ASSISTANT_AVATAR) ?? null; const agentId = typeof input?.agentId === "string" && input.agentId.trim() ? input.agentId.trim() : null; - return { agentId, name, avatar }; + return { agentId, name, avatar, avatarSource, avatarStatus, avatarReason }; } diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index 39f40a7152e..5d2f90b2ac5 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -26,28 +26,48 @@ vi.mock("../icons.ts", () => ({ ), })); -vi.mock("../views/agents-utils.ts", () => ({ - agentLogoUrl: () => "/openclaw-logo.svg", - isRenderableControlUiAvatarUrl: (value: string) => - /^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//")), - resolveChatAvatarRenderUrl: ( - candidate: string | null | undefined, - agent: { identity?: { avatar?: string; avatarUrl?: string } }, - ) => { - if (typeof candidate === "string" && candidate.startsWith("blob:")) { - return candidate; - } - for (const value of [candidate, agent.identity?.avatarUrl, agent.identity?.avatar]) { - if ( - typeof value === "string" && - (/^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//"))) - ) { - return value; +vi.mock("../views/agents-utils.ts", () => { + const isRenderableControlUiAvatarUrl = (value: string) => + /^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//")); + + return { + assistantAvatarFallbackUrl: () => "/openclaw-molty.png", + agentLogoUrl: () => "/openclaw-logo.svg", + isRenderableControlUiAvatarUrl, + resolveAssistantTextAvatar: (value: string | null | undefined) => { + const trimmed = value?.trim(); + if (!trimmed || trimmed === "A") { + return null; } - } - return null; - }, -})); + if (trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed)) { + return null; + } + if ( + trimmed.length > 8 || + /\s/.test(trimmed) || + /[\\/.:]/.test(trimmed) || + /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u.test(trimmed) + ) { + return null; + } + return trimmed; + }, + resolveChatAvatarRenderUrl: ( + candidate: string | null | undefined, + agent: { identity?: { avatar?: string; avatarUrl?: string } }, + ) => { + if (typeof candidate === "string" && candidate.startsWith("blob:")) { + return candidate; + } + for (const value of [candidate, agent.identity?.avatarUrl, agent.identity?.avatar]) { + if (typeof value === "string" && isRenderableControlUiAvatarUrl(value)) { + return value; + } + } + return null; + }, + }; +}); vi.mock("../tool-display.ts", () => ({ formatToolDetail: () => undefined, @@ -216,7 +236,21 @@ describe("grouped chat rendering", () => { ); const img = container.querySelector("img.chat-avatar"); - expect(img?.getAttribute("src")).toBe("/openclaw-logo.svg"); + expect(img?.getAttribute("src")).toBe("/openclaw-molty.png"); + }); + + it("uses the Molty png as the default assistant transcript avatar", () => { + const container = document.createElement("div"); + + renderAssistantMessage(container, { + role: "assistant", + content: "hello", + timestamp: 1000, + }); + + const avatar = container.querySelector(".chat-avatar.assistant"); + expect(avatar).not.toBeNull(); + expect(avatar?.getAttribute("src")).toBe("/openclaw-molty.png"); }); it("positions delete confirm by message side", () => { @@ -279,7 +313,7 @@ describe("grouped chat rendering", () => { }; const remoteAvatar = renderAvatar("https://example.com/avatar.png"); - expect(remoteAvatar?.getAttribute("src")).toBe("/openclaw-logo.svg"); + expect(remoteAvatar?.getAttribute("src")).toBe("/openclaw-molty.png"); const blobAvatar = renderAvatar("blob:managed-image"); expect(blobAvatar?.tagName).toBe("IMG"); diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 429492c7a92..f51f22ed9d9 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -2,7 +2,7 @@ import { html, nothing } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { until } from "lit/directives/until.js"; import { getSafeLocalStorage } from "../../local-storage.ts"; -import { DEFAULT_ASSISTANT_AVATAR, type AssistantIdentity } from "../assistant-identity.ts"; +import type { AssistantIdentity } from "../assistant-identity.ts"; import type { EmbedSandboxMode } from "../embed-sandbox.ts"; import { icons } from "../icons.ts"; import { toSanitizedMarkdownHtml } from "../markdown.ts"; @@ -20,7 +20,12 @@ import { resolveLocalUserAvatarUrl, resolveLocalUserName, } from "../user-identity.ts"; -import { agentLogoUrl, isRenderableControlUiAvatarUrl } from "../views/agents-utils.ts"; +import { + assistantAvatarFallbackUrl, + isRenderableControlUiAvatarUrl, + resolveAssistantTextAvatar, +} from "../views/agents-utils.ts"; +export { resolveAssistantTextAvatar } from "../views/agents-utils.ts"; import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts"; import { extractTextCached, @@ -685,6 +690,7 @@ function renderAvatar( const assistantName = assistant?.name?.trim() || "Assistant"; const assistantAvatar = assistant?.avatar?.trim() || ""; const assistantAvatarText = resolveAssistantTextAvatar(assistantAvatar); + const assistantFallbackAvatar = assistantAvatarFallbackUrl(basePath ?? ""); const userName = resolveLocalUserName(user); const userAvatarUrl = resolveLocalUserAvatarUrl(user); const userAvatarText = resolveLocalUserAvatarText(user); @@ -749,7 +755,7 @@ function renderAvatar( if (authToken?.trim() && assistantAvatar.startsWith("/")) { return html``; } @@ -766,17 +772,15 @@ function renderAvatar( } return html``; } - /* Assistant with no custom avatar: use logo when basePath available */ - if (normalized === "assistant" && basePath) { - const logoUrl = agentLogoUrl(basePath); + if (normalized === "assistant") { return html``; } @@ -789,27 +793,6 @@ function isAvatarUrl(value: string): boolean { return trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed); } -const UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS = /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u; - -export function resolveAssistantTextAvatar(value: string | null | undefined): string | null { - const trimmed = value?.trim(); - if (!trimmed || trimmed === DEFAULT_ASSISTANT_AVATAR) { - return null; - } - if (isAvatarUrl(trimmed)) { - return null; - } - if ( - trimmed.length > 8 || - /\s/.test(trimmed) || - /[\\/.:]/.test(trimmed) || - UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS.test(trimmed) - ) { - return null; - } - return trimmed; -} - function resolveRenderableMessageImages( images: ImageBlock[], opts?: ImageRenderOptions, diff --git a/ui/src/ui/controllers/assistant-identity.test.ts b/ui/src/ui/controllers/assistant-identity.test.ts new file mode 100644 index 00000000000..0283696c915 --- /dev/null +++ b/ui/src/ui/controllers/assistant-identity.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { setAssistantAvatarOverride } from "./assistant-identity.ts"; + +describe("setAssistantAvatarOverride", () => { + it("writes the assistant avatar override through config.patch", async () => { + const request = vi.fn().mockResolvedValue({}); + + await setAssistantAvatarOverride( + { + client: { request } as never, + connected: true, + applySessionKey: "agent:main", + configSnapshot: { hash: "config-hash" }, + }, + "data:image/png;base64,YXZhdGFy", + ); + + expect(request).toHaveBeenCalledWith("config.patch", { + baseHash: "config-hash", + raw: JSON.stringify({ ui: { assistant: { avatar: "data:image/png;base64,YXZhdGFy" } } }), + sessionKey: "agent:main", + note: "Assistant avatar override updated from Control UI.", + }); + }); + + it("clears the assistant avatar override through config.patch", async () => { + const request = vi.fn().mockResolvedValue({}); + + await setAssistantAvatarOverride( + { + client: { request } as never, + connected: true, + configSnapshot: { hash: "config-hash" }, + }, + null, + ); + + expect(request).toHaveBeenCalledWith("config.patch", { + baseHash: "config-hash", + raw: JSON.stringify({ ui: { assistant: { avatar: null } } }), + sessionKey: undefined, + note: "Assistant avatar override cleared from Control UI.", + }); + }); +}); diff --git a/ui/src/ui/controllers/assistant-identity.ts b/ui/src/ui/controllers/assistant-identity.ts index abf6aa974c8..756718241e0 100644 --- a/ui/src/ui/controllers/assistant-identity.ts +++ b/ui/src/ui/controllers/assistant-identity.ts @@ -7,9 +7,19 @@ export type AssistantIdentityState = { sessionKey: string; assistantName: string; assistantAvatar: string | null; + assistantAvatarSource?: string | null; + assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null; + assistantAvatarReason?: string | null; assistantAgentId: string | null; }; +export type AssistantAvatarOverrideState = { + client: GatewayBrowserClient | null; + connected: boolean; + applySessionKey?: string; + configSnapshot?: { hash?: string | null } | null; +}; + export async function loadAssistantIdentity( state: AssistantIdentityState, opts?: { sessionKey?: string }, @@ -27,8 +37,32 @@ export async function loadAssistantIdentity( const normalized = normalizeAssistantIdentity(res); state.assistantName = normalized.name; state.assistantAvatar = normalized.avatar; + state.assistantAvatarSource = normalized.avatarSource ?? null; + state.assistantAvatarStatus = normalized.avatarStatus ?? null; + state.assistantAvatarReason = normalized.avatarReason ?? null; state.assistantAgentId = normalized.agentId ?? null; } catch { // Ignore errors; keep last known identity. } } + +export async function setAssistantAvatarOverride( + state: AssistantAvatarOverrideState, + avatar: string | null, +) { + if (!state.client || !state.connected) { + throw new Error("Gateway is not connected."); + } + const baseHash = state.configSnapshot?.hash; + if (!baseHash) { + throw new Error("Config hash missing; refresh and retry."); + } + await state.client.request("config.patch", { + baseHash, + raw: JSON.stringify({ ui: { assistant: { avatar } } }), + sessionKey: state.applySessionKey, + note: avatar + ? "Assistant avatar override updated from Control UI." + : "Assistant avatar override cleared from Control UI.", + }); +} diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index f42fc3f2a0c..ae19523ecd1 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -7,6 +7,7 @@ import { resetConfigPendingChanges, runUpdate, saveConfig, + stageConfigPreset, updateConfigFormValue, type ConfigState, } from "./config.ts"; @@ -162,6 +163,114 @@ describe("updateConfigFormValue", () => { '{\n "gateway": {\n "mode": "local",\n "port": 18789\n }\n}\n', ); }); + + it("clears dirty when a form edit returns to the original value", () => { + const state = createState(); + applyConfigSnapshot(state, { + config: { gateway: { mode: "local", port: 18789 } }, + valid: true, + issues: [], + raw: '{\n "gateway": {\n "mode": "local",\n "port": 18789\n }\n}\n', + }); + + updateConfigFormValue(state, ["gateway", "port"], 3000); + expect(state.configFormDirty).toBe(true); + + updateConfigFormValue(state, ["gateway", "port"], 18789); + + expect(state.configFormDirty).toBe(false); + }); +}); + +describe("stageConfigPreset", () => { + it("ignores preset staging before a config snapshot is ready", () => { + const state = createState(); + + stageConfigPreset(state, { + agents: { + defaults: { + bootstrapMaxChars: 50_000, + bootstrapTotalMaxChars: 300_000, + contextInjection: "always", + }, + }, + }); + + expect(state.configForm).toBeNull(); + expect(state.configRaw).toBe(""); + expect(state.configFormDirty).toBe(false); + }); + + it("stages preset changes without dropping unrelated config", () => { + const state = createState(); + applyConfigSnapshot(state, { + config: { + agents: { + defaults: { + bootstrapMaxChars: 12_000, + bootstrapTotalMaxChars: 60_000, + contextInjection: "always", + }, + }, + gateway: { mode: "local" }, + }, + valid: true, + issues: [], + raw: '{\n "agents": {\n "defaults": {\n "bootstrapMaxChars": 12000,\n "bootstrapTotalMaxChars": 60000,\n "contextInjection": "always"\n }\n },\n "gateway": {\n "mode": "local"\n }\n}\n', + }); + + stageConfigPreset(state, { + agents: { + defaults: { + bootstrapMaxChars: 50_000, + bootstrapTotalMaxChars: 300_000, + contextInjection: "always", + }, + }, + }); + + expect(state.configFormDirty).toBe(true); + expect(state.configForm).toEqual({ + agents: { + defaults: { + bootstrapMaxChars: 50_000, + bootstrapTotalMaxChars: 300_000, + contextInjection: "always", + }, + }, + gateway: { mode: "local" }, + }); + }); + + it("stays clean when the staged preset already matches the saved config", () => { + const state = createState(); + applyConfigSnapshot(state, { + config: { + agents: { + defaults: { + bootstrapMaxChars: 20_000, + bootstrapTotalMaxChars: 150_000, + contextInjection: "always", + }, + }, + }, + valid: true, + issues: [], + raw: '{\n "agents": {\n "defaults": {\n "bootstrapMaxChars": 20000,\n "bootstrapTotalMaxChars": 150000,\n "contextInjection": "always"\n }\n }\n}\n', + }); + + stageConfigPreset(state, { + agents: { + defaults: { + bootstrapMaxChars: 20_000, + bootstrapTotalMaxChars: 150_000, + contextInjection: "always", + }, + }, + }); + + expect(state.configFormDirty).toBe(false); + }); }); describe("resetConfigPendingChanges", () => { diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index e2e0cac93ce..35e6c49a46f 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -1,3 +1,4 @@ +import { applyMergePatch } from "../../../../src/config/merge-patch.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; import type { ConfigSchemaResponse, ConfigSnapshot, ConfigUiHints } from "../types.ts"; import type { JsonSchema } from "../views/config-form.shared.ts"; @@ -165,6 +166,17 @@ async function submitConfigChange( } } +function syncConfigDraft(state: ConfigState, nextForm: Record) { + const original = cloneConfigObject( + state.configFormOriginal ?? state.configSnapshot?.config ?? {}, + ); + const nextRaw = serializeConfigForm(nextForm); + const originalRaw = serializeConfigForm(original); + state.configForm = nextForm; + state.configRaw = nextRaw; + state.configFormDirty = nextRaw !== originalRaw; +} + export async function saveConfig(state: ConfigState) { await submitConfigChange(state, "config.set", "configSaving"); } @@ -203,11 +215,7 @@ export async function runUpdate(state: ConfigState) { function mutateConfigForm(state: ConfigState, mutate: (draft: Record) => void) { const base = cloneConfigObject(state.configForm ?? state.configSnapshot?.config ?? {}); mutate(base); - state.configForm = base; - state.configFormDirty = true; - if (state.configFormMode === "form") { - state.configRaw = serializeConfigForm(base); - } + syncConfigDraft(state, base); } export function updateConfigFormValue( @@ -218,6 +226,25 @@ export function updateConfigFormValue( mutateConfigForm(state, (draft) => setPathValue(draft, path, value)); } +export function stageConfigPreset(state: ConfigState, patch: Record) { + const snapshotConfig = + state.configSnapshot?.config && + typeof state.configSnapshot.config === "object" && + !Array.isArray(state.configSnapshot.config) + ? state.configSnapshot.config + : null; + const baseSource = state.configForm ?? snapshotConfig; + if (!baseSource || (!state.configForm && !state.configSnapshot?.hash)) { + return; + } + const base = cloneConfigObject(baseSource); + const merged = applyMergePatch(base, patch); + if (!merged || typeof merged !== "object" || Array.isArray(merged)) { + return; + } + syncConfigDraft(state, cloneConfigObject(merged as Record)); +} + export function resetConfigPendingChanges(state: ConfigState) { state.configForm = cloneConfigObject( state.configFormOriginal ?? state.configSnapshot?.config ?? {}, diff --git a/ui/src/ui/controllers/control-ui-bootstrap.test.ts b/ui/src/ui/controllers/control-ui-bootstrap.test.ts index 0f1416f9031..97a28b51fa8 100644 --- a/ui/src/ui/controllers/control-ui-bootstrap.test.ts +++ b/ui/src/ui/controllers/control-ui-bootstrap.test.ts @@ -12,6 +12,9 @@ describe("loadControlUiBootstrapConfig", () => { basePath: "/openclaw", assistantName: "Ops", assistantAvatar: "O", + assistantAvatarSource: "avatars/ops.png", + assistantAvatarStatus: "none", + assistantAvatarReason: "missing", assistantAgentId: "main", serverVersion: "2026.3.7", localMediaPreviewRoots: ["/tmp/openclaw"], @@ -25,6 +28,9 @@ describe("loadControlUiBootstrapConfig", () => { basePath: "/openclaw", assistantName: "Assistant", assistantAvatar: null, + assistantAvatarSource: null, + assistantAvatarStatus: null, + assistantAvatarReason: null, assistantAgentId: null, localMediaPreviewRoots: [], embedSandboxMode: "scripts" as const, @@ -40,6 +46,9 @@ describe("loadControlUiBootstrapConfig", () => { ); expect(state.assistantName).toBe("Ops"); expect(state.assistantAvatar).toBe("O"); + expect(state.assistantAvatarSource).toBe("avatars/ops.png"); + expect(state.assistantAvatarStatus).toBe("none"); + expect(state.assistantAvatarReason).toBe("missing"); expect(state.assistantAgentId).toBe("main"); expect(state.serverVersion).toBe("2026.3.7"); expect(state.localMediaPreviewRoots).toEqual(["/tmp/openclaw"]); diff --git a/ui/src/ui/controllers/control-ui-bootstrap.ts b/ui/src/ui/controllers/control-ui-bootstrap.ts index a65d1f9139e..be7083f127f 100644 --- a/ui/src/ui/controllers/control-ui-bootstrap.ts +++ b/ui/src/ui/controllers/control-ui-bootstrap.ts @@ -11,6 +11,9 @@ export type ControlUiBootstrapState = { basePath: string; assistantName: string; assistantAvatar: string | null; + assistantAvatarSource?: string | null; + assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null; + assistantAvatarReason?: string | null; assistantAgentId: string | null; serverVersion: string | null; localMediaPreviewRoots: string[]; @@ -66,9 +69,15 @@ export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapStat agentId: parsed.assistantAgentId ?? null, name: parsed.assistantName, avatar: parsed.assistantAvatar ?? null, + avatarSource: parsed.assistantAvatarSource ?? null, + avatarStatus: parsed.assistantAvatarStatus ?? null, + avatarReason: parsed.assistantAvatarReason ?? null, }); state.assistantName = normalized.name; state.assistantAvatar = normalized.avatar; + state.assistantAvatarSource = normalized.avatarSource ?? null; + state.assistantAvatarStatus = normalized.avatarStatus ?? null; + state.assistantAvatarReason = normalized.avatarReason ?? null; state.assistantAgentId = normalized.agentId ?? null; state.serverVersion = parsed.serverVersion ?? null; state.localMediaPreviewRoots = Array.isArray(parsed.localMediaPreviewRoots) diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 503421043c5..2447828e5b1 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -344,6 +344,9 @@ export type AgentIdentityResult = { agentId: string; name: string; avatar: string; + avatarSource?: string | null; + avatarStatus?: "none" | "local" | "remote" | "data" | null; + avatarReason?: string | null; emoji?: string; }; diff --git a/ui/src/ui/views/agents-utils.test.ts b/ui/src/ui/views/agents-utils.test.ts index 300f297e25f..ca65b5edc8a 100644 --- a/ui/src/ui/views/agents-utils.test.ts +++ b/ui/src/ui/views/agents-utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { agentLogoUrl, + assistantAvatarFallbackUrl, buildAgentContext, resolveConfiguredCronModelSuggestions, resolveAgentAvatarUrl, @@ -114,6 +115,13 @@ describe("agentLogoUrl", () => { }); }); +describe("assistantAvatarFallbackUrl", () => { + it("uses the bundled Molty png for assistant profile fallbacks", () => { + expect(assistantAvatarFallbackUrl("/ui")).toBe("/ui/apple-touch-icon.png"); + expect(assistantAvatarFallbackUrl("")).toBe("apple-touch-icon.png"); + }); +}); + describe("resolveAgentAvatarUrl", () => { it("prefers a runtime avatar URL over non-URL identity avatars", () => { expect( diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index 5de64a82039..de43b7d7544 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -4,6 +4,7 @@ import { normalizeToolName, resolveToolProfilePolicy, } from "../../../../src/agents/tool-policy-shared.js"; +import { DEFAULT_ASSISTANT_AVATAR } from "../assistant-identity.ts"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts"; import type { AgentIdentityResult, @@ -245,6 +246,37 @@ export function agentLogoUrl(basePath: string): string { return base ? `${base}/favicon.svg` : "favicon.svg"; } +export function assistantAvatarFallbackUrl(basePath: string): string { + const base = normalizeOptionalString(basePath)?.replace(/\/$/, "") ?? ""; + return base ? `${base}/apple-touch-icon.png` : "apple-touch-icon.png"; +} + +function isAvatarUrl(value: string): boolean { + const trimmed = value.trim(); + return trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed); +} + +const UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS = /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u; + +export function resolveAssistantTextAvatar(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed || trimmed === DEFAULT_ASSISTANT_AVATAR) { + return null; + } + if (isAvatarUrl(trimmed)) { + return null; + } + if ( + trimmed.length > 8 || + /\s/.test(trimmed) || + /[\\/.:]/.test(trimmed) || + UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS.test(trimmed) + ) { + return null; + } + return trimmed; +} + function isLikelyEmoji(value: string) { const trimmed = value.trim(); if (!trimmed) { diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 8e795452f65..d19392c6f29 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -192,4 +192,52 @@ describe("renderChat", () => { expect(avatar?.textContent).toContain("VC"); expect(avatar?.getAttribute("aria-label")).toBe("Val"); }); + + it("renders configured assistant image avatars in transcript groups", () => { + const container = document.createElement("div"); + + render( + renderChat( + createProps({ + assistantName: "Val", + assistantAvatar: "avatars/val.png", + assistantAvatarUrl: "blob:identity-avatar", + messages: [{ role: "assistant", content: "hello", timestamp: 1000 }], + stream: null, + streamStartedAt: null, + }), + ), + container, + ); + + const avatar = container.querySelector( + ".chat-group.assistant img.chat-avatar", + ); + expect(avatar).not.toBeNull(); + expect(avatar?.getAttribute("src")).toBe("blob:identity-avatar"); + expect(avatar?.getAttribute("alt")).toBe("Val"); + }); + + it("uses the Molty png as the welcome fallback assistant avatar", () => { + const container = document.createElement("div"); + + render( + renderChat( + createProps({ + assistantName: "Val", + assistantAvatar: null, + assistantAvatarUrl: null, + messages: [], + stream: null, + streamStartedAt: null, + }), + ), + container, + ); + + const avatar = container.querySelector(".agent-chat__avatar--logo img"); + expect(avatar).not.toBeNull(); + expect(avatar?.getAttribute("src")).toBe("apple-touch-icon.png"); + expect(avatar?.getAttribute("alt")).toBe("Val"); + }); }); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index d248c8fa65a..d3356c8ddfb 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -43,7 +43,11 @@ import { detectTextDirection } from "../text-direction.ts"; import type { SessionsListResult } from "../types.ts"; import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts"; import { resolveLocalUserName } from "../user-identity.ts"; -import { agentLogoUrl, resolveChatAvatarRenderUrl } from "./agents-utils.ts"; +import { + agentLogoUrl, + assistantAvatarFallbackUrl, + resolveChatAvatarRenderUrl, +} from "./agents-utils.ts"; import { renderMarkdownSidebar } from "./markdown-sidebar.ts"; import "../components/resizable-divider.ts"; @@ -484,6 +488,7 @@ function renderWelcomeState(props: ChatProps): TemplateResult { const name = props.assistantName || "Assistant"; const avatar = resolveAssistantAvatarUrl(props); const avatarText = avatar ? null : resolveAssistantTextAvatar(props.assistantAvatar); + const fallbackAvatarUrl = assistantAvatarFallbackUrl(props.basePath ?? ""); const logoUrl = agentLogoUrl(props.basePath ?? ""); return html` @@ -500,7 +505,7 @@ function renderWelcomeState(props: ChatProps): TemplateResult { ${avatarText} ` : html``}

${name}

diff --git a/ui/src/ui/views/config-presets.test.ts b/ui/src/ui/views/config-presets.test.ts new file mode 100644 index 00000000000..b72e0f731b2 --- /dev/null +++ b/ui/src/ui/views/config-presets.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { OpenClawSchema } from "../../../../src/config/zod-schema.js"; +import { CONFIG_PRESETS, detectActivePreset } from "./config-presets.ts"; + +describe("detectActivePreset", () => { + it("keeps every preset patch valid for the runtime config schema", () => { + for (const preset of CONFIG_PRESETS) { + const defaults = preset.patch.agents.defaults; + + expect(() => OpenClawSchema.parse(preset.patch), preset.id).not.toThrow(); + expect(defaults.bootstrapMaxChars, preset.id).toBeGreaterThan(0); + expect(defaults.bootstrapTotalMaxChars, preset.id).toBeGreaterThan(0); + expect(defaults.bootstrapTotalMaxChars, preset.id).toBeGreaterThanOrEqual( + defaults.bootstrapMaxChars, + ); + } + }); + + it("returns null when bootstrap defaults are unset", () => { + expect(detectActivePreset({})).toBeNull(); + }); + + it("returns the matching preset when all preset fields match", () => { + expect( + detectActivePreset({ + agents: { + defaults: { + bootstrapMaxChars: 50_000, + bootstrapTotalMaxChars: 300_000, + contextInjection: "always", + }, + }, + }), + ).toBe("codeAgent"); + }); + + it("does not match a preset when context injection differs", () => { + expect( + detectActivePreset({ + agents: { + defaults: { + bootstrapMaxChars: 50_000, + bootstrapTotalMaxChars: 300_000, + contextInjection: "continuation-skip", + }, + }, + }), + ).toBeNull(); + }); +}); diff --git a/ui/src/ui/views/config-presets.ts b/ui/src/ui/views/config-presets.ts index 94af39d1cd7..91b0f4ab021 100644 --- a/ui/src/ui/views/config-presets.ts +++ b/ui/src/ui/views/config-presets.ts @@ -5,19 +5,33 @@ export type ConfigPresetId = "personal" | "codeAgent" | "teamBot" | "minimal"; +export type ConfigPresetPatch = { + agents: { + defaults: { + bootstrapMaxChars: number; + bootstrapTotalMaxChars: number; + contextInjection: "always" | "continuation-skip"; + }; + }; +}; + export type ConfigPreset = { id: ConfigPresetId; label: string; description: string; + detail: string; + impact: string; icon: string; - patch: Record; + patch: ConfigPresetPatch; }; export const CONFIG_PRESETS: ConfigPreset[] = [ { id: "personal", label: "Personal Assistant", - description: "Balanced context and cost. Best for daily use.", + description: "Balanced default for daily use.", + detail: "Good fit for chat, docs, and light edits without a large coding budget.", + impact: "Injects bootstrap context every turn with a moderate prompt budget.", icon: "✨", patch: { agents: { @@ -32,7 +46,9 @@ export const CONFIG_PRESETS: ConfigPreset[] = [ { id: "codeAgent", label: "Code Agent", - description: "Higher context for coding tasks. More tokens per turn.", + description: "Highest context budget for repo work.", + detail: "Best for multi-file changes, long bootstrap docs, and code-heavy sessions.", + impact: "Uses the largest prompt budget and reinjects context every turn.", icon: "🛠️", patch: { agents: { @@ -47,7 +63,10 @@ export const CONFIG_PRESETS: ConfigPreset[] = [ { id: "teamBot", label: "Team Bot", - description: "Multi-channel, group-aware. Leaner per-turn context.", + description: "Lean follow-ups for shared bots.", + detail: + "Best for multi-channel workflows where continuity matters more than large bootstrap payloads.", + impact: "Keeps follow-up turns smaller by skipping safe continuation reinjection.", icon: "👥", patch: { agents: { @@ -62,7 +81,9 @@ export const CONFIG_PRESETS: ConfigPreset[] = [ { id: "minimal", label: "Minimal", - description: "Lowest cost per turn. Fast and lean.", + description: "Smallest context budget and lowest cost.", + detail: "Best for quick utility turns, automations, and cost-sensitive workflows.", + impact: "Uses the smallest bootstrap budget and the leanest follow-up behavior.", icon: "⚡", patch: { agents: { @@ -87,10 +108,11 @@ export function detectActivePreset(config: Record): ConfigPrese const agents = config.agents as Record | undefined; const defaults = agents?.defaults as Record | undefined; if (!defaults) { - return "personal"; // treat unset as default + return null; } const maxChars = defaults.bootstrapMaxChars; const totalMax = defaults.bootstrapTotalMaxChars; + const contextInjection = defaults.contextInjection; for (const preset of CONFIG_PRESETS) { const presetDefaults = (preset.patch.agents as Record)?.defaults as | Record @@ -100,7 +122,8 @@ export function detectActivePreset(config: Record): ConfigPrese } if ( maxChars === presetDefaults.bootstrapMaxChars && - totalMax === presetDefaults.bootstrapTotalMaxChars + totalMax === presetDefaults.bootstrapTotalMaxChars && + contextInjection === presetDefaults.contextInjection ) { return preset.id; } diff --git a/ui/src/ui/views/config-quick.test.ts b/ui/src/ui/views/config-quick.test.ts index 84a825b9c96..be72b905414 100644 --- a/ui/src/ui/views/config-quick.test.ts +++ b/ui/src/ui/views/config-quick.test.ts @@ -37,16 +37,25 @@ function createProps(overrides: Partial = {}): QuickSettings onOpenCustomThemeImport: vi.fn(), setThemeMode: vi.fn(), setBorderRadius: vi.fn(), - userName: "Val", userAvatar: null, - onUserNameChange: vi.fn(), onUserAvatarChange: vi.fn(), configObject: {}, - onApplyPreset: vi.fn(), + onSelectPreset: vi.fn(), onAdvancedSettings: vi.fn(), connected: true, gatewayUrl: "ws://localhost:18789", assistantName: "OpenClaw", + assistantAvatar: null, + assistantAvatarUrl: null, + assistantAvatarSource: null, + assistantAvatarStatus: null, + assistantAvatarReason: null, + assistantAvatarOverride: null, + assistantAvatarUploadBusy: false, + assistantAvatarUploadError: null, + onAssistantAvatarOverrideChange: vi.fn(), + onAssistantAvatarClearOverride: vi.fn(), + basePath: "", version: "2026.4.22", ...overrides, }; @@ -58,10 +67,186 @@ describe("renderQuickSettings", () => { render(renderQuickSettings(createProps()), container); - expect(container.querySelectorAll(".qs-stack")).toHaveLength(4); + expect(container.querySelectorAll(".qs-stack")).toHaveLength(3); + expect(container.querySelector(".qs-card--personal")).not.toBeNull(); expect(container.querySelectorAll(".qs-card--span-all")).toHaveLength(1); }); + it("keeps the local user name fixed and shows the assistant identity", () => { + const container = document.createElement("div"); + + render( + renderQuickSettings( + createProps({ + assistantName: "Nova", + assistantAvatar: "assets/avatars/nova-portrait.png", + assistantAvatarUrl: "blob:nova", + }), + ), + container, + ); + + const titles = Array.from(container.querySelectorAll(".qs-identity-card__title")).map((node) => + node.textContent?.trim(), + ); + expect(titles).toContain("You"); + expect(titles).toContain("Nova"); + expect(container.querySelector('input[placeholder="You"]')).toBeNull(); + expect( + Array.from(container.querySelectorAll(".qs-row__label")).some( + (node) => node.textContent?.trim() === "Name", + ), + ).toBe(false); + expect(container.querySelector(".qs-assistant-avatar")?.getAttribute("src")).toBe("blob:nova"); + }); + + it("renders same-origin assistant avatar routes from IDENTITY.md", () => { + const container = document.createElement("div"); + + render( + renderQuickSettings( + createProps({ + assistantName: "Nova", + assistantAvatar: "/avatar/main", + assistantAvatarUrl: "/avatar/main", + assistantAvatarSource: "assets/avatars/nova-portrait.png", + assistantAvatarStatus: "local", + }), + ), + container, + ); + + expect(container.querySelector(".qs-assistant-avatar")?.getAttribute("src")).toBe( + "/avatar/main", + ); + }); + + it("shows the IDENTITY.md avatar source when the assistant falls back to the logo", () => { + const container = document.createElement("div"); + + render( + renderQuickSettings( + createProps({ + assistantName: "Nova", + assistantAvatar: "/avatar/main", + assistantAvatarUrl: null, + assistantAvatarSource: "assets/avatars/nova-portrait.png", + assistantAvatarStatus: "none", + assistantAvatarReason: "missing", + }), + ), + container, + ); + + expect(container.querySelector(".qs-assistant-avatar")?.getAttribute("src")).toBe( + "apple-touch-icon.png", + ); + expect(container.querySelector(".qs-identity-card__source")?.textContent).toContain( + "assets/avatars/nova-portrait.png", + ); + expect(container.querySelector(".qs-identity-card__issue")?.textContent?.trim()).toBe( + "File not found", + ); + expect( + Array.from(container.querySelectorAll("label.btn")).some( + (label) => label.textContent?.trim() === "Choose image", + ), + ).toBe(true); + }); + + it("reads assistant image imports into an override", () => { + const onAssistantAvatarOverrideChange = vi.fn(); + const readAsDataURL = vi.fn(function (this: FileReader) { + Object.defineProperty(this, "result", { + configurable: true, + value: "data:image/png;base64,YXZhdGFy", + }); + this.dispatchEvent(new Event("load")); + }); + class MockFileReader { + result: string | null = null; + listeners = new Map void>>(); + addEventListener(type: string, listener: (event: Event) => void) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + dispatchEvent(event: Event) { + for (const listener of this.listeners.get(event.type) ?? []) { + listener(event); + } + return true; + } + readAsDataURL = readAsDataURL; + } + vi.stubGlobal("FileReader", MockFileReader); + + try { + const container = document.createElement("div"); + render( + renderQuickSettings( + createProps({ + assistantAvatarSource: "assets/avatars/nova-portrait.png", + assistantAvatarStatus: "none", + assistantAvatarReason: "missing", + onAssistantAvatarOverrideChange, + }), + ), + container, + ); + + const inputs = Array.from(container.querySelectorAll('input[type="file"]')); + const input = inputs.find((node) => + node.closest(".qs-identity-card--assistant"), + ) as HTMLInputElement | null; + expect(input).not.toBeNull(); + if (!input) { + return; + } + + Object.defineProperty(input, "files", { + configurable: true, + value: [new File(["avatar"], "avatar.png", { type: "image/png" })], + }); + input.dispatchEvent(new Event("change")); + + expect(readAsDataURL).toHaveBeenCalledTimes(1); + expect(onAssistantAvatarOverrideChange).toHaveBeenCalledWith( + "data:image/png;base64,YXZhdGFy", + ); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("can clear an assistant avatar override back to IDENTITY.md", () => { + const onAssistantAvatarClearOverride = vi.fn(); + const container = document.createElement("div"); + + render( + renderQuickSettings( + createProps({ + assistantAvatar: "data:image/png;base64,b3ZlcnJpZGU=", + assistantAvatarUrl: "data:image/png;base64,b3ZlcnJpZGU=", + assistantAvatarSource: "data:image/png;base64,...", + assistantAvatarStatus: "data", + assistantAvatarOverride: "data:image/png;base64,b3ZlcnJpZGU=", + onAssistantAvatarClearOverride, + }), + ), + container, + ); + + expect(container.querySelector(".qs-identity-card__source")?.textContent).toContain( + "UI override", + ); + const clear = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Clear override", + ); + expect(clear).not.toBeUndefined(); + clear?.dispatchEvent(new Event("click")); + + expect(onAssistantAvatarClearOverride).toHaveBeenCalledTimes(1); + }); + it("rejects oversized avatar uploads before reading them", () => { const onUserAvatarChange = vi.fn(); const fileReader = vi.fn(); @@ -71,7 +256,9 @@ describe("renderQuickSettings", () => { const container = document.createElement("div"); render(renderQuickSettings(createProps({ onUserAvatarChange })), container); - const input = container.querySelector('input[type="file"]') as HTMLInputElement | null; + const input = Array.from(container.querySelectorAll('input[type="file"]')).find( + (node) => !node.closest(".qs-identity-card--assistant"), + ) as HTMLInputElement | null; expect(input).not.toBeNull(); if (!input) { return; diff --git a/ui/src/ui/views/config-quick.ts b/ui/src/ui/views/config-quick.ts index 6adfb3dc75e..e0a9ce59454 100644 --- a/ui/src/ui/views/config-quick.ts +++ b/ui/src/ui/views/config-quick.ts @@ -8,16 +8,25 @@ import { html, nothing, type TemplateResult } from "lit"; import { icons } from "../icons.ts"; import type { BorderRadiusStop } from "../storage.ts"; +import { normalizeOptionalString } from "../string-coerce.ts"; import type { ThemeTransitionContext } from "../theme-transition.ts"; import type { ThemeMode, ThemeName } from "../theme.ts"; import { - hasLocalUserIdentity, normalizeLocalUserIdentity, resolveLocalUserAvatarText, resolveLocalUserAvatarUrl, - resolveLocalUserName, } from "../user-identity.ts"; -import { CONFIG_PRESETS, detectActivePreset, type ConfigPresetId } from "./config-presets.ts"; +import { + assistantAvatarFallbackUrl, + resolveChatAvatarRenderUrl, + resolveAssistantTextAvatar, +} from "./agents-utils.ts"; +import { + CONFIG_PRESETS, + detectActivePreset, + getPresetById, + type ConfigPresetId, +} from "./config-presets.ts"; // ── Types ── @@ -73,14 +82,20 @@ export type QuickSettingsProps = { onOpenCustomThemeImport?: () => void; setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void; setBorderRadius: (value: number) => void; - userName?: string | null; userAvatar?: string | null; - onUserNameChange?: (next: string) => void; onUserAvatarChange?: (next: string | null) => void; // Presets configObject?: Record; - onApplyPreset?: (presetId: ConfigPresetId) => void; + savedConfigObject?: Record; + configDirty?: boolean; + configSaving?: boolean; + configApplying?: boolean; + configReady?: boolean; + onSelectPreset?: (presetId: ConfigPresetId) => void; + onResetConfig?: () => void; + onSaveConfig?: () => void; + onApplyConfig?: () => void; // Navigation onAdvancedSettings?: () => void; @@ -89,6 +104,17 @@ export type QuickSettingsProps = { connected: boolean; gatewayUrl: string; assistantName: string; + assistantAvatar?: string | null; + assistantAvatarUrl?: string | null; + assistantAvatarSource?: string | null; + assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null; + assistantAvatarReason?: string | null; + assistantAvatarOverride?: string | null; + assistantAvatarUploadBusy?: boolean; + assistantAvatarUploadError?: string | null; + onAssistantAvatarOverrideChange?: (dataUrl: string) => void | Promise; + onAssistantAvatarClearOverride?: () => void | Promise; + basePath?: string | null; version: string; }; @@ -110,9 +136,11 @@ const BORDER_RADIUS_STOPS: Array<{ value: BorderRadiusStop; label: string }> = [ ]; const THINKING_LEVELS = ["off", "low", "medium", "high"]; +const LOCAL_USER_LABEL = "You"; // Keep raw uploads comfortably below the 2 MB persisted data URL limit after // base64 expansion and a small MIME/header prefix are added. const MAX_LOCAL_USER_AVATAR_FILE_BYTES = 1_500_000; +const MAX_ASSISTANT_AVATAR_UPLOAD_BYTES = MAX_LOCAL_USER_AVATAR_FILE_BYTES; function renderDefaultUserAvatar() { return html` @@ -123,29 +151,96 @@ function renderDefaultUserAvatar() { `; } -function renderLocalUserAvatarPreview( - name: string | null | undefined, - avatar: string | null | undefined, -) { - const identity = normalizeLocalUserIdentity({ name, avatar }); - const label = resolveLocalUserName(identity); +function renderLocalUserAvatarPreview(avatar: string | null | undefined) { + const identity = normalizeLocalUserIdentity({ name: null, avatar }); const avatarUrl = resolveLocalUserAvatarUrl(identity); const avatarText = resolveLocalUserAvatarText(identity); if (avatarUrl) { - return html`${label}`; + return html`${LOCAL_USER_LABEL}`; } if (avatarText) { - return html`
+ return html`
${avatarText}
`; } return html` -
+
${renderDefaultUserAvatar()}
`; } +function resolveAssistantPreviewAvatarUrl(props: QuickSettingsProps): string | null { + if (props.assistantAvatarStatus === "none" && props.assistantAvatarReason === "missing") { + return null; + } + return resolveChatAvatarRenderUrl(props.assistantAvatarUrl, { + identity: { + avatar: props.assistantAvatar ?? undefined, + avatarUrl: props.assistantAvatarUrl ?? undefined, + }, + }); +} + +function formatAssistantAvatarSource(value: string | null | undefined): string | null { + const source = normalizeOptionalString(value); + if (!source) { + return null; + } + if (/^data:image\//i.test(source)) { + const header = source.slice(0, source.indexOf(",") > 0 ? source.indexOf(",") : 32); + return `${header},...`; + } + return source.length > 72 ? `${source.slice(0, 34)}...${source.slice(-24)}` : source; +} + +function formatAssistantAvatarIssue( + status: QuickSettingsProps["assistantAvatarStatus"], + reason: string | null | undefined, + _rendered: boolean, +): string | null { + if (status === "remote") { + return "Remote URLs are blocked by Control UI image policy"; + } + if (reason === "missing") { + return "File not found"; + } + if (reason === "unsupported_extension") { + return "Unsupported image type"; + } + if (reason === "outside_workspace") { + return "Outside workspace"; + } + if (reason === "too_large") { + return "Image is too large"; + } + return reason ? "Cannot render avatar" : null; +} + +function renderAssistantAvatarPreview(props: QuickSettingsProps) { + const assistantName = normalizeOptionalString(props.assistantName) ?? "Assistant"; + const assistantAvatarUrl = resolveAssistantPreviewAvatarUrl(props); + if (assistantAvatarUrl) { + return html`${assistantName}`; + } + const assistantAvatarText = resolveAssistantTextAvatar(props.assistantAvatar); + if (assistantAvatarText) { + return html`
+ ${assistantAvatarText} +
`; + } + return html` + ${assistantName} + `; +} + function handleLocalUserAvatarFileSelect(e: Event, props: QuickSettingsProps) { const input = e.target as HTMLInputElement; const file = input.files?.[0]; @@ -170,6 +265,101 @@ function handleLocalUserAvatarFileSelect(e: Event, props: QuickSettingsProps) { input.value = ""; } +function handleAssistantAvatarFileSelect(e: Event, props: QuickSettingsProps) { + const input = e.target as HTMLInputElement; + const file = input.files?.[0]; + const onAssistantAvatarOverrideChange = props.onAssistantAvatarOverrideChange; + if (!file || !onAssistantAvatarOverrideChange) { + input.value = ""; + return; + } + if (file.size > MAX_ASSISTANT_AVATAR_UPLOAD_BYTES) { + input.value = ""; + return; + } + const reader = new FileReader(); + reader.addEventListener("load", () => { + const result = typeof reader.result === "string" ? reader.result : ""; + if (result) { + void onAssistantAvatarOverrideChange(result); + } + }); + reader.readAsDataURL(file); + input.value = ""; +} + +type ProfileSettings = { + bootstrapMaxChars: number; + bootstrapTotalMaxChars: number; + contextInjection: "always" | "continuation-skip"; +}; + +const DEFAULT_PROFILE_SETTINGS: ProfileSettings = { + bootstrapMaxChars: 12_000, + bootstrapTotalMaxChars: 60_000, + contextInjection: "always", +}; + +function resolveProfileSettings(config?: Record): ProfileSettings { + const agents = config?.agents as Record | undefined; + const defaults = agents?.defaults as Record | undefined; + const bootstrapMaxChars = + typeof defaults?.bootstrapMaxChars === "number" && Number.isFinite(defaults.bootstrapMaxChars) + ? Math.floor(defaults.bootstrapMaxChars) + : DEFAULT_PROFILE_SETTINGS.bootstrapMaxChars; + const bootstrapTotalMaxChars = + typeof defaults?.bootstrapTotalMaxChars === "number" && + Number.isFinite(defaults.bootstrapTotalMaxChars) + ? Math.floor(defaults.bootstrapTotalMaxChars) + : DEFAULT_PROFILE_SETTINGS.bootstrapTotalMaxChars; + const contextInjection = + defaults?.contextInjection === "continuation-skip" ? "continuation-skip" : "always"; + return { bootstrapMaxChars, bootstrapTotalMaxChars, contextInjection }; +} + +function profileSettingsEqual(a: ProfileSettings, b: ProfileSettings): boolean { + return ( + a.bootstrapMaxChars === b.bootstrapMaxChars && + a.bootstrapTotalMaxChars === b.bootstrapTotalMaxChars && + a.contextInjection === b.contextInjection + ); +} + +function formatCharBudget(value: number): string { + return `${value.toLocaleString()} chars`; +} + +function formatContextInjectionLabel(mode: ProfileSettings["contextInjection"]): string { + return mode === "always" ? "Every turn" : "Skip safe follow-ups"; +} + +function describeContextInjection(mode: ProfileSettings["contextInjection"]): string { + return mode === "always" + ? "Reinject workspace bootstrap context on every turn." + : "Skip bootstrap reinjection after a completed safe follow-up."; +} + +function renderProfileStat(params: { + label: string; + value: string; + previousValue: string; + note: string; +}) { + const changed = params.value !== params.previousValue; + return html` +
+
+ ${params.label} + ${params.value} +
+
+ ${changed ? `Was ${params.previousValue}` : "Matches current default"} +
+
${params.note}
+
+ `; +} + // ── Card renderers ── function renderCardHeader(icon: TemplateResult, title: string, action?: TemplateResult) { @@ -413,34 +603,111 @@ function renderAppearanceCard(props: QuickSettingsProps) { function renderPersonalCard(props: QuickSettingsProps) { const identity = normalizeLocalUserIdentity({ - name: props.userName ?? null, + name: null, avatar: props.userAvatar ?? null, }); const avatarText = resolveLocalUserAvatarText(identity) ?? ""; - const label = resolveLocalUserName(identity); + const assistantName = normalizeOptionalString(props.assistantName) ?? "Assistant"; + const assistantAvatarUrl = resolveAssistantPreviewAvatarUrl(props); + const assistantAvatarRendered = Boolean( + assistantAvatarUrl || resolveAssistantTextAvatar(props.assistantAvatar), + ); + const assistantAvatarSource = formatAssistantAvatarSource(props.assistantAvatarSource); + const assistantAvatarIssue = formatAssistantAvatarIssue( + props.assistantAvatarStatus ?? null, + props.assistantAvatarReason, + assistantAvatarRendered, + ); + const assistantAvatarOverride = normalizeOptionalString(props.assistantAvatarOverride); + const assistantAvatarSourceLabel = assistantAvatarOverride ? "UI override" : "IDENTITY.md"; + const canOverrideAssistantAvatar = Boolean(props.onAssistantAvatarOverrideChange); + const assistantAvatarSubtitle = assistantAvatarOverride + ? "Override from settings" + : assistantAvatarIssue + ? "Fallback avatar" + : assistantAvatarRendered + ? "From IDENTITY.md" + : "Fallback logo"; return html` -
+
${renderCardHeader(icons.image, "Personal")}
-
- ${renderLocalUserAvatarPreview(props.userName, props.userAvatar)} -
-
${label}
-
This browser only
-
-
-
- +
+
+ ${renderLocalUserAvatarPreview(props.userAvatar)} +
+
User
+
${LOCAL_USER_LABEL}
+
Avatar is browser-local
+
+
+
+ ${renderAssistantAvatarPreview(props)} +
+
Assistant
+
${assistantName}
+
${assistantAvatarSubtitle}
+ ${assistantAvatarSource + ? html` +
+ ${assistantAvatarSourceLabel} + ${assistantAvatarSource} +
+ ` + : nothing} + ${assistantAvatarIssue + ? html`
${assistantAvatarIssue}
` + : nothing} + ${canOverrideAssistantAvatar + ? html` +
+ + ${assistantAvatarOverride + ? html` + + ` + : nothing} +
+ Stores a Control UI override. Clear it to return to IDENTITY.md. +
+
+ ` + : nothing} + ${props.assistantAvatarUploadError + ? html`
+ ${props.assistantAvatarUploadError} +
` + : nothing} +
+
@@ -486,24 +752,209 @@ function renderPersonalCard(props: QuickSettingsProps) { } function renderPresetsCard(props: QuickSettingsProps) { - const activePreset = props.configObject ? detectActivePreset(props.configObject) : "personal"; + const draftConfig = props.configObject ?? props.savedConfigObject ?? {}; + const savedConfig = props.savedConfigObject ?? {}; + const selectedPresetId = detectActivePreset(draftConfig); + const savedPresetId = detectActivePreset(savedConfig); + const selectedPreset = selectedPresetId ? getPresetById(selectedPresetId) : undefined; + const savedPreset = savedPresetId ? getPresetById(savedPresetId) : undefined; + const draftSettings = resolveProfileSettings(draftConfig); + const savedSettings = resolveProfileSettings(savedConfig); + const hasPendingProfileChange = !profileSettingsEqual(draftSettings, savedSettings); + const hasPendingConfigChange = props.configDirty === true; + const canCommit = + props.connected && + props.configReady === true && + props.configSaving !== true && + props.configApplying !== true; + const stateBanner = hasPendingProfileChange + ? html` +
+ +
+ ${selectedPreset?.label ?? "Custom"} is selected but not saved yet. + Save Profile writes it as the default. Apply Now writes it and reloads the current + session. +
+
+ ` + : savedPreset + ? html` +
+ +
+ ${savedPreset.label} is your current default. + Profiles only change bootstrap size and follow-up reinjection behavior. +
+
+ ` + : html` +
+ +
+ Custom bootstrap settings are active. + Choose a built-in profile to replace the current custom values. +
+
+ `; + const panelTitle = selectedPreset?.label ?? "Custom Configuration"; + const panelDescription = + selectedPreset?.detail ?? "This config does not currently match one of the built-in profiles."; + const panelImpact = + selectedPreset?.impact ?? + "Pick a profile to stage a focused change to bootstrap size and follow-up behavior."; + const commitCopy = hasPendingProfileChange + ? "Save Profile writes this as the default. Apply Now writes it and reloads the current session." + : "Other staged config edits are pending. Saving here will commit all staged config changes."; return html`
- ${renderCardHeader(icons.zap, "Profile")} -
- ${CONFIG_PRESETS.map( - (preset) => html` - - `, - )} + ${renderCardHeader( + icons.zap, + "Context Profile", + hasPendingProfileChange + ? html`Pending` + : savedPreset + ? html`Saved` + : html`Custom`, + )} +
+
+
Bootstrap Context
+

+ Choose how much workspace context OpenClaw injects into each run. These profiles do not + change your model, tools, channels, or theme. +

+ ${stateBanner} +
+ ${CONFIG_PRESETS.map((preset) => { + const presetDefaults = ((preset.patch.agents as Record | undefined) + ?.defaults ?? {}) as Record; + const presetContext = + presetDefaults.contextInjection === "continuation-skip" + ? "continuation-skip" + : "always"; + return html` + + `; + })} +
+
+ +
+
+ ${selectedPreset ? "Selected Profile" : "Current Values"} +
+

${panelTitle}

+

${panelDescription}

+
${panelImpact}
+ +
+ ${renderProfileStat({ + label: "Bootstrap Per File", + value: formatCharBudget(draftSettings.bootstrapMaxChars), + previousValue: formatCharBudget(savedSettings.bootstrapMaxChars), + note: "Maximum context injected from any single bootstrap file.", + })} + ${renderProfileStat({ + label: "Bootstrap Total", + value: formatCharBudget(draftSettings.bootstrapTotalMaxChars), + previousValue: formatCharBudget(savedSettings.bootstrapTotalMaxChars), + note: "Total combined context allowed across all bootstrap files.", + })} + ${renderProfileStat({ + label: "Follow-up Turns", + value: formatContextInjectionLabel(draftSettings.contextInjection), + previousValue: formatContextInjectionLabel(savedSettings.contextInjection), + note: describeContextInjection(draftSettings.contextInjection), + })} +
+ + ${hasPendingConfigChange + ? html` +
+
${commitCopy}
+
+ + + +
+
+ ` + : html` + + `} +
`; @@ -526,6 +977,10 @@ function renderStack(...cards: TemplateResult[]) { return html`
${cards}
`; } +function renderWideStack(...cards: TemplateResult[]) { + return html`
${cards}
`; +} + // ── Main render ── export function renderQuickSettings(props: QuickSettingsProps) { @@ -540,9 +995,9 @@ export function renderQuickSettings(props: QuickSettingsProps) {
${renderStack(renderModelCard(props), renderSecurityCard(props))} + ${renderPersonalCard(props)} ${renderStack(renderChannelsCard(props), renderAutomationsCard(props))} - ${renderStack(renderAppearanceCard(props))} ${renderStack(renderPersonalCard(props))} - ${renderPresetsCard(props)} + ${renderWideStack(renderAppearanceCard(props))} ${renderPresetsCard(props)}
${renderConnectionFooter(props)}