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.
This commit is contained in:
Val Alexander
2026-04-25 06:27:22 -05:00
committed by GitHub
parent 443b837bd5
commit fc5920fb51
46 changed files with 2249 additions and 274 deletions

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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 () => {

View File

@@ -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 };
}

View File

@@ -230,6 +230,7 @@ async function withSafeBinsExecTool(
await withEnvAsync(
{
OPENCLAW_SHELL_ENV_TIMEOUT_MS: "1",
PATH: "/usr/bin:/bin",
SHELL: "/bin/sh",
},
async () => {

View File

@@ -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");
});
});

View File

@@ -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 = {

View File

@@ -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";

View File

@@ -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[];

View File

@@ -98,7 +98,7 @@ describe("handleControlUiHttpRequest", () => {
async function runAvatarRequest(params: {
url: string;
method: "GET" | "HEAD";
method: "GET" | "HEAD" | "POST";
resolveAvatar: Parameters<typeof handleControlUiAvatarRequest>[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",
});
});

View File

@@ -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<boolean> {
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)],

View File

@@ -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 },

View File

@@ -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", () => {

View File

@@ -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)) {

View File

@@ -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 () => {

3
src/shared/regexp.ts Normal file
View File

@@ -0,0 +1,3 @@
export function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -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.
*/

View File

@@ -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;
}
}

View File

@@ -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");
});
});

View File

@@ -54,6 +54,9 @@ function makeHost(overrides?: Partial<ChatHost>): ChatHost {
basePath: "",
hello: null,
chatAvatarUrl: null,
chatAvatarSource: null,
chatAvatarStatus: null,
chatAvatarReason: null,
chatSideResult: null,
chatSideResultTerminalRuns: new Set<string>(),
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 () => {

View File

@@ -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<string>;
chatModelOverrides: Record<string, ChatModelOverride | null>;
@@ -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<string, string> | 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);
}
}
}

View File

@@ -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(),

View File

@@ -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<typeof refreshChatAvatar>[0]);
void loadAgents(host as unknown as AgentsState);
void loadHealthState(host as unknown as HealthState);
void loadNodes(host as unknown as NodesState, { quiet: true });

View File

@@ -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[];

View File

@@ -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;

View File

@@ -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<string, unknown>;
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<string, unknown> | 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<string, unknown> | undefined)
?.defaults ?? {}) as Record<string, unknown>;
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<string, unknown> | 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,

View File

@@ -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<string, ChatModelOverride | null>;
chatModelsLoading: boolean;

View File

@@ -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<string, ChatModelOverride | null> = {};
@state() chatModelsLoading = false;

View File

@@ -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 };
}

View File

@@ -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<HTMLImageElement>(".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");

View File

@@ -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`<img
class="chat-avatar ${className} chat-avatar--logo"
src="${agentLogoUrl(basePath ?? "")}"
src="${assistantFallbackAvatar}"
alt="${assistantName}"
/>`;
}
@@ -766,17 +772,15 @@ function renderAvatar(
}
return html`<img
class="chat-avatar ${className} chat-avatar--logo"
src="${agentLogoUrl(basePath ?? "")}"
src="${assistantFallbackAvatar}"
alt="${assistantName}"
/>`;
}
/* Assistant with no custom avatar: use logo when basePath available */
if (normalized === "assistant" && basePath) {
const logoUrl = agentLogoUrl(basePath);
if (normalized === "assistant") {
return html`<img
class="chat-avatar ${className} chat-avatar--logo"
src="${logoUrl}"
src="${assistantFallbackAvatar}"
alt="${assistantName}"
/>`;
}
@@ -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,

View File

@@ -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.",
});
});
});

View File

@@ -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.",
});
}

View File

@@ -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", () => {

View File

@@ -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<string, unknown>) {
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<string, unknown>) => 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<string, unknown>) {
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<string, unknown>));
}
export function resetConfigPendingChanges(state: ConfigState) {
state.configForm = cloneConfigObject(
state.configFormOriginal ?? state.configSnapshot?.config ?? {},

View File

@@ -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"]);

View File

@@ -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)

View File

@@ -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;
};

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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<HTMLImageElement>(
".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<HTMLImageElement>(".agent-chat__avatar--logo img");
expect(avatar).not.toBeNull();
expect(avatar?.getAttribute("src")).toBe("apple-touch-icon.png");
expect(avatar?.getAttribute("alt")).toBe("Val");
});
});

