mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(ui): align chat header controls
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user