feat(control-ui): personalize local user identity and tighten layouts

## Summary
- add browser-local operator identity in Control UI and route user name/avatar rendering through the shared chat/avatar path used by assistant and agent surfaces
- tighten Quick Settings, fallback chip, and mobile chat layout behavior so the personalized UI uses space better and avoids clipped controls
- guard oversized local avatar uploads before FileReader allocation, restore the fallback-chip keyboard focus ring, and add the changelog note for the user-visible Control UI work

## Testing
- pnpm test ui/src/ui/views/config-quick.test.ts ui/src/styles/components.test.ts
- pnpm check:changed
This commit is contained in:
Val Alexander
2026-04-22 17:38:58 -05:00
committed by GitHub
parent 5daa104e63
commit 12bbb371d0
20 changed files with 869 additions and 13 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
- Providers/Tencent: add the bundled Tencent Cloud provider plugin with TokenHub and Token Plan onboarding, docs, `hy3-preview` model catalog entries, and tiered Hy3 pricing metadata. (#68460) Thanks @JuniperSling.
- TUI: add local embedded mode for running terminal chats without a Gateway while keeping plugin approval gates enforced. (#66767) Thanks @fuller-stack-dev.
- CLI/Claude: default `claude-cli` runs to warm stdio sessions, including custom configs that omit transport fields, and resume from the stored Claude session after Gateway restarts or idle exits. (#69679) Thanks @obviyus.
- Control UI/settings+chat: add a browser-local personal identity for the operator (name plus local-safe avatar), route user identity rendering through the shared chat/avatar path used by assistant and agent surfaces, and tighten Quick Settings, agent fallback chips, and narrow-screen chat layouts so personalization no longer wastes space or clips controls. (#70362) Thanks @BunsDev.
### Fixes

View File

@@ -3411,6 +3411,51 @@ td.data-table-key-col {
padding: 0;
}
.agent-chip-input .chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
}
.agent-chip-input .chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 18px;
height: 18px;
padding: 0;
margin: 0;
border: none;
border-radius: var(--radius-full);
background: transparent;
color: var(--muted);
line-height: 1;
cursor: pointer;
transition:
background var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out),
opacity var(--duration-fast) var(--ease-out);
}
.agent-chip-input .chip-remove:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
color: var(--text);
}
.agent-chip-input .chip-remove:focus-visible:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
color: var(--text);
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.agent-chip-input .chip-remove:disabled {
opacity: 0.45;
cursor: default;
}
.agent-model-meta {
display: grid;
gap: 6px;

View File

@@ -0,0 +1,16 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("agent fallback chip styles", () => {
it("styles the chip remove control inside the agent model input", () => {
const css = readFileSync(new URL("./components.css", import.meta.url), "utf8");
expect(css).toContain(".agent-chip-input .chip {");
expect(css).toContain(".agent-chip-input .chip-remove {");
expect(css).toContain(".agent-chip-input .chip-remove:hover:not(:disabled)");
expect(css).toContain(".agent-chip-input .chip-remove:focus-visible:not(:disabled)");
expect(css).toContain("outline: 2px solid var(--accent);");
expect(css).toContain("outline-offset: 2px;");
expect(css).toContain(".agent-chip-input .chip-remove:disabled");
});
});

View File

@@ -44,16 +44,22 @@
.qs-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr));
align-items: stretch;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: start;
gap: 14px;
}
.qs-stack {
display: grid;
align-content: start;
gap: 14px;
min-width: 0;
}
/* ── Card ── */
.qs-card {
min-width: 0;
height: 100%;
background: var(--card);
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-radius: var(--radius-lg);
@@ -180,6 +186,77 @@
color: var(--accent);
}
.qs-field {
display: grid;
gap: 6px;
width: 100%;
}
.qs-field__input {
width: 100%;
min-width: 0;
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--bg) 80%, var(--bg-elevated) 20%);
color: var(--text);
font: inherit;
font-size: 0.8125rem;
padding: 8px 10px;
}
.qs-field__input::placeholder {
color: var(--muted);
}
.qs-personal-preview {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px 10px;
}
.qs-personal-preview__copy {
min-width: 0;
}
.qs-personal-preview__title {
font-size: 0.95rem;
font-weight: 650;
color: var(--text-strong);
}
.qs-user-avatar {
width: 40px;
height: 40px;
flex: 0 0 auto;
border-radius: var(--radius-md);
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
object-fit: cover;
object-position: center;
}
.qs-user-avatar--text,
.qs-user-avatar--default {
display: grid;
place-items: center;
background: var(--accent-subtle);
color: var(--accent);
font-size: 0.875rem;
font-weight: 650;
}
.qs-user-avatar--default svg {
width: 18px;
height: 18px;
fill: currentColor;
}
.qs-personal-actions {
display: flex;
gap: 8px;
padding: 10px 16px 16px;
}
.qs-row__chevron svg {
width: 12px;
height: 12px;
@@ -567,6 +644,24 @@
color: var(--muted);
}
@media (max-width: 1380px) {
.qs-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1100px) {
.qs-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.qs-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.qs-container {
padding: 20px 0 40px;

View File

@@ -0,0 +1,20 @@
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");
expect(css).toContain(".qs-personal-preview");
expect(css).toContain(".qs-user-avatar");
expect(css).toContain(".qs-personal-actions");
});
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)");
});
});

