mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
16
ui/src/styles/components.test.ts
Normal file
16
ui/src/styles/components.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
20
ui/src/styles/config-quick.test.ts
Normal file
20
ui/src/styles/config-quick.test.ts
Normal 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)");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
13
ui/src/styles/layout.mobile.test.ts
Normal file
13
ui/src/styles/layout.mobile.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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("/")) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
28
ui/src/ui/user-identity.test.ts
Normal file
28
ui/src/ui/user-identity.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
75
ui/src/ui/user-identity.ts
Normal file
75
ui/src/ui/user-identity.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
93
ui/src/ui/views/config-quick.test.ts
Normal file
93
ui/src/ui/views/config-quick.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user