fix(ui): align chat header controls

This commit is contained in:
Val Alexander
2026-05-15 03:38:07 -05:00
parent a099acc557
commit a25444c5fa
8 changed files with 163 additions and 109 deletions
+1
View File
@@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/Azure OpenAI Responses: default unset Azure OpenAI API versions to `preview` so `/openai/v1/responses` calls use Azure's current Responses API route. (#82026) Thanks @leoge007.
- Control UI/WebChat: compact the desktop chat header controls into a single aligned row so the session, model, thinking, and action controls no longer waste vertical space. Thanks @BunsDev.
- Agents: retry empty final turns for generic `anthropic-messages` providers instead of limiting non-visible recovery to Kimi, so custom/proxied Anthropic-compatible routes can recover with a visible answer. Addresses #46080. Thanks @wmgx, @w1tv, and @iFwu.
- Control UI: rotate browser service-worker caches per build so updated Gateways are less likely to keep serving stale dashboard bundles that trigger protocol mismatch errors.
- Discord: report unresolved configured bot-token SecretRefs during startup instead of treating the account as unconfigured. (#82009) Thanks @giodl73-repo.
+35 -38
View File
@@ -1158,8 +1158,9 @@
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
gap: 8px;
flex-wrap: wrap;
min-height: 36px;
}
.chat-controls__session {
@@ -1179,8 +1180,9 @@
minmax(128px, 4fr);
grid-template-areas: "agent session model thinking";
align-items: center;
gap: 8px;
gap: 6px;
width: 100%;
min-height: 36px;
min-width: 0;
}
@@ -1251,53 +1253,48 @@
/* Controls separator */
.chat-controls__separator {
color: rgba(255, 255, 255, 0.4);
font-size: 18px;
margin: 0 8px;
align-self: center;
flex: 0 0 1px;
width: 1px;
height: 22px;
margin: 0 3px;
overflow: hidden;
background: color-mix(in srgb, var(--border-strong) 72%, transparent);
color: transparent;
font-size: 0;
font-weight: 300;
}
:root[data-theme-mode="light"] .chat-controls__separator {
color: rgba(16, 24, 40, 0.3);
.chat-controls .btn--icon {
width: 36px;
min-width: 36px;
height: 36px;
padding: 0;
border-radius: var(--radius-lg);
}
.chat-controls__session select {
padding: 6px 10px;
font-size: 13px;
:root[data-theme-mode="light"] .chat-controls__separator {
background: rgba(16, 24, 40, 0.18);
}
.chat-controls__session select,
.chat-controls__agent select,
.chat-controls__model select,
.chat-controls__thinking-select select {
box-sizing: border-box;
height: 36px;
min-height: 36px;
padding: 0 34px 0 12px;
width: 100%;
max-width: none;
border-color: color-mix(in srgb, var(--input) 88%, transparent);
border-radius: var(--radius-lg);
background-color: color-mix(in srgb, var(--card) 92%, var(--bg-elevated) 8%);
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-controls__agent select {
width: 100%;
max-width: none;
}
.chat-controls__model select {
width: 100%;
max-width: none;
}
.chat-controls__thinking-select select {
width: 100%;
max-width: none;
}
.chat-controls__autoscroll {
min-width: 112px;
max-width: 118px;
}
.chat-controls__autoscroll-select {
width: 100%;
height: 32px;
min-width: 0;
padding: 4px 26px 4px 8px;
font-size: 12px;
}
.chat-controls__thinking {
display: flex;
align-items: center;
+11
View File
@@ -46,4 +46,15 @@ describe("chat layout styles", () => {
expect(css).toContain("@media (display-mode: standalone) and (max-width: 768px)");
expect(css).toContain("margin-bottom: calc(14px + max(var(--safe-area-bottom), 34px));");
});
it("keeps desktop chat header controls on a compact aligned rhythm", () => {
const css = readLayoutCss();
expect(css).toContain("min-height: 36px;");
expect(css).toContain("height: 36px;");
expect(css).toContain(".chat-controls .btn--icon {");
expect(css).toContain("width: 36px;");
expect(css).toContain(".chat-controls__separator {");
expect(css).toContain("height: 22px;");
});
});
+28 -3
View File
@@ -983,6 +983,7 @@
.content--chat .content-header.content-header--chat-hidden {
opacity: 0;
transform: translateY(-10px);
min-height: 0;
max-height: 0px;
padding-top: 0;
padding-bottom: 0;
@@ -1108,19 +1109,30 @@
grid-template-columns: minmax(0, 1fr) max-content;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-bottom: 0;
gap: 10px;
min-height: 44px;
padding: 4px 8px;
overflow: visible;
max-height: 56px;
max-height: 44px;
}
.content--chat .content-header > div:first-child {
display: grid;
align-items: center;
position: relative;
text-align: left;
min-width: 0;
}
.shell--chat-focus .content--chat .content-header {
min-height: 0;
}
.content--chat .page-meta {
align-self: center;
justify-content: flex-end;
height: 36px;
align-items: center;
min-width: max-content;
overflow: visible;
}
@@ -1128,6 +1140,19 @@
.content--chat .chat-controls {
flex-shrink: 0;
flex-wrap: nowrap;
gap: 6px;
}
.content--chat .content-header .chat-controls__session-notice {
position: absolute;
top: calc(100% + 2px);
left: 0;
width: min(100%, 680px);
min-height: 0;
overflow: hidden;
pointer-events: none;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ===========================================
+13 -19
View File
@@ -4,10 +4,11 @@
@media (max-width: 1320px) {
.content--chat .content-header {
align-items: stretch;
align-items: center;
gap: 8px;
row-gap: 0;
max-height: 56px;
min-height: 44px;
max-height: 44px;
overflow: visible;
}
@@ -16,6 +17,9 @@
}
.content--chat .page-meta {
align-self: center;
height: 36px;
align-items: center;
min-width: 0;
justify-content: flex-end;
flex-wrap: nowrap;
@@ -505,28 +509,18 @@
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__thinking {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
align-items: center;
justify-content: stretch;
gap: 8px;
width: 100%;
padding: 0;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__autoscroll {
flex: 1 1 100%;
max-width: none;
min-width: 0;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__autoscroll-select {
width: 100%;
height: 40px;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .btn--icon {
width: 44px;
min-width: 44px;
width: 100%;
min-width: 0;
height: 44px;
}
+17
View File
@@ -16,12 +16,29 @@ function readGroupedChatCss(): string {
describe("chat header responsive mobile styles", () => {
it("keeps the chat header and session controls from clipping on narrow widths", () => {
const css = readMobileCss();
const layoutCss = readLayoutCss();
expect(css).toContain("@media (max-width: 1320px)");
expect(css).toContain(".content--chat .content-header");
expect(css).toContain("max-height: 44px;");
expect(layoutCss).toContain(".content--chat .content-header .chat-controls__session-notice");
expect(layoutCss).toContain("position: absolute;");
expect(css).toContain(".chat-controls__session-row");
expect(css).toContain(".chat-controls__thinking-select");
});
it("lays out mobile chat header action icons as an even full-width grid", () => {
const css = readMobileCss();
expect(css).toContain(
".chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__thinking",
);
expect(css).toContain("grid-template-columns: repeat(5, minmax(0, 1fr));");
expect(css).toContain(
".chat-mobile-controls-wrapper .chat-controls-dropdown .btn--icon {\n width: 100%;",
);
expect(css).toContain("height: 44px;");
});
});
describe("sidebar menu trigger styles", () => {
+23 -13
View File
@@ -100,11 +100,12 @@ describe("chat header controls (browser)", () => {
container.querySelectorAll<HTMLButtonElement>(".chat-controls .btn--icon[data-tooltip]"),
);
expect(buttons).toHaveLength(5);
expect(buttons).toHaveLength(6);
const labels = buttons.map((button) => button.getAttribute("data-tooltip"));
expect(labels).toEqual([
t("chat.refreshTitle"),
`${t("chat.autoScrollMode")}: ${t("chat.autoScrollNearBottom")}`,
t("chat.thinkingToggle"),
t("chat.toolCallsToggle"),
t("chat.focusToggle"),
@@ -162,7 +163,9 @@ describe("chat header controls (browser)", () => {
container.querySelectorAll<HTMLButtonElement>(".chat-controls__thinking .btn--icon"),
);
expect(buttons).toHaveLength(4);
expect(buttons).toHaveLength(5);
const autoScrollButton = requireButton(buttons.at(0), "auto-scroll mode");
expect(autoScrollButton.dataset.chatAutoScrollMode).toBe("near-bottom");
const cronButton = requireButton(buttons.at(-1), "cron sessions");
expect([...cronButton.classList]).toEqual(["btn", "btn--sm", "btn--icon", "active"]);
expect(cronButton.getAttribute("aria-pressed")).toBe("true");
@@ -173,26 +176,29 @@ describe("chat header controls (browser)", () => {
expect(state.sessionsHideCron).toBe(false);
});
it("renders and applies the chat auto-scroll mode selector", async () => {
it("renders and applies the chat auto-scroll mode toggle", async () => {
const applySettings = vi.fn();
const state = createState({ applySettings });
const container = document.createElement("div");
render(renderChatControls(state), container);
await Promise.resolve();
const select = requireElement(
container.querySelector<HTMLSelectElement>('[data-chat-auto-scroll-select="true"]'),
"auto-scroll select",
const toggle = requireButton(
container.querySelector<HTMLButtonElement>('[data-chat-auto-scroll-toggle="true"]'),
"auto-scroll toggle",
);
expect(select.getAttribute("aria-label")).toBe(t("chat.autoScrollMode"));
expect(select.value).toBe("near-bottom");
expect(toggle.getAttribute("aria-label")).toBe(
`${t("chat.autoScrollMode")}: ${t("chat.autoScrollNearBottom")}`,
);
expect(toggle.getAttribute("data-tooltip")).toBe(toggle.getAttribute("aria-label"));
expect(toggle.dataset.chatAutoScrollMode).toBe("near-bottom");
expect(toggle.getAttribute("aria-pressed")).toBe("true");
select.value = "off";
select.dispatchEvent(new Event("change"));
toggle.click();
expect(applySettings).toHaveBeenCalledWith({
...state.settings,
chatAutoScroll: "off",
chatAutoScroll: "always",
});
});
@@ -229,12 +235,16 @@ describe("chat header controls (browser)", () => {
const selectDatasets = Array.from(container.querySelectorAll("select")).map(
(select) => select.dataset,
);
expect(selectDatasets).toHaveLength(5);
expect(selectDatasets).toHaveLength(4);
expect(selectDatasets[0]?.chatAgentFilter).toBe("true");
expect(selectDatasets[1]?.chatSessionSelect).toBe("true");
expect(selectDatasets[2]?.chatModelSelect).toBe("true");
expect(selectDatasets[3]?.chatThinkingSelect).toBe("true");
expect(selectDatasets[4]?.chatAutoScrollSelect).toBe("true");
const autoScrollToggle = requireButton(
container.querySelector<HTMLButtonElement>('[data-chat-auto-scroll-toggle="true"]'),
"auto-scroll toggle",
);
expect(autoScrollToggle.dataset.chatAutoScrollMode).toBe("near-bottom");
});
it("renders the mobile dropdown from state instead of mutating DOM classes", async () => {
+35 -36
View File
@@ -25,11 +25,7 @@ import {
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "./session-key.ts";
import {
CHAT_AUTO_SCROLL_MODES,
normalizeChatAutoScrollMode,
type ChatAutoScrollMode,
} from "./storage.ts";
import { normalizeChatAutoScrollMode, type ChatAutoScrollMode } from "./storage.ts";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-coerce.ts";
import type { ThemeMode } from "./theme.ts";
import type { SessionsListResult } from "./types.ts";
@@ -276,37 +272,40 @@ function chatAutoScrollLabel(mode: ChatAutoScrollMode) {
return t("chat.autoScrollNearBottom");
}
function renderChatAutoScrollSelect(state: AppViewState) {
function nextChatAutoScrollMode(mode: ChatAutoScrollMode): ChatAutoScrollMode {
switch (mode) {
case "near-bottom":
return "always";
case "always":
return "off";
case "off":
return "near-bottom";
}
return "near-bottom";
}
function renderChatAutoScrollToggle(state: AppViewState) {
const mode = normalizeChatAutoScrollMode(state.settings.chatAutoScroll);
const label = t("chat.autoScrollMode");
const label = `${t("chat.autoScrollMode")}: ${chatAutoScrollLabel(mode)}`;
const active = mode !== "off";
return html`
<label class="field chat-controls__autoscroll" title=${label}>
<span class="agent-chat__sr-only">${label}</span>
<select
class="chat-controls__autoscroll-select"
data-chat-auto-scroll-select="true"
aria-label=${label}
title=${label}
.value=${mode}
@change=${(event: Event) => {
const nextMode = normalizeChatAutoScrollMode(
(event.currentTarget as HTMLSelectElement | null)?.value,
);
state.applySettings({
...state.settings,
chatAutoScroll: nextMode,
});
}}
>
${CHAT_AUTO_SCROLL_MODES.map(
(option) => html`
<option value=${option} ?selected=${option === mode}>
${chatAutoScrollLabel(option)}
</option>
`,
)}
</select>
</label>
<button
class="btn btn--sm btn--icon ${active ? "active" : ""}"
data-chat-auto-scroll-toggle="true"
data-chat-auto-scroll-mode=${mode}
data-tooltip=${label}
aria-label=${label}
aria-pressed=${active}
title=${label}
@click=${() => {
state.applySettings({
...state.settings,
chatAutoScroll: nextChatAutoScrollMode(mode),
});
}}
>
${icons.scrollText}
</button>
`;
}
@@ -399,7 +398,7 @@ export function renderChatControls(state: AppViewState) {
${refreshIcon}
</button>
<span class="chat-controls__separator">|</span>
${renderChatAutoScrollSelect(state)}
${renderChatAutoScrollToggle(state)}
<button
class="btn btn--sm btn--icon ${showThinking ? "active" : ""}"
?disabled=${disableThinkingToggle}
@@ -564,7 +563,7 @@ export function renderChatMobileToggle(state: AppViewState) {
<div class="chat-controls">
${renderChatSessionSelectBase(state, switchChatSession)}
<div class="chat-controls__thinking">
${renderChatAutoScrollSelect(state)}
${renderChatAutoScrollToggle(state)}
<button
class="btn btn--sm btn--icon ${showThinking ? "active" : ""}"
?disabled=${disableThinkingToggle}