View File

@@ -2,6 +2,61 @@
Mobile Layout
=========================================== */
@media (max-width: 1320px) {
.content--chat .content-header {
align-items: stretch;
gap: 12px;
row-gap: 10px;
max-height: 180px;
overflow: visible;
}
.content--chat .content-header > div:first-child {
flex: 1 1 100%;
min-width: 0;
}
.content--chat .page-meta {
width: 100%;
min-width: 0;
justify-content: space-between;
flex-wrap: wrap;
row-gap: 8px;
}
.content--chat .chat-controls {
margin-left: auto;
justify-content: flex-end;
row-gap: 8px;
}
.chat-controls__session-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: start;
gap: 10px 12px;
width: 100%;
}
.chat-controls__thinking-select {
grid-column: 1 / -1;
}
.chat-controls__session,
.chat-controls__model,
.chat-controls__thinking-select {
min-width: 0;
max-width: none;
}
.chat-controls__session select,
.chat-controls__model select,
.chat-controls__thinking-select select {
width: 100%;
max-width: none;
}
}
/* Tablet and smaller: switch the left nav to a slide-over drawer. */
@media (max-width: 1100px) {
.shell,

View File

@@ -0,0 +1,13 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("chat header responsive mobile styles", () => {
it("keeps the chat header and session controls from clipping on narrow widths", () => {
const css = readFileSync(new URL("./layout.mobile.css", import.meta.url), "utf8");
expect(css).toContain("@media (max-width: 1320px)");
expect(css).toContain(".content--chat .content-header");
expect(css).toContain(".chat-controls__session-row");
expect(css).toContain(".chat-controls__thinking-select");
});
});

View File

@@ -1011,6 +1011,10 @@ export function renderApp(state: AppViewState) {
setTheme: (theme, context) => state.setTheme(theme, context),
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?.());
@@ -2295,6 +2299,8 @@ export function renderApp(state: AppViewState) {
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
assistantName: state.assistantName,
assistantAvatar: state.assistantAvatar,
userName: state.userName ?? null,
userAvatar: state.userAvatar ?? null,
localMediaPreviewRoots: state.localMediaPreviewRoots,
embedSandboxMode: state.embedSandboxMode,
allowExternalEmbedUrls: state.allowExternalEmbedUrls,

View File

@@ -53,17 +53,25 @@ import {
tabFromPath,
type Tab,
} from "./navigation.ts";
import { saveSettings, type UiSettings } from "./storage.ts";
import {
saveLocalUserIdentity,
saveSettings,
type LocalUserIdentity,
type UiSettings,
} from "./storage.ts";
import { normalizeOptionalString } from "./string-coerce.ts";
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition.ts";
import { resolveTheme, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts";
import type { AgentsListResult, AttentionItem } from "./types.ts";
import { normalizeLocalUserIdentity } from "./user-identity.ts";
import { resetChatViewState } from "./views/chat.ts";
export { setLastActiveSessionKey } from "./app-last-active-session.ts";
type SettingsHost = {
settings: UiSettings;
userName?: string | null;
userAvatar?: string | null;
password?: string;
theme: ThemeName;
themeMode: ThemeMode;
@@ -93,6 +101,11 @@ type SettingsHost = {
dreamDiaryContent: string | null;
};
type LocalUserIdentityHost = {
userName?: string | null;
userAvatar?: string | null;
};
type SettingsAppHost = SettingsHost &
AgentFilesState &
AgentIdentityState &
@@ -137,6 +150,20 @@ export function applySettings(host: SettingsHost, next: UiSettings) {
host.applySessionKey = host.settings.lastActiveSessionKey;
}
export function applyLocalUserIdentity(
host: LocalUserIdentityHost,
next: Partial<LocalUserIdentity>,
) {
const normalized = normalizeLocalUserIdentity({
name: host.userName,
avatar: host.userAvatar,
...next,
});
host.userName = normalized.name;
host.userAvatar = normalized.avatar;
saveLocalUserIdentity(normalized);
}
function applySessionSelection(host: SettingsHost, session: string) {
host.sessionKey = session;
applySettings(host, {

View File

@@ -66,6 +66,8 @@ export type AppViewState = {
assistantName: string;
assistantAvatar: string | null;
assistantAgentId: string | null;
userName?: string | null;
userAvatar?: string | null;
localMediaPreviewRoots: string[];
embedSandboxMode: EmbedSandboxMode;
allowExternalEmbedUrls: boolean;
@@ -376,6 +378,7 @@ export type AppViewState = {
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
setBorderRadius: (value: number) => void;
applySettings: (next: UiSettings) => void;
applyLocalUserIdentity?: (next: { name?: string | null; avatar?: string | null }) => void;
loadOverview: (opts?: { refresh?: boolean }) => Promise<void>;
loadAssistantIdentity: () => Promise<void>;
loadCron: () => Promise<void>;

View File

@@ -39,6 +39,7 @@ import {
} from "./app-scroll.ts";
import {
applySettings as applySettingsInternal,
applyLocalUserIdentity as applyLocalUserIdentityInternal,
loadCron as loadCronInternal,
loadOverview as loadOverviewInternal,
setTab as setTabInternal,
@@ -77,7 +78,7 @@ import type {
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import type { Tab } from "./navigation.ts";
import type { SidebarContent } from "./sidebar-content.ts";
import { loadSettings, type UiSettings } from "./storage.ts";
import { loadLocalUserIdentity, loadSettings, type UiSettings } from "./storage.ts";
import { VALID_THEME_NAMES, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts";
import type {
AgentsListResult,
@@ -115,6 +116,7 @@ declare global {
}
const bootAssistantIdentity = normalizeAssistantIdentity({});
const bootLocalUserIdentity = loadLocalUserIdentity();
function resolveOnboardingMode(): boolean {
if (!window.location.search) {
@@ -162,6 +164,8 @@ export class OpenClawApp extends LitElement {
@state() assistantName = bootAssistantIdentity.name;
@state() assistantAvatar = bootAssistantIdentity.avatar;
@state() assistantAgentId = bootAssistantIdentity.agentId ?? null;
@state() userName = bootLocalUserIdentity.name;
@state() userAvatar = bootLocalUserIdentity.avatar;
@state() localMediaPreviewRoots: string[] = [];
@state() embedSandboxMode: "strict" | "scripts" | "trusted" = "scripts";
@state() allowExternalEmbedUrls = false;
@@ -636,6 +640,13 @@ export class OpenClawApp extends LitElement {
applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], next);
}
applyLocalUserIdentity(next: { name?: string | null; avatar?: string | null }) {
applyLocalUserIdentityInternal(
this as unknown as Parameters<typeof applyLocalUserIdentityInternal>[0],
next,
);
}
setTab(next: Tab) {
setTabInternal(this as unknown as Parameters<typeof setTabInternal>[0], next);
this.navDrawerOpen = false;

View File

@@ -19,6 +19,23 @@ 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;
}
}
return null;
},
}));
vi.mock("./speech.ts", () => ({
@@ -224,6 +241,84 @@ describe("grouped chat rendering", () => {
expect(avatar?.getAttribute("src")).toBe("/openclaw-logo.svg");
});
it("renders the configured local user name in user message footers", () => {
const container = document.createElement("div");
renderGroupedMessage(
container,
{
role: "user",
content: "hello",
timestamp: 1000,
},
"user",
{ userName: "Buns" },
);
const sender = container.querySelector<HTMLElement>(".chat-group.user .chat-sender-name");
expect(sender?.textContent).toBe("Buns");
});
it("renders a local user image avatar when provided", () => {
const container = document.createElement("div");
renderGroupedMessage(
container,
{
role: "user",
content: "hello",
timestamp: 1000,
},
"user",
{ userName: "Buns", userAvatar: "data:image/png;base64,AAA" },
);
const avatar = container.querySelector<HTMLImageElement>(".chat-avatar.user");
expect(avatar).not.toBeNull();
expect(avatar?.getAttribute("src")).toBe("data:image/png;base64,AAA");
expect(avatar?.getAttribute("alt")).toBe("Buns");
});
it("renders a local user avatar route when provided", () => {
const container = document.createElement("div");
renderGroupedMessage(
container,
{
role: "user",
content: "hello",
timestamp: 1000,
},
"user",
{ userName: "Buns", userAvatar: "/avatar/user" },
);
const avatar = container.querySelector<HTMLImageElement>(".chat-avatar.user");
expect(avatar).not.toBeNull();
expect(avatar?.getAttribute("src")).toBe("/avatar/user");
expect(avatar?.getAttribute("alt")).toBe("Buns");
});
it("renders a local user text avatar when provided", () => {
const container = document.createElement("div");
renderGroupedMessage(
container,
{
role: "user",
content: "hello",
timestamp: 1000,
},
"user",
{ userAvatar: "🦞" },
);
const avatar = container.querySelector<HTMLElement>(".chat-avatar.user");
expect(avatar).not.toBeNull();
expect(avatar?.tagName).toBe("DIV");
expect(avatar?.textContent).toContain("🦞");
});
it("keeps inline tool cards collapsed by default and renders expanded state", () => {
const container = document.createElement("div");
const message = {

View File

@@ -14,6 +14,11 @@ import type {
NormalizedMessage,
ToolCard,
} from "../types/chat-types.ts";
import {
resolveLocalUserAvatarText,
resolveLocalUserAvatarUrl,
resolveLocalUserName,
} from "../user-identity.ts";
import { agentLogoUrl, isRenderableControlUiAvatarUrl } from "../views/agents-utils.ts";
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
import {
@@ -191,7 +196,7 @@ export function renderReadingIndicatorGroup(
) {
return html`
<div class="chat-group assistant">
${renderAvatar("assistant", assistant, basePath, authToken)}
${renderAvatar("assistant", assistant, undefined, basePath, authToken)}
<div class="chat-group-messages">
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
<span class="chat-reading-indicator__dots">
@@ -219,7 +224,7 @@ export function renderStreamingGroup(
return html`
<div class="chat-group assistant">
${renderAvatar("assistant", assistant, basePath, authToken)}
${renderAvatar("assistant", assistant, undefined, basePath, authToken)}
<div class="chat-group-messages">
${renderGroupedMessage(
{
@@ -254,6 +259,8 @@ export function renderMessageGroup(
onRequestUpdate?: () => void;
assistantName?: string;
assistantAvatar?: string | null;
userName?: string | null;
userAvatar?: string | null;
basePath?: string;
localMediaPreviewRoots?: readonly string[];
assistantAttachmentAuthToken?: string | null;
@@ -266,10 +273,14 @@ export function renderMessageGroup(
) {
const normalizedRole = normalizeRoleForGrouping(group.role);
const assistantName = opts.assistantName ?? "Assistant";
const resolvedUserName = resolveLocalUserName({
name: opts.userName ?? null,
avatar: opts.userAvatar ?? null,
});
const userLabel = group.senderLabel?.trim();
const who =
normalizedRole === "user"
? (userLabel ?? "You")
? (userLabel ?? resolvedUserName)
: normalizedRole === "assistant"
? assistantName
: normalizedRole === "tool"
@@ -299,6 +310,10 @@ export function renderMessageGroup(
name: assistantName,
avatar: opts.assistantAvatar ?? null,
},
{
name: opts.userName ?? null,
avatar: opts.userAvatar ?? null,
},
opts.basePath,
opts.assistantAttachmentAuthToken,
)}
@@ -591,12 +606,16 @@ function renderTtsButton(group: MessageGroup) {
function renderAvatar(
role: string,
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
user?: { name?: string | null; avatar?: string | null },
basePath?: string,
authToken?: string | null,
) {
const normalized = normalizeRoleForGrouping(role);
const assistantName = assistant?.name?.trim() || "Assistant";
const assistantAvatar = assistant?.avatar?.trim() || "";
const userName = resolveLocalUserName(user);
const userAvatarUrl = resolveLocalUserAvatarUrl(user);
const userAvatarText = resolveLocalUserAvatarText(user);
const initial =
normalized === "user"
? html`
@@ -643,6 +662,16 @@ function renderAvatar(
? "tool"
: "other";
if (normalized === "user" && userAvatarUrl) {
return html`<img class="chat-avatar ${className}" src="${userAvatarUrl}" alt="${userName}" />`;
}
if (normalized === "user" && userAvatarText) {
return html`<div class="chat-avatar ${className}" aria-label="${userName}">
${userAvatarText}
</div>`;
}
if (assistantAvatar && normalized === "assistant") {
if (isAvatarUrl(assistantAvatar)) {
if (authToken?.trim() && assistantAvatar.startsWith("/")) {

View File

@@ -1,6 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createStorageMock } from "../test-helpers/storage.ts";
import { loadSettings, saveSettings } from "./storage.ts";
import {
loadLocalUserIdentity,
loadSettings,
saveLocalUserIdentity,
saveSettings,
} from "./storage.ts";
function setTestLocation(params: { protocol: string; host: string; pathname: string }) {
vi.stubGlobal("location", {
@@ -437,4 +442,49 @@ describe("loadSettings default gateway URL derivation", () => {
lastActiveSessionKey: "agent:current:main",
});
});
it("persists local user identity separately from gateway settings", async () => {
setTestLocation({
protocol: "https:",
host: "gateway.example:8443",
pathname: "/",
});
saveLocalUserIdentity({ name: "Buns", avatar: "🦞" });
expect(loadLocalUserIdentity()).toEqual({
name: "Buns",
avatar: "🦞",
});
expect(JSON.parse(localStorage.getItem("openclaw.control.user.v1") ?? "{}")).toEqual({
name: "Buns",
avatar: "🦞",
});
});
it("normalizes invalid local user identity values on load", async () => {
localStorage.setItem(
"openclaw.control.user.v1",
JSON.stringify({
name: " ",
avatar: "https://example.com/avatar.png",
}),
);
expect(loadLocalUserIdentity()).toEqual({
name: null,
avatar: null,
});
});
it("removes the persisted local user identity when cleared", async () => {
saveLocalUserIdentity({ name: "Buns", avatar: "data:image/png;base64,AAA" });
saveLocalUserIdentity({ name: null, avatar: null });
expect(loadLocalUserIdentity()).toEqual({
name: null,
avatar: null,
});
expect(localStorage.getItem("openclaw.control.user.v1")).toBeNull();
});
});

View File

@@ -1,5 +1,6 @@
const SETTINGS_KEY_PREFIX = "openclaw.control.settings.v1:";
const LEGACY_SETTINGS_KEY = "openclaw.control.settings.v1";
const LOCAL_USER_IDENTITY_KEY = "openclaw.control.user.v1";
const LEGACY_TOKEN_SESSION_KEY = "openclaw.control.token.v1";
const TOKEN_SESSION_KEY_PREFIX = "openclaw.control.token.v1:";
const MAX_SCOPED_SESSION_ENTRIES = 10;
@@ -25,6 +26,11 @@ import { getSafeLocalStorage, getSafeSessionStorage } from "../local-storage.ts"
import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts";
import { normalizeOptionalString } from "./string-coerce.ts";
import { parseThemeSelection, type ThemeMode, type ThemeName } from "./theme.ts";
import {
hasLocalUserIdentity,
normalizeLocalUserIdentity,
type LocalUserIdentity,
} from "./user-identity.ts";
export const BORDER_RADIUS_STOPS = [0, 25, 50, 75, 100] as const;
export type BorderRadiusStop = (typeof BORDER_RADIUS_STOPS)[number];
@@ -60,6 +66,8 @@ export type UiSettings = {
locale?: string;
};
export type { LocalUserIdentity } from "./user-identity.ts";
function isViteDevPage(): boolean {
if (typeof document === "undefined") {
return false;
@@ -270,6 +278,34 @@ export function saveSettings(next: UiSettings) {
persistSettings(next);
}
export function loadLocalUserIdentity(): LocalUserIdentity {
const storage = getSafeLocalStorage();
try {
const raw = storage?.getItem(LOCAL_USER_IDENTITY_KEY);
if (!raw) {
return normalizeLocalUserIdentity();
}
return normalizeLocalUserIdentity(JSON.parse(raw) as Partial<LocalUserIdentity>);
} catch {
return normalizeLocalUserIdentity();
}
}
export function saveLocalUserIdentity(next: LocalUserIdentity) {
const storage = getSafeLocalStorage();
const normalized = normalizeLocalUserIdentity(next);
try {
if (!hasLocalUserIdentity(normalized)) {
storage?.removeItem(LOCAL_USER_IDENTITY_KEY);
return;
}
storage?.setItem(LOCAL_USER_IDENTITY_KEY, JSON.stringify(normalized));
} catch {
// best-effort — quota exceeded or security restrictions should not
// prevent in-memory identity updates from being applied
}
}
function persistSettings(next: UiSettings) {
persistSessionToken(next.gatewayUrl, next.token);
const storage = getSafeLocalStorage();

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import {
normalizeLocalUserIdentity,
resolveLocalUserAvatarText,
resolveLocalUserAvatarUrl,
resolveLocalUserName,
} from "./user-identity.ts";
describe("local user identity helpers", () => {
it("normalizes the display name with the same fallback used by chat", () => {
expect(resolveLocalUserName({ name: " Val " })).toBe("Val");
expect(resolveLocalUserName({ name: " " })).toBe("You");
});
it("resolves renderable local avatar URLs through the shared chat path", () => {
expect(resolveLocalUserAvatarUrl({ avatar: "/avatar/user" })).toBe("/avatar/user");
expect(resolveLocalUserAvatarUrl({ avatar: "data:image/png;base64,AAA" })).toBe(
"data:image/png;base64,AAA",
);
expect(resolveLocalUserAvatarUrl({ avatar: "https://example.com/avatar.png" })).toBeNull();
});
it("keeps text avatars only when no image avatar survives normalization", () => {
expect(resolveLocalUserAvatarText({ avatar: "🦞" })).toBe("🦞");
expect(resolveLocalUserAvatarText({ avatar: "/avatar/user" })).toBeNull();
expect(normalizeLocalUserIdentity({ avatar: "line 1\nline 2" }).avatar).toBeNull();
});
});

View File

@@ -0,0 +1,75 @@
import { coerceIdentityValue } from "../../../src/shared/assistant-identity-values.js";
import { normalizeOptionalString } from "./string-coerce.ts";
import {
isRenderableControlUiAvatarUrl,
resolveChatAvatarRenderUrl,
} from "./views/agents-utils.ts";
const MAX_LOCAL_USER_NAME = 50;
const MAX_LOCAL_USER_TEXT_AVATAR = 16;
const MAX_LOCAL_USER_IMAGE_AVATAR = 2_000_000;
export type LocalUserIdentity = {
name: string | null;
avatar: string | null;
};
function normalizeAvatar(value?: string | null): string | null {
const trimmed = normalizeOptionalString(value);
if (!trimmed) {
return null;
}
if (isRenderableControlUiAvatarUrl(trimmed)) {
return trimmed.length <= MAX_LOCAL_USER_IMAGE_AVATAR ? trimmed : null;
}
if (/[\r\n]/.test(trimmed)) {
return null;
}
return trimmed.length <= MAX_LOCAL_USER_TEXT_AVATAR ? trimmed : null;
}
export function normalizeLocalUserIdentity(
input?: Partial<LocalUserIdentity> | null,
): LocalUserIdentity {
return {
name:
coerceIdentityValue(
typeof input?.name === "string" ? input.name : undefined,
MAX_LOCAL_USER_NAME,
) ?? null,
avatar: normalizeAvatar(input?.avatar),
};
}
export function hasLocalUserIdentity(identity: LocalUserIdentity): boolean {
return Boolean(identity.name || identity.avatar);
}
export function resolveLocalUserName(
input?: Partial<LocalUserIdentity> | null,
fallback = "You",
): string {
return normalizeLocalUserIdentity(input).name ?? fallback;
}
export function resolveLocalUserAvatarUrl(
input?: Partial<LocalUserIdentity> | null,
): string | null {
const normalized = normalizeLocalUserIdentity(input);
return resolveChatAvatarRenderUrl(normalized.avatar, {
identity: {
avatar: normalized.avatar ?? undefined,
},
});
}
export function resolveLocalUserAvatarText(
input?: Partial<LocalUserIdentity> | null,
): string | null {
const normalized = normalizeLocalUserIdentity(input);
const avatar = normalizeOptionalString(normalized.avatar);
if (!avatar) {
return null;
}
return resolveLocalUserAvatarUrl(normalized) ? null : avatar;
}

View File

@@ -40,6 +40,7 @@ import type { SidebarContent } from "../sidebar-content.ts";
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 { renderMarkdownSidebar } from "./markdown-sidebar.ts";
import "../components/resizable-divider.ts";
@@ -79,6 +80,8 @@ export type ChatProps = {
allowExternalEmbedUrls?: boolean;
assistantName: string;
assistantAvatar: string | null;
userName?: string | null;
userAvatar?: string | null;
localMediaPreviewRoots?: string[];
assistantAttachmentAuthToken?: string | null;
autoExpandToolCalls?: boolean;
@@ -547,6 +550,10 @@ function renderPinnedSection(
pinned: PinnedMessages,
requestUpdate: () => void,
): TemplateResult | typeof nothing {
const userRoleLabel = resolveLocalUserName({
name: props.userName ?? null,
avatar: props.userAvatar ?? null,
});
const messages = Array.isArray(props.messages) ? props.messages : [];
const entries: Array<{ index: number; text: string; role: string }> = [];
for (const idx of pinned.indices) {
@@ -582,7 +589,7 @@ function renderPinnedSection(
({ index, text, role }) => html`
<div class="agent-chat__pinned-item">
<span class="agent-chat__pinned-role"
>${role === "user" ? "You" : "Assistant"}</span
>${role === "user" ? userRoleLabel : "Assistant"}</span
>
<span class="agent-chat__pinned-text"
>${text.slice(0, 100)}${text.length > 100 ? "..." : ""}</span
@@ -902,6 +909,8 @@ export function renderChat(props: ChatProps) {
onRequestUpdate: requestUpdate,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
userName: props.userName ?? null,
userAvatar: props.userAvatar ?? null,
basePath: props.basePath,
localMediaPreviewRoots: props.localMediaPreviewRoots ?? [],
assistantAttachmentAuthToken: props.assistantAttachmentAuthToken ?? null,

View File

@@ -0,0 +1,93 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { renderQuickSettings, type QuickSettingsProps } from "./config-quick.ts";
function createProps(overrides: Partial<QuickSettingsProps> = {}): QuickSettingsProps {
return {
currentModel: "gpt-5.4",
thinkingLevel: "off",
fastMode: false,
onModelChange: vi.fn(),
onThinkingChange: vi.fn(),
onFastModeToggle: vi.fn(),
channels: [],
onChannelConfigure: vi.fn(),
apiKeys: [],
onApiKeyChange: vi.fn(),
automation: {
cronJobCount: 0,
skillCount: 0,
mcpServerCount: 0,
},
onManageCron: vi.fn(),
onBrowseSkills: vi.fn(),
onConfigureMcp: vi.fn(),
security: {
gatewayAuth: "Unknown",
execPolicy: "Allowlist",
deviceAuth: true,
},
onSecurityConfigure: vi.fn(),
theme: "claw",
themeMode: "system",
borderRadius: 50,
setTheme: vi.fn(),
setThemeMode: vi.fn(),
setBorderRadius: vi.fn(),
userName: "Val",
userAvatar: null,
onUserNameChange: vi.fn(),
onUserAvatarChange: vi.fn(),
configObject: {},
onApplyPreset: vi.fn(),
onAdvancedSettings: vi.fn(),
connected: true,
gatewayUrl: "ws://localhost:18789",
assistantName: "OpenClaw",
version: "2026.4.22",
...overrides,
};
}
describe("renderQuickSettings", () => {
it("uses stacked columns for the compact settings layout", () => {
const container = document.createElement("div");
render(renderQuickSettings(createProps()), container);
expect(container.querySelectorAll(".qs-stack")).toHaveLength(4);
expect(container.querySelectorAll(".qs-card--span-all")).toHaveLength(1);
});
it("rejects oversized avatar uploads before reading them", () => {
const onUserAvatarChange = vi.fn();
const fileReader = vi.fn();
vi.stubGlobal("FileReader", fileReader);
try {
const container = document.createElement("div");
render(renderQuickSettings(createProps({ onUserAvatarChange })), container);
const input = container.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
if (!input) {
return;
}
const file = new File([new Uint8Array(1_500_001)], "avatar.png", { type: "image/png" });
Object.defineProperty(input, "files", {
configurable: true,
value: [file],
});
input.dispatchEvent(new Event("change"));
expect(fileReader).not.toHaveBeenCalled();
expect(onUserAvatarChange).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
});

View File

@@ -10,6 +10,13 @@ import { icons } from "../icons.ts";
import type { BorderRadiusStop } from "../storage.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";
// ── Types ──
@@ -74,6 +81,10 @@ export type QuickSettingsProps = {
setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => 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>;
@@ -107,6 +118,65 @@ const BORDER_RADIUS_STOPS: Array<{ value: BorderRadiusStop; label: string }> = [
];
const THINKING_LEVELS = ["off", "low", "medium", "high"];
// 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;
function renderDefaultUserAvatar() {
return html`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<circle cx="12" cy="8" r="4" />
<path d="M20 21a8 8 0 1 0-16 0" />
</svg>
`;
}
function renderLocalUserAvatarPreview(
name: string | null | undefined,
avatar: string | null | undefined,
) {
const identity = normalizeLocalUserIdentity({ name, avatar });
const label = resolveLocalUserName(identity);
const avatarUrl = resolveLocalUserAvatarUrl(identity);
const avatarText = resolveLocalUserAvatarText(identity);
if (avatarUrl) {
return html`<img class="qs-user-avatar" src=${avatarUrl} alt=${label} />`;
}
if (avatarText) {
return html`<div class="qs-user-avatar qs-user-avatar--text" aria-label=${label}>
${avatarText}
</div>`;
}
return html`
<div class="qs-user-avatar qs-user-avatar--default" aria-label=${label}>
${renderDefaultUserAvatar()}
</div>
`;
}
function handleLocalUserAvatarFileSelect(e: Event, props: QuickSettingsProps) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
const onUserAvatarChange = props.onUserAvatarChange;
if (!file || !onUserAvatarChange) {
input.value = "";
return;
}
if (!file.type.startsWith("image/")) {
input.value = "";
return;
}
if (file.size > MAX_LOCAL_USER_AVATAR_FILE_BYTES) {
input.value = "";
return;
}
const reader = new FileReader();
reader.addEventListener("load", () => {
onUserAvatarChange(typeof reader.result === "string" ? reader.result : null);
});
reader.readAsDataURL(file);
input.value = "";
}
// ── Card renderers ──
@@ -381,6 +451,80 @@ function renderAppearanceCard(props: QuickSettingsProps) {
`;
}
function renderPersonalCard(props: QuickSettingsProps) {
const identity = normalizeLocalUserIdentity({
name: props.userName ?? null,
avatar: props.userAvatar ?? null,
});
const avatarText = resolveLocalUserAvatarText(identity) ?? "";
const label = resolveLocalUserName(identity);
return html`
<div class="qs-card">
${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>
<div class="qs-row">
<label class="qs-field">
<span class="qs-row__label">Avatar text / emoji</span>
<input
class="qs-field__input"
type="text"
maxlength="16"
.value=${avatarText}
placeholder="JD or 🦞"
@input=${(e: Event) => {
const value = (e.target as HTMLInputElement).value;
props.onUserAvatarChange?.(value.trim() ? value : null);
}}
/>
</label>
</div>
<div class="qs-personal-actions">
<label class="btn btn--sm">
Choose image
<input
type="file"
accept="image/*"
hidden
@change=${(e: Event) => handleLocalUserAvatarFileSelect(e, props)}
/>
</label>
<button
type="button"
class="btn btn--sm btn--ghost"
?disabled=${!hasLocalUserIdentity(identity)}
@click=${() => {
props.onUserNameChange?.("");
props.onUserAvatarChange?.(null);
}}
>
Clear
</button>
</div>
</div>
</div>
`;
}
function renderPresetsCard(props: QuickSettingsProps) {
const activePreset = props.configObject ? detectActivePreset(props.configObject) : "personal";
@@ -418,6 +562,10 @@ function renderConnectionFooter(props: QuickSettingsProps) {
`;
}
function renderStack(...cards: TemplateResult[]) {
return html`<div class="qs-stack">${cards}</div>`;
}
// ── Main render ──
export function renderQuickSettings(props: QuickSettingsProps) {
@@ -431,9 +579,10 @@ export function renderQuickSettings(props: QuickSettingsProps) {
</div>
<div class="qs-grid">
${renderModelCard(props)} ${renderChannelsCard(props)} ${renderApiKeysCard(props)}
${renderAutomationsCard(props)} ${renderSecurityCard(props)} ${renderAppearanceCard(props)}
${renderPresetsCard(props)}
${renderStack(renderModelCard(props), renderSecurityCard(props))}
${renderStack(renderChannelsCard(props), renderAutomationsCard(props))}
${renderStack(renderApiKeysCard(props), renderAppearanceCard(props))}
${renderStack(renderPersonalCard(props))} ${renderPresetsCard(props)}
</div>
${renderConnectionFooter(props)}