diff --git a/CHANGELOG.md b/CHANGELOG.md index 260d95bf002..7809e6b2b17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 96b3888dff6..68e762cf62c 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -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; diff --git a/ui/src/styles/components.test.ts b/ui/src/styles/components.test.ts new file mode 100644 index 00000000000..cfa33785535 --- /dev/null +++ b/ui/src/styles/components.test.ts @@ -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"); + }); +}); diff --git a/ui/src/styles/config-quick.css b/ui/src/styles/config-quick.css index ceab9619eae..5028d92e228 100644 --- a/ui/src/styles/config-quick.css +++ b/ui/src/styles/config-quick.css @@ -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; diff --git a/ui/src/styles/config-quick.test.ts b/ui/src/styles/config-quick.test.ts new file mode 100644 index 00000000000..dfbaf1d4c54 --- /dev/null +++ b/ui/src/styles/config-quick.test.ts @@ -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)"); + }); +}); diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 21048053994..657b6b3600d 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -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, diff --git a/ui/src/styles/layout.mobile.test.ts b/ui/src/styles/layout.mobile.test.ts new file mode 100644 index 00000000000..05e25c6cd72 --- /dev/null +++ b/ui/src/styles/layout.mobile.test.ts @@ -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"); + }); +}); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index b1a3329e195..e1b89cf5c14 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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, diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 63fdf2ec9cf..e64aeda9149 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -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, +) { + 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, { diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 9be78c44072..0a2f5d5479a 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -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; loadAssistantIdentity: () => Promise; loadCron: () => Promise; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 7e910075462..ca05a641be2 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -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[0], next); } + applyLocalUserIdentity(next: { name?: string | null; avatar?: string | null }) { + applyLocalUserIdentityInternal( + this as unknown as Parameters[0], + next, + ); + } + setTab(next: Tab) { setTabInternal(this as unknown as Parameters[0], next); this.navDrawerOpen = false; diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index 7fea8f031f2..f0bb302512f 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -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(".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(".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(".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(".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 = { diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index b61dc3169c1..d61bb417458 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -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`
- ${renderAvatar("assistant", assistant, basePath, authToken)} + ${renderAvatar("assistant", assistant, undefined, basePath, authToken)}