View File

@@ -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}
</div>`
: html`<div class="agent-chat__avatar agent-chat__avatar--logo">
<img src=${logoUrl} alt="OpenClaw" />
<img src=${fallbackAvatarUrl} alt=${name} />
</div>`}
<h2>${name}</h2>
<div class="agent-chat__badges">

View File

@@ -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();
});
});

View File

@@ -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<string, unknown>;
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<string, unknown>): ConfigPrese
const agents = config.agents as Record<string, unknown> | undefined;
const defaults = agents?.defaults as Record<string, unknown> | 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<string, unknown>)?.defaults as
| Record<string, unknown>
@@ -100,7 +122,8 @@ export function detectActivePreset(config: Record<string, unknown>): ConfigPrese
}
if (
maxChars === presetDefaults.bootstrapMaxChars &&
totalMax === presetDefaults.bootstrapTotalMaxChars
totalMax === presetDefaults.bootstrapTotalMaxChars &&
contextInjection === presetDefaults.contextInjection
) {
return preset.id;
}

View File

@@ -37,16 +37,25 @@ function createProps(overrides: Partial<QuickSettingsProps> = {}): 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<string, Array<(event: Event) => 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;

View File

@@ -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<string, unknown>;
onApplyPreset?: (presetId: ConfigPresetId) => void;
savedConfigObject?: Record<string, unknown>;
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<void>;
onAssistantAvatarClearOverride?: () => void | Promise<void>;
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`<img class="qs-user-avatar" src=${avatarUrl} alt=${label} />`;
return html`<img class="qs-user-avatar" src=${avatarUrl} alt=${LOCAL_USER_LABEL} />`;
}
if (avatarText) {
return html`<div class="qs-user-avatar qs-user-avatar--text" aria-label=${label}>
return html`<div class="qs-user-avatar qs-user-avatar--text" aria-label=${LOCAL_USER_LABEL}>
${avatarText}
</div>`;
}
return html`
<div class="qs-user-avatar qs-user-avatar--default" aria-label=${label}>
<div class="qs-user-avatar qs-user-avatar--default" aria-label=${LOCAL_USER_LABEL}>
${renderDefaultUserAvatar()}
</div>
`;
}
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`<img class="qs-assistant-avatar" src=${assistantAvatarUrl} alt=${assistantName} />`;
}
const assistantAvatarText = resolveAssistantTextAvatar(props.assistantAvatar);
if (assistantAvatarText) {
return html`<div
class="qs-assistant-avatar qs-assistant-avatar--text"
aria-label=${assistantName}
>
${assistantAvatarText}
</div>`;
}
return html`
<img
class="qs-assistant-avatar qs-assistant-avatar--fallback"
src=${assistantAvatarFallbackUrl(props.basePath ?? "")}
alt=${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<string, unknown>): ProfileSettings {
const agents = config?.agents as Record<string, unknown> | undefined;
const defaults = agents?.defaults as Record<string, unknown> | 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`
<div class="qs-profile-stat ${changed ? "qs-profile-stat--changed" : ""}">
<div class="qs-profile-stat__header">
<span class="qs-profile-stat__label">${params.label}</span>
<span class="qs-profile-stat__value">${params.value}</span>
</div>
<div class="qs-profile-stat__sub">
${changed ? `Was ${params.previousValue}` : "Matches current default"}
</div>
<div class="qs-profile-stat__note muted">${params.note}</div>
</div>
`;
}
// ── 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`
<div class="qs-card">
<div class="qs-card qs-card--personal">
${renderCardHeader(icons.image, "Personal")}
<div class="qs-card__body">
<div class="qs-personal-preview">
${renderLocalUserAvatarPreview(props.userName, props.userAvatar)}
<div class="qs-personal-preview__copy">
<div class="qs-personal-preview__title">${label}</div>
<div class="muted">This browser only</div>
</div>
</div>
<div class="qs-row">
<label class="qs-field">
<span class="qs-row__label">Name</span>
<input
class="qs-field__input"
type="text"
maxlength="50"
.value=${props.userName ?? ""}
placeholder="You"
@input=${(e: Event) => props.onUserNameChange?.((e.target as HTMLInputElement).value)}
/>
</label>
<div class="qs-identity-grid">
<section class="qs-identity-card" aria-label="Your local chat identity">
${renderLocalUserAvatarPreview(props.userAvatar)}
<div class="qs-identity-card__copy">
<div class="qs-identity-card__eyebrow">User</div>
<div class="qs-identity-card__title">${LOCAL_USER_LABEL}</div>
<div class="qs-identity-card__sub">Avatar is browser-local</div>
</div>
</section>
<section
class="qs-identity-card qs-identity-card--assistant"
aria-label="Assistant identity"
>
${renderAssistantAvatarPreview(props)}
<div class="qs-identity-card__copy">
<div class="qs-identity-card__eyebrow">Assistant</div>
<div class="qs-identity-card__title">${assistantName}</div>
<div class="qs-identity-card__sub">${assistantAvatarSubtitle}</div>
${assistantAvatarSource
? html`
<div
class="qs-identity-card__source"
title=${props.assistantAvatarSource ?? ""}
>
<span>${assistantAvatarSourceLabel}</span>
<code>${assistantAvatarSource}</code>
</div>
`
: nothing}
${assistantAvatarIssue
? html`<div class="qs-identity-card__issue">${assistantAvatarIssue}</div>`
: nothing}
${canOverrideAssistantAvatar
? html`
<div class="qs-identity-card__repair">
<label class="btn btn--sm">
${props.assistantAvatarUploadBusy
? "Saving..."
: assistantAvatarOverride
? "Replace image"
: "Choose image"}
<input
type="file"
accept="image/*"
hidden
?disabled=${props.assistantAvatarUploadBusy === true}
@change=${(e: Event) => handleAssistantAvatarFileSelect(e, props)}
/>
</label>
${assistantAvatarOverride
? html`
<button
type="button"
class="btn btn--sm btn--ghost"
?disabled=${props.assistantAvatarUploadBusy === true}
@click=${() => {
void props.onAssistantAvatarClearOverride?.();
}}
>
Clear override
</button>
`
: nothing}
<div class="muted">
Stores a Control UI override. Clear it to return to IDENTITY.md.
</div>
</div>
`
: nothing}
${props.assistantAvatarUploadError
? html`<div class="qs-identity-card__error">
${props.assistantAvatarUploadError}
</div>`
: nothing}
</div>
</section>
</div>
<div class="qs-row">
<label class="qs-field">
@@ -471,13 +738,12 @@ function renderPersonalCard(props: QuickSettingsProps) {
<button
type="button"
class="btn btn--sm btn--ghost"
?disabled=${!hasLocalUserIdentity(identity)}
?disabled=${!identity.avatar}
@click=${() => {
props.onUserNameChange?.("");
props.onUserAvatarChange?.(null);
}}
>
Clear
Clear avatar
</button>
</div>
</div>
@@ -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`
<div class="qs-profile-state qs-profile-state--pending" aria-live="polite">
<span class="qs-status-dot"></span>
<div class="qs-profile-state__text">
<span class="qs-profile-state__title"
>${selectedPreset?.label ?? "Custom"} is selected but not saved yet.</span
>
<span class="qs-profile-state__copy"
>Save Profile writes it as the default. Apply Now writes it and reloads the current
session.</span
>
</div>
</div>
`
: savedPreset
? html`
<div class="qs-profile-state qs-profile-state--ok" aria-live="polite">
<span class="qs-status-dot qs-status-dot--ok"></span>
<div class="qs-profile-state__text">
<span class="qs-profile-state__title"
>${savedPreset.label} is your current default.</span
>
<span class="qs-profile-state__copy"
>Profiles only change bootstrap size and follow-up reinjection behavior.</span
>
</div>
</div>
`
: html`
<div class="qs-profile-state" aria-live="polite">
<span class="qs-status-dot"></span>
<div class="qs-profile-state__text">
<span class="qs-profile-state__title">Custom bootstrap settings are active.</span>
<span class="qs-profile-state__copy"
>Choose a built-in profile to replace the current custom values.</span
>
</div>
</div>
`;
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`
<div class="qs-card qs-card--span-all">
${renderCardHeader(icons.zap, "Profile")}
<div class="qs-card__body qs-presets-grid">
${CONFIG_PRESETS.map(
(preset) => html`
<button
class="qs-preset ${preset.id === activePreset ? "qs-preset--active" : ""}"
@click=${() => props.onApplyPreset?.(preset.id)}
>
<span class="qs-preset__icon">${preset.icon}</span>
<span class="qs-preset__label">${preset.label}</span>
<span class="qs-preset__desc muted">${preset.description}</span>
</button>
`,
)}
${renderCardHeader(
icons.zap,
"Context Profile",
hasPendingProfileChange
? html`<span class="qs-badge qs-badge--warn">Pending</span>`
: savedPreset
? html`<span class="qs-badge qs-badge--ok">Saved</span>`
: html`<span class="qs-badge">Custom</span>`,
)}
<div class="qs-card__body qs-profiles">
<div class="qs-profiles__copy">
<div class="qs-profiles__eyebrow">Bootstrap Context</div>
<p class="qs-profiles__intro">
Choose how much workspace context OpenClaw injects into each run. These profiles do not
change your model, tools, channels, or theme.
</p>
${stateBanner}
<div class="qs-presets-grid">
${CONFIG_PRESETS.map((preset) => {
const presetDefaults = ((preset.patch.agents as Record<string, unknown> | undefined)
?.defaults ?? {}) as Record<string, unknown>;
const presetContext =
presetDefaults.contextInjection === "continuation-skip"
? "continuation-skip"
: "always";
return html`
<button
type="button"
class="qs-preset ${preset.id === selectedPresetId ? "qs-preset--active" : ""}"
aria-pressed=${preset.id === selectedPresetId}
@click=${() => props.onSelectPreset?.(preset.id)}
>
<div class="qs-preset__head">
<div class="qs-preset__identity">
<span class="qs-preset__icon">${preset.icon}</span>
<div class="qs-preset__identity-copy">
<span class="qs-preset__label">${preset.label}</span>
<span class="qs-preset__desc muted">${preset.description}</span>
</div>
</div>
<div class="qs-preset__badges">
${preset.id === savedPresetId
? html`<span class="qs-badge qs-badge--ok">Current</span>`
: nothing}
${hasPendingProfileChange && preset.id === selectedPresetId
? html`<span class="qs-badge qs-badge--warn">Selected</span>`
: nothing}
</div>
</div>
<div class="qs-preset__meta">
<span
>${formatCharBudget(Number(presetDefaults.bootstrapMaxChars ?? 0))} per
file</span
>
<span
>${formatCharBudget(Number(presetDefaults.bootstrapTotalMaxChars ?? 0))}
total</span
>
<span>${formatContextInjectionLabel(presetContext)}</span>
</div>
</button>
`;
})}
</div>
</div>
<div class="qs-profile-panel">
<div class="qs-profile-panel__eyebrow">
${selectedPreset ? "Selected Profile" : "Current Values"}
</div>
<h4 class="qs-profile-panel__title">${panelTitle}</h4>
<p class="qs-profile-panel__copy">${panelDescription}</p>
<div class="qs-profile-panel__impact">${panelImpact}</div>
<div class="qs-profile-panel__stats">
${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),
})}
</div>
${hasPendingConfigChange
? html`
<div class="qs-profile-panel__actions">
<div class="qs-profile-panel__actions-copy muted">${commitCopy}</div>
<div class="qs-profile-panel__actions-row">
<button
class="btn btn--sm"
?disabled=${props.configSaving === true || props.configApplying === true}
@click=${props.onResetConfig}
>
Discard
</button>
<button
class="btn btn--sm primary"
?disabled=${!canCommit}
@click=${props.onSaveConfig}
>
${props.configSaving === true
? "Saving…"
: hasPendingProfileChange
? "Save Profile"
: "Save Changes"}
</button>
<button
class="btn btn--sm"
?disabled=${!canCommit}
@click=${props.onApplyConfig}
>
${props.configApplying === true ? "Applying…" : "Apply Now"}
</button>
</div>
</div>
`
: html`
<div class="qs-profile-panel__footer muted" aria-live="polite">
${savedPreset
? "Saved and ready. Choose another profile to stage a change."
: "Current values are custom. Choose a profile to stage a change."}
</div>
`}
</div>
</div>
</div>
`;
@@ -526,6 +977,10 @@ function renderStack(...cards: TemplateResult[]) {
return html`<div class="qs-stack">${cards}</div>`;
}
function renderWideStack(...cards: TemplateResult[]) {
return html`<div class="qs-stack qs-stack--wide">${cards}</div>`;
}
// ── Main render ──
export function renderQuickSettings(props: QuickSettingsProps) {
@@ -540,9 +995,9 @@ export function renderQuickSettings(props: QuickSettingsProps) {
<div class="qs-grid">
${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)}
</div>
${renderConnectionFooter(props)}