fix(control-ui): refine responsive chat controls

This commit is contained in:
Val Alexander
2026-05-04 03:29:52 -05:00
parent e11a8a84ac
commit ac19a857a8
23 changed files with 1350 additions and 125 deletions

View File

@@ -20,6 +20,8 @@ Docs: https://docs.openclaw.ai
- Channels/streaming: cap progress-draft tool lines by default so edited progress boxes avoid jumpy reflow from long wrapped lines.
- Agents/verbose: use compact explain-mode tool summaries for `/verbose` and progress drafts by default, with `agents.defaults.toolProgressDetail: "raw"` and per-agent overrides for debugging raw command/detail output.
- Agents/commands: add `/steer <message>` for queue-independent steering of the active current-session run without starting a new turn when the session is idle. (#76934)
- Control UI/chat: add an agent filter to the chat session picker, keep chat controls/composer responsive across phone/tablet/desktop widths, keep desktop chat controls on one row, and hide that row while scrolling down the transcript. Thanks @BunsDev.
- Control UI/chat: collapse consecutive duplicate text messages into one bubble with a count so no-op heartbeat acknowledgements stay compact without hiding nearby context.
- Agents/subagents: preserve every grouped child result when direct completion fallback has to bypass the requester-agent announce turn. Thanks @vincentkoc.
- TTS/telephony: honor provider voice/model overrides in telephony synthesis providers so Google Meet agent speech logs match the backend that actually produced the audio. Thanks @vincentkoc.
- Voice Call/realtime: bound the paced Twilio audio queue and close overloaded realtime streams before provider audio can pile up behind the websocket backpressure guard. Thanks @vincentkoc.

View File

@@ -158,6 +158,9 @@ Imported themes are stored only in the current browser profile. They are not wri
- During an active send and the final history refresh, the chat view keeps local optimistic user/assistant messages visible if `chat.history` briefly returns an older snapshot; the canonical transcript replaces those local messages once the Gateway history catches up.
- Live `chat` events are delivery state, while `chat.history` is rebuilt from the durable session transcript. After tool-final events the Control UI reloads history and merges only a small optimistic tail; the transcript boundary is documented in [WebChat](/web/webchat).
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
- The chat session picker is scoped by the selected agent. Switching agents shows only sessions tied to that agent and falls back to that agent's main session when it has no saved dashboard sessions yet.
- On desktop widths, chat controls stay on one compact row and collapse while scrolling down the transcript; scrolling up, returning to the top, or reaching the bottom restores the controls.
- Consecutive duplicate text-only messages render as one bubble with a count badge. Messages that carry images, attachments, tool output, or canvas previews are left uncollapsed.
- The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options.
- Typing `/new` in the Control UI creates and switches to the same fresh dashboard session as New Chat. Typing `/reset` keeps the Gateway's explicit in-place reset for the current session.
- The chat model picker requests the Gateway's configured model view. If `agents.defaults.models` is present, that allowlist drives the picker. Otherwise the picker shows explicit `models.providers.*.models` entries plus providers with usable auth. The full catalog stays available through the debug `models.list` RPC with `view: "all"`.

View File

@@ -242,6 +242,27 @@ img.chat-avatar {
padding-right: 70px;
}
.chat-duplicate-count {
display: inline-flex;
align-items: center;
align-self: flex-start;
min-height: 22px;
margin-top: 8px;
padding: 2px 7px;
border: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--panel) 72%, transparent);
color: var(--muted);
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.chat-group.user .chat-duplicate-count {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: color-mix(in srgb, var(--foreground) 80%, var(--primary) 20%);
}
.chat-bubble-actions {
position: absolute;
top: 6px;

View File

@@ -55,7 +55,7 @@
/* Grow, shrink, and use 0 base for proper scrolling */
overflow-y: auto;
overflow-x: hidden;
padding: 0 6px 6px;
padding: 0 clamp(6px, 1vw, 12px) 6px;
margin: 0 0 0 0;
min-height: 0;
/* Allow shrinking for flex scroll behavior */
@@ -602,6 +602,7 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
}
@@ -1037,20 +1038,50 @@
}
.chat-controls__session {
min-width: 140px;
max-width: 300px;
min-width: 0;
max-width: none;
}
.chat-controls__agent {
min-width: 0;
max-width: none;
}
.chat-controls__session-row {
display: flex;
display: grid;
grid-template-columns:
minmax(132px, 7fr) minmax(116px, 5fr) minmax(132px, 5fr)
minmax(128px, 4fr);
grid-template-areas: "session agent model thinking";
align-items: center;
gap: 12px;
flex-wrap: wrap;
gap: 8px;
width: 100%;
min-width: 0;
}
.chat-controls__session-row--single-agent {
grid-template-columns: minmax(132px, 7fr) minmax(132px, 5fr) minmax(128px, 4fr);
grid-template-areas: "session model thinking";
}
.chat-controls__session-picker {
grid-area: session;
}
.chat-controls__agent {
grid-area: agent;
}
.chat-controls__model {
min-width: 170px;
max-width: 320px;
grid-area: model;
min-width: 0;
max-width: none;
}
.chat-controls__thinking-select {
grid-area: thinking;
min-width: 0;
max-width: none;
}
.chat-controls__thinking {
@@ -1075,13 +1106,25 @@
.chat-controls__session select {
padding: 6px 10px;
font-size: 13px;
max-width: 300px;
width: 100%;
max-width: none;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-controls__agent select {
width: 100%;
max-width: none;
}
.chat-controls__model select {
max-width: 320px;
width: 100%;
max-width: none;
}
.chat-controls__thinking-select select {
width: 100%;
max-width: none;
}
.chat-controls__thinking {
@@ -1105,12 +1148,24 @@
border-color: rgba(16, 24, 40, 0.15);
}
@media (max-width: 1535px) {
.chat-controls__thinking-select {
min-width: 0;
max-width: none;
}
}
@media (max-width: 768px) {
.chat-controls__session {
min-width: 120px;
max-width: none;
}
.chat-controls__agent {
min-width: 120px;
max-width: none;
}
.chat-controls__model {
min-width: 140px;
max-width: none;
@@ -1123,6 +1178,22 @@
.chat-compose__field textarea {
min-height: 64px;
}
.agent-chat__input-btn,
.agent-chat__toolbar .btn--ghost,
.chat-send-btn {
width: 44px;
min-width: 44px;
height: 44px;
}
.agent-chat__suggestions {
grid-template-columns: minmax(0, 1fr);
}
.agent-chat__suggestion {
min-height: 44px;
}
}
@media (max-width: 640px) {
@@ -1161,6 +1232,11 @@
.chat-controls__model {
min-width: 150px;
}
.chat-bubble.has-copy {
padding-top: 34px;
padding-right: 12px;
}
}
/* Chat loading skeleton */
@@ -1177,7 +1253,9 @@
justify-content: center;
text-align: center;
gap: 12px;
padding: 48px 24px;
width: min(100%, 560px);
margin: auto;
padding: clamp(28px, 6vh, 56px) 24px;
flex: 1;
min-height: 0;
}
@@ -1266,18 +1344,18 @@
}
.agent-chat__suggestions {
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
justify-content: center;
max-width: 480px;
width: min(100%, 480px);
margin-top: 8px;
}
.agent-chat__suggestion {
min-height: 40px;
font-size: 13px;
padding: 8px 16px;
border-radius: var(--radius-full);
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--panel);
color: var(--foreground);

View File

@@ -1748,6 +1748,32 @@
margin-bottom: 0;
}
@media (max-width: 768px) {
.chat-side-result {
position: fixed;
left: max(8px, var(--safe-area-left));
right: max(8px, var(--safe-area-right));
bottom: calc(108px + var(--safe-area-bottom));
z-index: 35;
width: auto;
max-height: min(42vh, 320px);
margin: 0;
overflow: auto;
background: color-mix(in srgb, var(--card) 96%, var(--info) 4%);
box-shadow: var(--shadow-lg);
}
.chat-side-result--error {
background: color-mix(in srgb, var(--card) 96%, var(--danger) 4%);
}
.chat-side-result__dismiss {
width: 44px;
min-width: 44px;
height: 44px;
}
}
/* ===========================================
Code Blocks
=========================================== */

View File

@@ -980,6 +980,16 @@
pointer-events: none;
}
.content--chat .content-header.content-header--chat-hidden {
opacity: 0;
transform: translateY(-10px);
max-height: 0px;
padding-top: 0;
padding-bottom: 0;
overflow: hidden;
pointer-events: none;
}
.page-title {
font-size: 22px;
font-weight: 650;
@@ -1094,25 +1104,30 @@
/* Chat view header adjustments */
.content--chat .content-header {
flex-direction: row;
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
align-items: center;
justify-content: space-between;
gap: 16px;
gap: 12px;
padding-bottom: 0;
overflow: visible;
max-height: 56px;
}
.content--chat .content-header > div:first-child {
text-align: left;
min-width: 0;
}
.content--chat .page-meta {
justify-content: flex-start;
justify-content: flex-end;
min-width: max-content;
overflow: visible;
}
.content--chat .chat-controls {
flex-shrink: 0;
flex-wrap: nowrap;
}
/* ===========================================

View File

@@ -5,44 +5,62 @@
@media (max-width: 1320px) {
.content--chat .content-header {
align-items: stretch;
gap: 12px;
row-gap: 10px;
max-height: 180px;
gap: 8px;
row-gap: 0;
max-height: 56px;
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;
justify-content: flex-end;
flex-wrap: nowrap;
row-gap: 0;
}
.content--chat .chat-controls {
margin-left: auto;
margin-left: 0;
justify-content: flex-end;
row-gap: 8px;
row-gap: 0;
}
.chat-controls__session-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: start;
gap: 10px 12px;
grid-template-columns:
minmax(112px, 7fr) minmax(96px, 5fr) minmax(116px, 5fr)
minmax(112px, 4fr);
grid-template-areas: "session agent model thinking";
align-items: center;
gap: 8px;
width: 100%;
}
.chat-controls__session-row--single-agent {
grid-template-columns: minmax(112px, 7fr) minmax(116px, 5fr) minmax(112px, 4fr);
grid-template-areas: "session model thinking";
}
.chat-controls__agent {
grid-area: agent;
}
.chat-controls__session-picker {
grid-area: session;
}
.chat-controls__model {
grid-area: model;
}
.chat-controls__thinking-select {
grid-column: 1 / -1;
grid-area: thinking;
}
.chat-controls__session,
.chat-controls__agent,
.chat-controls__model,
.chat-controls__thinking-select {
min-width: 0;
@@ -50,6 +68,7 @@
}
.chat-controls__session select,
.chat-controls__agent select,
.chat-controls__model select,
.chat-controls__thinking-select select {
width: 100%;
@@ -381,23 +400,31 @@
.chat-mobile-controls-wrapper .chat-controls-mobile-toggle {
display: flex;
width: 44px;
min-width: 44px;
height: 44px;
}
/* The dropdown panel — anchored below the gear in topbar */
/* The dropdown panel is viewport-clamped so narrow phones never crop controls. */
.chat-mobile-controls-wrapper .chat-controls-dropdown {
display: none;
position: absolute;
top: 100%;
right: 0;
position: fixed;
top: calc(var(--shell-topbar-height) + 8px);
right: max(8px, var(--safe-area-right));
left: auto;
z-index: 100;
width: min(420px, calc(100vw - var(--safe-area-left) - var(--safe-area-right) - 16px));
background: var(--card, #161b22);
border: 1px solid var(--border, #30363d);
border-radius: var(--radius-md);
padding: 8px;
padding: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
flex-direction: column;
gap: 4px;
min-width: 220px;
gap: 8px;
min-width: 0;
max-height: min(72vh, 520px);
overflow: auto;
overscroll-behavior: contain;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown.open {
@@ -407,34 +434,91 @@
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls {
display: flex;
flex-direction: column;
gap: 4px;
align-items: stretch;
gap: 8px;
width: 100%;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session-row {
display: grid;
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
grid-template-areas:
"session agent"
"model thinking";
gap: 8px;
width: 100%;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session-row--single-agent {
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
grid-template-areas:
"session thinking"
"model model";
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session {
min-width: unset;
max-width: unset;
width: 100%;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__agent {
grid-area: agent;
min-width: unset;
max-width: unset;
width: 100%;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session-picker {
grid-area: session;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__model {
grid-area: model;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__thinking-select {
grid-area: thinking;
min-width: 0;
max-width: none;
width: 100%;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__thinking-select-full {
display: block;
width: 100%;
max-width: none;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session select {
width: 100%;
font-size: 14px;
min-height: 44px;
padding: 10px 12px;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__agent select {
width: 100%;
font-size: 14px;
min-height: 44px;
padding: 10px 12px;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__thinking {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 6px;
padding: 4px 0;
justify-content: center;
justify-content: flex-end;
width: 100%;
padding: 0;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .btn--icon {
width: 44px;
min-width: 44px;
height: 44px;
}
.content {
padding: 4px 4px 16px;
gap: 12px;
@@ -548,23 +632,34 @@
}
.agent-chat__input {
margin: 0 8px 10px;
margin: 0 8px calc(10px + var(--safe-area-bottom));
}
.agent-chat__toolbar {
padding: 4px 8px;
align-items: stretch;
gap: 8px;
padding: 6px 8px;
}
.agent-chat__toolbar-left,
.agent-chat__toolbar-right {
flex-wrap: wrap;
gap: 6px;
}
.agent-chat__input-btn,
.agent-chat__toolbar .btn--ghost {
width: 28px;
height: 28px;
.agent-chat__toolbar .btn--ghost,
.chat-send-btn {
width: 44px;
min-width: 44px;
height: 44px;
}
.agent-chat__input-btn svg,
.agent-chat__toolbar .btn--ghost svg {
width: 14px;
height: 14px;
.agent-chat__toolbar .btn--ghost svg,
.chat-send-btn svg {
width: 16px;
height: 16px;
}
/* Log stream */

View File

@@ -48,6 +48,10 @@ function createState(overrides: Partial<AppViewState> = {}) {
applySettings: () => undefined,
chatMobileControlsOpen: false,
setChatMobileControlsOpen: () => undefined,
chatModelCatalog: [],
chatModelOverrides: {},
chatModelsLoading: false,
client: { request: vi.fn() },
...overrides,
} as unknown as AppViewState;
}
@@ -105,12 +109,26 @@ describe("chat header controls (browser)", () => {
it("renders the cron session filter in the mobile dropdown controls", async () => {
const state = createState({
sessionKey: "agent:alpha:main",
agentsList: {
defaultId: "alpha",
mainKey: "agent:alpha:main",
scope: "all",
agents: [
{ id: "alpha", name: "Alpha" },
{ id: "beta", name: "Beta" },
],
},
sessionsResult: {
ts: 0,
path: "",
count: 2,
count: 3,
defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null },
sessions: [row({ key: "main" }), row({ key: "agent:main:cron:daily-briefing" })],
sessions: [
row({ key: "agent:alpha:main" }),
row({ key: "agent:alpha:cron:daily-briefing" }),
row({ key: "agent:beta:cron:nightly-check" }),
],
},
});
const container = document.createElement("div");
@@ -134,6 +152,42 @@ describe("chat header controls (browser)", () => {
expect(state.sessionsHideCron).toBe(false);
});
it("uses the shared chat session controls in the mobile dropdown", async () => {
const state = createState({
sessionKey: "agent:alpha:main",
chatMobileControlsOpen: true,
agentsList: {
defaultId: "alpha",
mainKey: "agent:alpha:main",
scope: "all",
agents: [
{ id: "alpha", name: "Alpha" },
{ id: "beta", name: "Beta" },
],
},
sessionsResult: {
ts: 0,
path: "",
count: 2,
defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null },
sessions: [
row({ key: "agent:alpha:main" }),
row({ key: "agent:beta:dashboard:recent", label: "Beta recent" }),
],
},
});
const container = document.createElement("div");
render(renderChatMobileToggle(state), container);
await Promise.resolve();
const sessionRows = container.querySelectorAll(".chat-controls__session-row");
expect(sessionRows).toHaveLength(1);
expect(container.querySelector('select[data-chat-agent-filter="true"]')).not.toBeNull();
expect(container.querySelector('select[data-chat-session-select="true"]')).not.toBeNull();
expect(container.querySelector('select[data-chat-model-select="true"]')).not.toBeNull();
expect(container.querySelector('select[data-chat-thinking-select="true"]')).not.toBeNull();
});
it("renders the mobile dropdown from state instead of mutating DOM classes", async () => {
const setChatMobileControlsOpen = vi.fn();
const state = createState({

View File

@@ -571,7 +571,7 @@ describe("resolveSessionOptionGroups", () => {
expect(labels).not.toContain("Subagent: cron-config-check");
});
it("uses agent group labels to keep duplicate main sessions unique", () => {
it("filters the chat session options to the active agent", () => {
const labels = labelsForSessionOptions({
sessionKey: "agent:alpha:main",
agentsList: {
@@ -593,10 +593,53 @@ describe("resolveSessionOptionGroups", () => {
],
});
expect(labels.filter((label) => label === "Deep Chat (alpha) / main")).toHaveLength(1);
expect(labels).toContain("Deep Chat (alpha) / main · named-main");
expect(labels).toContain("Coding (beta) / main");
expect(labels).not.toContain("main");
expect(labels).toContain("main");
expect(labels).toContain("Deep Chat (alpha) / main");
expect(labels).not.toContain("Coding (beta) / main");
});
it("shows sessions for the selected agent after switching agent scope", () => {
const labels = labelsForSessionOptions({
sessionKey: "agent:beta:main",
agentsList: {
defaultId: "alpha",
mainKey: "agent:alpha:main",
scope: "all",
agents: [
{ id: "alpha", name: "Deep Chat" },
{ id: "beta", name: "Coding" },
],
},
sessions: [
row({ key: "agent:alpha:main" }),
row({ key: "agent:beta:main" }),
row({ key: "agent:beta:dashboard:recent", label: "Bug triage" }),
],
});
expect(labels).toEqual(["main", "Bug triage"]);
});
it("keeps bare legacy sessions scoped to the default agent only", () => {
const labels = labelsForSessionOptions({
sessionKey: "agent:beta:main",
agentsList: {
defaultId: "alpha",
mainKey: "agent:alpha:main",
scope: "all",
agents: [
{ id: "alpha", name: "Deep Chat" },
{ id: "beta", name: "Coding" },
],
},
sessions: [
row({ key: "main", label: "Legacy main" }),
row({ key: "agent:alpha:main", label: "Alpha main" }),
row({ key: "agent:beta:main", label: "Beta main" }),
],
});
expect(labels).toEqual(["Beta main"]);
});
});

View File

@@ -7,7 +7,6 @@ import {
isCronSessionKey,
parseSessionKey,
renderChatSessionSelect as renderChatSessionSelectBase,
renderChatThinkingSelect,
resolveSessionDisplayName,
resolveSessionOptionGroups,
} from "./chat/session-controls.ts";
@@ -17,7 +16,11 @@ import { ChatState, loadChatHistory } from "./controllers/chat.ts";
import { createSessionAndRefresh, loadSessions } from "./controllers/sessions.ts";
import { icons } from "./icons.ts";
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts";
import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "./session-key.ts";
import {
normalizeAgentId,
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "./session-key.ts";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-coerce.ts";
import type { ThemeMode } from "./theme.ts";
import type { SessionsListResult } from "./types.ts";
@@ -233,9 +236,7 @@ export function renderChatSessionSelect(state: AppViewState) {
export function renderChatControls(state: AppViewState) {
const hideCron = state.sessionsHideCron ?? true;
const hiddenCronCount = hideCron
? countHiddenCronSessions(state.sessionKey, state.sessionsResult)
: 0;
const hiddenCronCount = hideCron ? countHiddenCronSessions(state, state.sessionsResult) : 0;
const disableThinkingToggle = state.onboarding;
const disableFocusToggle = state.onboarding;
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
@@ -418,7 +419,6 @@ export function renderChatControls(state: AppViewState) {
* Hidden on desktop via CSS.
*/
export function renderChatMobileToggle(state: AppViewState) {
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
const controlsDropdownId = "chat-mobile-controls-dropdown";
const mobileControlsOpen = state.chatMobileControlsOpen;
const disableThinkingToggle = state.onboarding;
@@ -427,9 +427,7 @@ export function renderChatMobileToggle(state: AppViewState) {
const showToolCalls = state.onboarding ? true : state.settings.chatShowToolCalls;
const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
const hideCron = state.sessionsHideCron ?? true;
const hiddenCronCount = hideCron
? countHiddenCronSessions(state.sessionKey, state.sessionsResult)
: 0;
const hiddenCronCount = hideCron ? countHiddenCronSessions(state, state.sessionsResult) : 0;
const toolCallsIcon = html`
<svg
width="18"
@@ -504,34 +502,7 @@ export function renderChatMobileToggle(state: AppViewState) {
}}
>
<div class="chat-controls">
<label class="field chat-controls__session">
<select
.value=${state.sessionKey}
@change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value;
switchChatSession(state, next);
}}
>
${sessionGroups.map(
(group) => html`
<optgroup label=${group.label}>
${group.options.map(
(opt) => html`
<option
value=${opt.key}
title=${opt.title}
?selected=${opt.key === state.sessionKey}
>
${opt.label}
</option>
`,
)}
</optgroup>
`,
)}
</select>
</label>
${renderChatThinkingSelect(state)}
${renderChatSessionSelectBase(state, switchChatSession)}
<div class="chat-controls__thinking">
<button
class="btn btn--sm btn--icon ${showThinking ? "active" : ""}"
@@ -696,13 +667,26 @@ async function refreshSessionOptions(state: AppViewState) {
});
}
/** Count sessions with a cron: key that would be hidden when hideCron=true. */
function countHiddenCronSessions(sessionKey: string, sessions: SessionsListResult | null): number {
/** Count cron sessions hidden by the active agent-scoped chat filter. */
function countHiddenCronSessions(state: AppViewState, sessions: SessionsListResult | null): number {
if (!sessions?.sessions) {
return 0;
}
// Don't count the currently active session even if it's a cron.
return sessions.sessions.filter((s) => isCronSessionKey(s.key) && s.key !== sessionKey).length;
const activeAgentId = normalizeAgentId(
parseAgentSessionKey(state.sessionKey)?.agentId ?? state.agentsList?.defaultId ?? "main",
);
const defaultAgentId = normalizeAgentId(state.agentsList?.defaultId ?? "main");
const isTiedToActiveAgent = (key: string) => {
const parsed = parseAgentSessionKey(key);
if (parsed) {
return normalizeAgentId(parsed.agentId) === activeAgentId;
}
return activeAgentId === defaultAgentId;
};
return sessions.sessions.filter(
(s) => isCronSessionKey(s.key) && s.key !== state.sessionKey && isTiedToActiveAgent(s.key),
).length;
}
type ThemeModeOption = { id: ThemeMode; labelKey: string; short: string };

View File

@@ -1539,7 +1539,13 @@ export function renderApp(state: AppViewState) {
: nothing}
${state.tab === "config"
? nothing
: html`<section class="content-header">
: html`<section
class=${isChat && state.chatHeaderControlsHidden
? "content-header content-header--chat-hidden"
: "content-header"}
?inert=${isChat && state.chatHeaderControlsHidden}
aria-hidden=${isChat && state.chatHeaderControlsHidden ? "true" : nothing}
>
<div>
${isChat
? renderChatSessionSelect(state)

View File

@@ -39,8 +39,10 @@ function createScrollHost(
style: { setProperty: vi.fn() } as unknown as CSSStyleDeclaration,
chatScrollFrame: null as number | null,
chatScrollTimeout: null as number | null,
chatLastScrollTop: 0,
chatHasAutoScrolled: false,
chatUserNearBottom: true,
chatHeaderControlsHidden: false,
chatNewMessagesBelow: false,
logsScrollFrame: null as number | null,
logsAtBottom: true,
@@ -101,6 +103,38 @@ describe("handleChatScroll", () => {
handleChatScroll(host, event);
expect(host.chatUserNearBottom).toBe(false);
});
it("hides chat header controls when scrolling down through transcript history", () => {
const { host } = createScrollHost({});
host.chatLastScrollTop = 100;
const event = createScrollEvent(3000, 260, 500);
handleChatScroll(host, event);
expect(host.chatHeaderControlsHidden).toBe(true);
});
it("shows chat header controls again when scrolling up", () => {
const { host } = createScrollHost({});
host.chatLastScrollTop = 800;
host.chatHeaderControlsHidden = true;
const event = createScrollEvent(3000, 700, 500);
handleChatScroll(host, event);
expect(host.chatHeaderControlsHidden).toBe(false);
});
it("keeps chat header controls visible near the bottom", () => {
const { host } = createScrollHost({});
host.chatLastScrollTop = 1900;
host.chatHeaderControlsHidden = true;
const event = createScrollEvent(3000, 2500, 500);
handleChatScroll(host, event);
expect(host.chatHeaderControlsHidden).toBe(false);
});
});
/* ------------------------------------------------------------------ */
@@ -266,10 +300,14 @@ describe("resetChatScroll", () => {
const { host } = createScrollHost({});
host.chatHasAutoScrolled = true;
host.chatUserNearBottom = false;
host.chatLastScrollTop = 300;
host.chatHeaderControlsHidden = true;
resetChatScroll(host);
expect(host.chatHasAutoScrolled).toBe(false);
expect(host.chatUserNearBottom).toBe(true);
expect(host.chatLastScrollTop).toBe(0);
expect(host.chatHeaderControlsHidden).toBe(false);
});
});

View File

@@ -1,5 +1,7 @@
/** Distance (px) from the bottom within which we consider the user "near bottom". */
const NEAR_BOTTOM_THRESHOLD = 450;
const HEADER_HIDE_SCROLL_DELTA = 12;
const HEADER_SHOW_TOP_THRESHOLD = 24;
type ScrollHost = {
updateComplete: Promise<unknown>;
@@ -7,8 +9,10 @@ type ScrollHost = {
style: CSSStyleDeclaration;
chatScrollFrame: number | null;
chatScrollTimeout: number | null;
chatLastScrollTop: number;
chatHasAutoScrolled: boolean;
chatUserNearBottom: boolean;
chatHeaderControlsHidden: boolean;
chatNewMessagesBelow: boolean;
logsScrollFrame: number | null;
logsAtBottom: boolean;
@@ -128,8 +132,21 @@ export function handleChatScroll(host: ScrollHost, event: Event) {
if (!container) {
return;
}
const scrollTop = Math.max(0, container.scrollTop);
const delta = scrollTop - host.chatLastScrollTop;
host.chatLastScrollTop = scrollTop;
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
host.chatUserNearBottom = distanceFromBottom < NEAR_BOTTOM_THRESHOLD;
const hasUsefulScroll = container.scrollHeight - container.clientHeight > NEAR_BOTTOM_THRESHOLD;
if (!hasUsefulScroll || scrollTop <= HEADER_SHOW_TOP_THRESHOLD || host.chatUserNearBottom) {
host.chatHeaderControlsHidden = false;
} else if (delta > HEADER_HIDE_SCROLL_DELTA) {
host.chatHeaderControlsHidden = true;
} else if (delta < -HEADER_HIDE_SCROLL_DELTA) {
host.chatHeaderControlsHidden = false;
}
// Clear the "new messages below" indicator when user scrolls back to bottom.
if (host.chatUserNearBottom) {
host.chatNewMessagesBelow = false;
@@ -148,6 +165,8 @@ export function handleLogsScroll(host: ScrollHost, event: Event) {
export function resetChatScroll(host: ScrollHost) {
host.chatHasAutoScrolled = false;
host.chatUserNearBottom = true;
host.chatLastScrollTop = 0;
host.chatHeaderControlsHidden = false;
host.chatNewMessagesBelow = false;
}

View File

@@ -120,6 +120,7 @@ export type AppViewState = {
realtimeTalkDetail: string | null;
realtimeTalkTranscript: string | null;
chatManualRefreshInFlight: boolean;
chatHeaderControlsHidden: boolean;
chatMobileControlsOpen: boolean;
nodesLoading: boolean;
nodes: Array<Record<string, unknown>>;

View File

@@ -226,6 +226,7 @@ export class OpenClawApp extends LitElement {
@state() realtimeTalkTranscript: string | null = null;
private realtimeTalkSession: RealtimeTalkSession | null = null;
@state() chatManualRefreshInFlight = false;
@state() chatHeaderControlsHidden = false;
@state() chatMobileControlsOpen = false;
private chatMobileControlsTrigger: HTMLElement | null = null;
@state() navDrawerOpen = false;
@@ -563,6 +564,7 @@ export class OpenClawApp extends LitElement {
client: GatewayBrowserClient | null = null;
private chatScrollFrame: number | null = null;
private chatScrollTimeout: number | null = null;
private chatLastScrollTop = 0;
private chatHasAutoScrolled = false;
private chatUserNearBottom = true;
@state() chatNewMessagesBelow = false;

View File

@@ -47,6 +47,56 @@ describe("buildChatItems", () => {
expect(groups.map((group) => group.senderLabel)).toEqual(["Iris", "Joaquin De Rojas"]);
});
it("collapses consecutive duplicate text messages into one rendered item with a count", () => {
const groups = messageGroups({
messages: [
{ role: "assistant", content: [{ type: "text", text: "HEARTBEAT_OK" }], timestamp: 1 },
{ role: "assistant", content: [{ type: "text", text: "HEARTBEAT_OK" }], timestamp: 2 },
{ role: "assistant", content: [{ type: "text", text: "HEARTBEAT_OK" }], timestamp: 3 },
],
});
expect(groups).toHaveLength(1);
expect(groups[0].messages).toHaveLength(1);
expect(groups[0].messages[0]).toMatchObject({ duplicateCount: 3 });
});
it("does not collapse duplicate text messages separated by another message", () => {
const groups = messageGroups({
messages: [
{ role: "assistant", content: [{ type: "text", text: "same" }], timestamp: 1 },
{ role: "user", content: [{ type: "text", text: "break" }], timestamp: 2 },
{ role: "assistant", content: [{ type: "text", text: "same" }], timestamp: 3 },
],
});
expect(groups).toHaveLength(3);
expect(groups[0].messages[0].duplicateCount).toBeUndefined();
expect(groups[2].messages[0].duplicateCount).toBeUndefined();
});
it("does not collapse messages that carry canvas previews", () => {
const canvasBlock = createAssistantCanvasBlock({ suffix: "duplicate_guard" });
const groups = messageGroups({
messages: [
{
role: "assistant",
content: [{ type: "text", text: "preview" }, canvasBlock],
timestamp: 1,
},
{
role: "assistant",
content: [{ type: "text", text: "preview" }, canvasBlock],
timestamp: 2,
},
],
});
expect(groups).toHaveLength(1);
expect(groups[0].messages).toHaveLength(2);
expect(groups[0].messages[0].duplicateCount).toBeUndefined();
});
it("attaches lifted canvas previews to the nearest assistant turn", () => {
const groups = messageGroups({
messages: [
@@ -222,3 +272,19 @@ function isCanvasBlock(block: unknown): boolean {
(block as { preview?: { kind?: unknown } }).preview?.kind === "canvas"
);
}
function createAssistantCanvasBlock(params: { suffix: string }) {
const viewId = `cv_inline_${params.suffix}`;
return {
type: "canvas",
preview: {
kind: "canvas",
surface: "assistant_message",
render: "url",
viewId,
title: "Inline demo",
url: `/__openclaw__/canvas/documents/${viewId}/index.html`,
preferredHeight: 360,
},
};
}

View File

@@ -180,12 +180,16 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
key: `group:${role}:${item.key}`,
role,
senderLabel,
messages: [{ message: item.message, key: item.key }],
messages: [{ message: item.message, key: item.key, duplicateCount: item.duplicateCount }],
timestamp,
isStreaming: false,
};
} else {
currentGroup.messages.push({ message: item.message, key: item.key });
currentGroup.messages.push({
message: item.message,
key: item.key,
duplicateCount: item.duplicateCount,
});
}
}
@@ -195,6 +199,53 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
return result;
}
function collapseDuplicateDisplaySignature(message: unknown): string | null {
const normalized = normalizeMessage(message);
const role = normalizeRoleForGrouping(normalized.role).toLowerCase();
if (!role || role === "tool") {
return null;
}
if (normalized.content.length === 0) {
return null;
}
const textParts: string[] = [];
for (const block of normalized.content) {
if (block.type !== "text" || typeof block.text !== "string") {
return null;
}
textParts.push(block.text);
}
const text = textParts.join("\n").trim().replace(/\s+/g, " ");
if (!text) {
return null;
}
const senderLabel = role === "user" ? (normalized.senderLabel ?? "").trim() : "";
return `${role}:${senderLabel}:${text}`;
}
function collapseSequentialDuplicateMessages(items: ChatItem[]): ChatItem[] {
const collapsed: ChatItem[] = [];
let previousSignature: string | null = null;
for (const item of items) {
if (item.kind !== "message") {
collapsed.push(item);
previousSignature = null;
continue;
}
const signature = collapseDuplicateDisplaySignature(item.message);
const previous = collapsed[collapsed.length - 1];
if (signature && previousSignature === signature && previous?.kind === "message") {
previous.duplicateCount = (previous.duplicateCount ?? 1) + 1;
continue;
}
collapsed.push(item);
previousSignature = signature;
}
return collapsed;
}
export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGroup> {
const items: ChatItem[] = [];
const history = Array.isArray(props.messages) ? props.messages : [];
@@ -309,7 +360,7 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
}
}
return groupMessages(items);
return groupMessages(collapseSequentialDuplicateMessages(items));
}
function messageKey(message: unknown, index: number): string {

View File

@@ -0,0 +1,423 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { chromium, type Browser, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
const VIEWPORTS = [
[320, 568],
[375, 812],
[430, 932],
[768, 1024],
[1024, 768],
[1366, 900],
[1440, 900],
] as const;
const TOUCH_TARGET_MIN_PX = 43.5;
let browser: Browser;
function readUiCss(): string {
const roots = [process.cwd(), resolve(process.cwd(), "ui")];
const files = [
"src/styles/base.css",
"src/styles/layout.css",
"src/styles/layout.mobile.css",
"src/styles/components.css",
"src/styles/chat/layout.css",
"src/styles/chat/text.css",
"src/styles/chat/grouped.css",
"src/styles/chat/tool-cards.css",
"src/styles/chat/sidebar.css",
];
return files
.map((file) => {
const path = roots
.map((root) => resolve(root, file))
.find((candidate) => existsSync(candidate));
expect(path, `Missing CSS fixture ${file}`).toBeTruthy();
return readFileSync(path!, "utf8");
})
.join("\n");
}
function iconSvg() {
return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"></path></svg>`;
}
function chatControlsHtml(opts: { agent?: boolean } = {}) {
const showAgent = opts.agent !== false;
return `
<div class="chat-mobile-controls-wrapper">
<button class="btn btn--sm btn--icon chat-controls-mobile-toggle" aria-expanded="true" aria-controls="chat-mobile-controls-dropdown">${iconSvg()}</button>
<div id="chat-mobile-controls-dropdown" class="chat-controls-dropdown open">
<div class="chat-controls">
<div class="chat-controls__session-row${showAgent ? "" : " chat-controls__session-row--single-agent"}">
${
showAgent
? `<label class="field chat-controls__session chat-controls__agent">
<select data-chat-agent-filter="true" aria-label="Filter sessions by agent"><option>Alpha</option><option>Beta</option></select>
</label>`
: ""
}
<label class="field chat-controls__session chat-controls__session-picker">
<select data-chat-session-select="true" aria-label="Chat session"><option>Daily planning</option></select>
</label>
<label class="field chat-controls__session chat-controls__model">
<select data-chat-model-select="true" aria-label="Chat model"><option>Default (gpt-5)</option></select>
</label>
<label class="field chat-controls__session chat-controls__thinking-select">
<select class="chat-controls__thinking-select-full" data-chat-thinking-select="true" aria-label="Chat thinking level"><option>Default (high)</option></select>
</label>
</div>
<div class="chat-controls__thinking">
<button class="btn btn--sm btn--icon active">${iconSvg()}</button>
<button class="btn btn--sm btn--icon active">${iconSvg()}</button>
<button class="btn btn--sm btn--icon">${iconSvg()}</button>
<button class="btn btn--sm btn--icon active">${iconSvg()}</button>
</div>
</div>
</div>
</div>
`;
}
function chatHeaderControlsHtml(hidden = false) {
return `
<main class="content content--chat" data-chat-header-responsive-fixture>
<section class="content-header${hidden ? " content-header--chat-hidden" : ""}"${hidden ? ' inert aria-hidden="true"' : ""}>
<div>
<div class="chat-controls__session-row">
<label class="field chat-controls__session chat-controls__session-picker">
<select data-chat-session-select="true" aria-label="Chat session"><option>main</option></select>
</label>
<label class="field chat-controls__session chat-controls__agent">
<select data-chat-agent-filter="true" aria-label="Filter sessions by agent"><option>Valentina</option></select>
</label>
<label class="field chat-controls__session chat-controls__model">
<select data-chat-model-select="true" aria-label="Chat model"><option>gpt-5.5</option></select>
</label>
<label class="field chat-controls__session chat-controls__thinking-select">
<select class="chat-controls__thinking-select-full" data-chat-thinking-select="true" aria-label="Chat thinking level"><option>Default (high)</option></select>
</label>
</div>
</div>
<div class="page-meta">
<div class="chat-controls">
<button class="btn btn--sm btn--icon" aria-label="Refresh chat data">${iconSvg()}</button>
<span class="chat-controls__separator">|</span>
<button class="btn btn--sm btn--icon active" aria-label="Toggle assistant thinking">${iconSvg()}</button>
<button class="btn btn--sm btn--icon active" aria-label="Toggle tool calls">${iconSvg()}</button>
<button class="btn btn--sm btn--icon" aria-label="Toggle focus mode">${iconSvg()}</button>
<button class="btn btn--sm btn--icon active" aria-label="Show cron sessions">${iconSvg()}</button>
</div>
</div>
</section>
<section class="card chat"></section>
</main>
`;
}
function chatHtml(opts: { sideResult?: boolean; singleAgent?: boolean } = {}) {
return `
<div class="shell shell--chat" data-chat-responsive-fixture>
<header class="topbar">
<div class="topnav-shell">
<div class="topnav-shell__actions">
<button class="topbar-search"><span class="topbar-search__label">Search</span><kbd class="topbar-search__kbd">K</kbd></button>
<div class="topbar-status">${chatControlsHtml({ agent: !opts.singleAgent })}</div>
</div>
</div>
</header>
<main class="content content--chat">
<section class="card chat">
<div class="chat-split-container">
<div class="chat-main">
<div class="chat-thread" role="log">
<div class="chat-thread-inner">
<div class="chat-group user">
<div class="chat-avatar user">V</div>
<div class="chat-group-messages">
<div class="chat-bubble"><div class="chat-text">Please keep every control visible at the smallest viewport.</div></div>
</div>
</div>
<div class="chat-group assistant">
<div class="chat-avatar assistant">A</div>
<div class="chat-group-messages">
<div class="chat-bubble has-copy">
<div class="chat-text">
<p>The chat shell should stay compact and readable.</p>
<pre><code>const importantLongIdentifier = "control-ui-chat-responsive-regression-fixture-keeps-code-scrollable"; console.log(importantLongIdentifier);</code></pre>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
${
opts.sideResult
? `<section class="chat-side-result" role="status" aria-live="polite">
<div class="chat-side-result__header">
<div class="chat-side-result__label-row"><span class="chat-side-result__label">BTW</span><span class="chat-side-result__meta">Not saved to chat history</span></div>
<button class="btn chat-side-result__dismiss">${iconSvg()}</button>
</div>
<div class="chat-side-result__question">What should I check next?</div>
<div class="chat-side-result__body"><p>Inspect the responsive controls and keep the transcript usable.</p></div>
</section>`
: ""
}
<div class="agent-chat__input">
<div class="agent-chat__composer-combobox">
<textarea rows="1">Queued follow-up for the active operator session</textarea>
</div>
<div class="agent-chat__toolbar">
<div class="agent-chat__toolbar-left">
<button class="agent-chat__input-btn">${iconSvg()}</button>
<button class="agent-chat__input-btn">${iconSvg()}</button>
<span class="agent-chat__token-count">8</span>
</div>
<div class="agent-chat__toolbar-right">
<button class="btn btn--ghost">${iconSvg()}</button>
<button class="btn btn--ghost">${iconSvg()}</button>
<button class="chat-send-btn">${iconSvg()}</button>
</div>
</div>
</div>
</section>
</main>
</div>
`;
}
async function openFixture(
width: number,
height: number,
opts: { sideResult?: boolean; singleAgent?: boolean } = {},
) {
const page = await browser.newPage({ viewport: { width, height } });
await page.setContent(
`<!doctype html><html><head><style>${readUiCss()}</style></head><body>${chatHtml(opts)}</body></html>`,
);
return page;
}
async function openHeaderFixture(width: number, height: number, opts: { hidden?: boolean } = {}) {
const page = await browser.newPage({ viewport: { width, height } });
await page.setContent(
`<!doctype html><html><head><style>${readUiCss()}</style></head><body>${chatHeaderControlsHtml(Boolean(opts.hidden))}</body></html>`,
);
return page;
}
async function expectNoHorizontalOverflow(page: Page) {
const metrics = await page.evaluate(() => ({
body: document.body.scrollWidth,
html: document.documentElement.scrollWidth,
viewport: window.innerWidth,
}));
expect(metrics.html).toBeLessThanOrEqual(metrics.viewport + 1);
expect(metrics.body).toBeLessThanOrEqual(metrics.viewport + 1);
}
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
});
afterAll(async () => {
await browser.close();
});
describe("chat responsive browser layout", () => {
it.each([
[1120, 740],
[1366, 900],
[1440, 900],
] as const)("keeps desktop chat controls in one row at %sx%s", async (width, height) => {
const page = await openHeaderFixture(width, height);
try {
await expectNoHorizontalOverflow(page);
const controls = await page.evaluate(() => {
const rectFor = (selector: string) => {
const node = document.querySelector(selector);
const rect = node?.getBoundingClientRect();
return rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null;
};
return {
session: rectFor('[data-chat-session-select="true"]'),
agent: rectFor('[data-chat-agent-filter="true"]'),
model: rectFor('[data-chat-model-select="true"]'),
thinking: rectFor('[data-chat-thinking-select="true"]'),
action: rectFor(".page-meta .btn--icon"),
};
});
const rowY = [
controls.session?.y,
controls.agent?.y,
controls.model?.y,
controls.thinking?.y,
controls.action?.y,
].filter((value): value is number => typeof value === "number");
expect(rowY.length).toBe(5);
expect(Math.max(...rowY) - Math.min(...rowY)).toBeLessThanOrEqual(4);
expect(controls.session!.x).toBeLessThan(controls.agent!.x);
expect(controls.session!.width / controls.agent!.width).toBeGreaterThan(1.25);
expect(controls.session!.width / controls.agent!.width).toBeLessThan(1.55);
} finally {
await page.close();
}
});
it("collapses the desktop chat controls row when scroll state hides it", async () => {
const page = await openHeaderFixture(1366, 900, { hidden: true });
try {
const hiddenState = await page.evaluate(() => {
const header = document.querySelector(".content-header") as HTMLElement | null;
const rect = header?.getBoundingClientRect();
const style = header ? getComputedStyle(header) : null;
return {
height: rect?.height ?? -1,
opacity: style?.opacity ?? "",
pointerEvents: style?.pointerEvents ?? "",
};
});
expect(hiddenState.height).toBeLessThanOrEqual(1);
expect(hiddenState.opacity).toBe("0");
expect(hiddenState.pointerEvents).toBe("none");
} finally {
await page.close();
}
});
it.each(VIEWPORTS)("keeps the chat shell inside the viewport at %sx%s", async (width, height) => {
const page = await openFixture(width, height);
try {
await expectNoHorizontalOverflow(page);
const code = await page.locator(".chat-text pre").boundingBox();
expect(code).not.toBeNull();
expect(code!.x + code!.width).toBeLessThanOrEqual(width + 1);
} finally {
await page.close();
}
});
it.each(["dark", "light"] as const)(
"keeps mobile controls inside the viewport with touch targets in %s mode",
async (themeMode) => {
const page = await openFixture(320, 568);
try {
await page.evaluate(
(mode) => document.documentElement.setAttribute("data-theme-mode", mode),
themeMode,
);
const dropdown = await page.locator(".chat-controls-dropdown.open").boundingBox();
expect(dropdown).not.toBeNull();
expect(dropdown!.x).toBeGreaterThanOrEqual(8);
expect(dropdown!.x + dropdown!.width).toBeLessThanOrEqual(312);
await expectNoHorizontalOverflow(page);
const mobileControls = await page.evaluate(() => {
const rectFor = (selector: string) => {
const node = document.querySelector(selector) as HTMLSelectElement | null;
if (!node) {
return null;
}
const rect = node.getBoundingClientRect();
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
text: node.options[node.selectedIndex]?.textContent?.trim() ?? "",
display: getComputedStyle(node).display,
};
};
return {
agent: rectFor('[data-chat-agent-filter="true"]'),
session: rectFor('[data-chat-session-select="true"]'),
thinkingFull: rectFor('[data-chat-thinking-select="true"]'),
compactCount: document.querySelectorAll('[data-chat-thinking-select-compact="true"]')
.length,
};
});
expect(mobileControls.agent).not.toBeNull();
expect(mobileControls.session).not.toBeNull();
expect(mobileControls.session!.y).toBe(mobileControls.agent!.y);
expect(mobileControls.session!.x).toBeLessThan(mobileControls.agent!.x);
expect(mobileControls.session!.width / mobileControls.agent!.width).toBeGreaterThan(1.25);
expect(mobileControls.session!.width / mobileControls.agent!.width).toBeLessThan(1.55);
expect(mobileControls.thinkingFull?.display).not.toBe("none");
expect(mobileControls.thinkingFull?.text).toBe("Default (high)");
expect(mobileControls.compactCount).toBe(0);
const sizes = await page
.locator(".chat-controls-mobile-toggle, .chat-controls-dropdown .btn--icon")
.evaluateAll((nodes) =>
nodes.map((node) => {
const rect = (node as HTMLElement).getBoundingClientRect();
return { width: rect.width, height: rect.height };
}),
);
expect(sizes.length).toBeGreaterThan(0);
for (const size of sizes) {
expect(size.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
expect(size.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
}
} finally {
await page.close();
}
},
);
it("keeps composer actions touch-sized on phones", async () => {
const page = await openFixture(320, 568);
try {
const sizes = await page
.locator(".agent-chat__input-btn, .agent-chat__toolbar .btn--ghost, .chat-send-btn")
.evaluateAll((nodes) =>
nodes.map((node) => {
const rect = (node as HTMLElement).getBoundingClientRect();
return { width: rect.width, height: rect.height };
}),
);
expect(sizes.length).toBeGreaterThan(0);
for (const size of sizes) {
expect(size.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
expect(size.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
}
} finally {
await page.close();
}
});
it("uses the compact mobile grid when the agent filter is not rendered", async () => {
const page = await openFixture(320, 568, { singleAgent: true });
try {
await expectNoHorizontalOverflow(page);
expect(await page.locator('[data-chat-agent-filter="true"]').count()).toBe(0);
const session = await page.locator('[data-chat-session-select="true"]').boundingBox();
const model = await page.locator('[data-chat-model-select="true"]').boundingBox();
const thinking = await page.locator('[data-chat-thinking-select="true"]').boundingBox();
expect(session).not.toBeNull();
expect(model).not.toBeNull();
expect(thinking).not.toBeNull();
expect(thinking!.x).toBeGreaterThan(session!.x);
expect(model!.y).toBeGreaterThan(session!.y);
expect(model!.width).toBeGreaterThan(session!.width);
} finally {
await page.close();
}
});
it("renders BTW side results as a mobile overlay without horizontal overflow", async () => {
const page = await openFixture(320, 568, { sideResult: true });
try {
await expectNoHorizontalOverflow(page);
const position = await page
.locator(".chat-side-result")
.evaluate((node) => getComputedStyle(node).position);
expect(position).toBe("fixed");
} finally {
await page.close();
}
});
});

View File

@@ -133,6 +133,31 @@ function renderAssistantMessages(
);
}
function renderAssistantMessageEntries(
container: HTMLElement,
entries: MessageGroup["messages"],
opts: Partial<RenderMessageGroupOptions> = {},
) {
const group: MessageGroup = {
kind: "group",
key: "assistant-group",
role: "assistant",
messages: entries,
timestamp: Date.now(),
isStreaming: false,
};
render(
renderMessageGroup(group, {
showReasoning: true,
showToolCalls: true,
assistantName: "OpenClaw",
assistantAvatar: null,
...opts,
}),
container,
);
}
function renderGroupedMessage(
container: HTMLElement,
message: unknown,
@@ -366,6 +391,25 @@ afterEach(() => {
});
describe("grouped chat rendering", () => {
it("renders a compact count for collapsed duplicate messages", () => {
const container = document.createElement("div");
renderAssistantMessageEntries(container, [
{
key: "assistant-heartbeat",
message: {
role: "assistant",
content: [{ type: "text", text: "HEARTBEAT_OK" }],
timestamp: 1,
},
duplicateCount: 4,
},
]);
const badge = container.querySelector(".chat-duplicate-count");
expect(badge?.textContent?.trim()).toBe("×4");
expect(badge?.getAttribute("aria-label")).toBe("4 consecutive identical messages collapsed");
});
it("does not render the stale assistant read-aloud footer action", () => {
const container = document.createElement("div");
renderAssistantMessage(container, {

View File

@@ -447,6 +447,7 @@ export function renderMessageGroup(
item.key,
{
isStreaming: group.isStreaming && index === group.messages.length - 1,
duplicateCount: item.duplicateCount ?? 1,
showReasoning: opts.showReasoning,
showToolCalls: opts.showToolCalls ?? true,
autoExpandToolCalls: opts.autoExpandToolCalls ?? false,
@@ -1371,6 +1372,7 @@ function renderGroupedMessage(
messageKey: string,
opts: {
isStreaming: boolean;
duplicateCount?: number;
showReasoning: boolean;
showToolCalls?: boolean;
autoExpandToolCalls?: boolean;
@@ -1478,6 +1480,7 @@ function renderGroupedMessage(
: "Tool output";
const hasActions = canCopyMarkdown || canExpand;
const duplicateCount = Math.max(1, Math.floor(opts.duplicateCount ?? 1));
return html`
<div class="${bubbleClasses}">
@@ -1617,6 +1620,15 @@ function renderGroupedMessage(
})
: nothing}
`}
${duplicateCount > 1
? html`<div
class="chat-duplicate-count"
aria-label=${`${duplicateCount} consecutive identical messages collapsed`}
title=${`${duplicateCount} consecutive identical messages collapsed`}
>
×${duplicateCount}
</div>`
: nothing}
</div>
`;
}

View File

@@ -9,7 +9,11 @@ import {
import { refreshVisibleToolsEffectiveForCurrentSession } from "../controllers/agents.ts";
import { loadSessions } from "../controllers/sessions.ts";
import { pushUniqueTrimmedSelectOption } from "../select-options.ts";
import { parseAgentSessionKey } from "../session-key.ts";
import {
buildAgentMainSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../session-key.ts";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts";
import {
listThinkingLevelLabels,
@@ -25,15 +29,25 @@ export function renderChatSessionSelect(
onSwitchSession: ChatSessionSwitchHandler = () => undefined,
) {
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
const agentOptions = resolveChatAgentFilterOptions(state);
const hasAgentSelect = agentOptions.length > 1;
const agentSelect = renderChatAgentSelect(state, onSwitchSession, agentOptions);
const modelSelect = renderChatModelSelect(state);
const thinkingSelect = renderChatThinkingSelect(state);
const selectedSessionLabel =
sessionGroups.flatMap((group) => group.options).find((entry) => entry.key === state.sessionKey)
?.label ?? state.sessionKey;
return html`
<div class="chat-controls__session-row">
<label class="field chat-controls__session">
<div
class=${hasAgentSelect
? "chat-controls__session-row"
: "chat-controls__session-row chat-controls__session-row--single-agent"}
>
${agentSelect}
<label class="field chat-controls__session chat-controls__session-picker">
<select
data-chat-session-select="true"
aria-label="Chat session"
.value=${state.sessionKey}
title=${selectedSessionLabel}
?disabled=${!state.connected || sessionGroups.length === 0}
@@ -71,6 +85,45 @@ export function renderChatSessionSelect(
`;
}
function renderChatAgentSelect(
state: AppViewState,
onSwitchSession: ChatSessionSwitchHandler,
options = resolveChatAgentFilterOptions(state),
) {
if (options.length <= 1) {
return "";
}
const activeAgentId = resolveChatAgentFilterId(state, state.sessionKey);
const selectedLabel = options.find((entry) => entry.id === activeAgentId)?.label ?? activeAgentId;
return html`
<label class="field chat-controls__session chat-controls__agent">
<select
data-chat-agent-filter="true"
aria-label="Filter sessions by agent"
title=${selectedLabel}
.value=${activeAgentId}
?disabled=${!state.connected}
@change=${(e: Event) => {
const nextAgentId = normalizeAgentId((e.target as HTMLSelectElement).value);
if (nextAgentId === activeAgentId) {
return;
}
onSwitchSession(state, resolvePreferredSessionForAgent(state, nextAgentId));
}}
>
${repeat(
options,
(entry) => entry.id,
(entry) =>
html`<option value=${entry.id} ?selected=${entry.id === activeAgentId}>
${entry.label}
</option>`,
)}
</select>
</label>
`;
}
async function refreshSessionOptions(state: AppViewState) {
await loadSessions(state as unknown as Parameters<typeof loadSessions>[0], {
activeMinutes: 0,
@@ -151,17 +204,13 @@ function buildThinkingOptions(
const options: ChatThinkingSelectOption[] = [];
const addOption = (value: string, label?: string) => {
pushUniqueTrimmedSelectOption(
options,
seen,
value,
(trimmed) =>
label ??
trimmed
.split(/[-_]/g)
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
.join(" "),
);
const resolvedLabel =
label ??
value
.split(/[-_]/g)
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
.join(" ");
pushUniqueTrimmedSelectOption(options, seen, value, () => resolvedLabel);
};
for (const level of levels) {
@@ -241,17 +290,19 @@ export function renderChatThinkingSelect(state: AppViewState) {
currentOverride === ""
? defaultLabel
: (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride);
const onChange = async (e: Event) => {
const next = (e.target as HTMLSelectElement).value.trim();
await switchChatThinkingLevel(state, next);
};
return html`
<label class="field chat-controls__session chat-controls__thinking-select">
<select
class="chat-controls__thinking-select-full"
data-chat-thinking-select="true"
aria-label="Chat thinking level"
title=${selectedLabel}
?disabled=${disabled}
@change=${async (e: Event) => {
const next = (e.target as HTMLSelectElement).value.trim();
await switchChatThinkingLevel(state, next);
}}
@change=${onChange}
>
<option value="" ?selected=${currentOverride === ""}>${defaultLabel}</option>
${repeat(
@@ -480,6 +531,68 @@ export type SessionOptionGroup = {
options: SessionOptionEntry[];
};
type ChatAgentFilterOption = {
id: string;
label: string;
};
function resolveChatAgentFilterId(state: AppViewState, sessionKey: string): string {
const parsed = parseAgentSessionKey(sessionKey);
return normalizeAgentId(parsed?.agentId ?? state.agentsList?.defaultId ?? "main");
}
function isSessionKeyTiedToAgent(key: string, agentId: string, defaultAgentId: string): boolean {
const parsed = parseAgentSessionKey(key);
if (parsed) {
return normalizeAgentId(parsed.agentId) === agentId;
}
return agentId === defaultAgentId;
}
function resolvePreferredSessionForAgent(state: AppViewState, agentId: string): string {
const normalizedAgentId = normalizeAgentId(agentId);
const defaultAgentId = normalizeAgentId(state.agentsList?.defaultId ?? "main");
const currentParsed = parseAgentSessionKey(state.sessionKey);
if (normalizeAgentId(currentParsed?.agentId ?? defaultAgentId) === normalizedAgentId) {
return state.sessionKey;
}
const rows = state.sessionsResult?.sessions ?? [];
const row = rows
.filter((entry) => isSessionKeyTiedToAgent(entry.key, normalizedAgentId, defaultAgentId))
.toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
return row?.key ?? buildAgentMainSessionKey({ agentId: normalizedAgentId });
}
function resolveChatAgentFilterOptions(state: AppViewState): ChatAgentFilterOption[] {
const seen = new Set<string>();
const options: ChatAgentFilterOption[] = [];
const add = (agentId: string) => {
const normalized = normalizeAgentId(agentId);
if (seen.has(normalized)) {
return;
}
seen.add(normalized);
options.push({
id: normalized,
label: resolveAgentGroupLabel(state, normalized),
});
};
add(resolveChatAgentFilterId(state, state.sessionKey));
add(state.agentsList?.defaultId ?? "main");
for (const agent of state.agentsList?.agents ?? []) {
add(agent.id);
}
for (const row of state.sessionsResult?.sessions ?? []) {
const parsed = parseAgentSessionKey(row.key);
if (parsed) {
add(parsed.agentId);
}
}
return options;
}
export function resolveSessionOptionGroups(
state: AppViewState,
sessionKey: string,
@@ -487,6 +600,8 @@ export function resolveSessionOptionGroups(
): SessionOptionGroup[] {
const rows = sessions?.sessions ?? [];
const hideCron = state.sessionsHideCron ?? true;
const activeAgentId = resolveChatAgentFilterId(state, sessionKey);
const defaultAgentId = normalizeAgentId(state.agentsList?.defaultId ?? "main");
const byKey = new Map<string, SessionsListResult["sessions"][number]>();
for (const row of rows) {
byKey.set(row.key, row);
@@ -532,6 +647,12 @@ export function resolveSessionOptionGroups(
};
for (const row of rows) {
if (
!isSessionKeyTiedToAgent(row.key, activeAgentId, defaultAgentId) &&
row.key !== sessionKey
) {
continue;
}
if (row.key !== sessionKey && (row.kind === "global" || row.kind === "unknown")) {
continue;
}

View File

@@ -4,7 +4,7 @@
/** Union type for items in the chat thread */
export type ChatItem =
| { kind: "message"; key: string; message: unknown }
| { kind: "message"; key: string; message: unknown; duplicateCount?: number }
| {
kind: "divider";
key: string;
@@ -22,7 +22,7 @@ export type MessageGroup = {
key: string;
role: string;
senderLabel?: string | null;
messages: Array<{ message: unknown; key: string }>;
messages: Array<{ message: unknown; key: string; duplicateCount?: number }>;
timestamp: number;
isStreaming: boolean;
};

View File

@@ -790,6 +790,89 @@ describe("chat welcome", () => {
});
describe("chat session controls", () => {
it("filters chat sessions by agent and switches to that agent's recent session", async () => {
const { state } = createChatHeaderState();
const onSwitchSession = vi.fn();
state.sessionKey = "agent:alpha:main";
state.agentsList = {
defaultId: "alpha",
mainKey: "agent:alpha:main",
scope: "all",
agents: [
{ id: "alpha", name: "Deep Chat" },
{ id: "beta", name: "Coding" },
],
};
state.sessionsResult = {
ts: 0,
path: "",
count: 4,
defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null },
sessions: [
{ key: "agent:alpha:main", kind: "direct", updatedAt: 4 },
{ key: "agent:alpha:dashboard:alpha-recent", kind: "direct", updatedAt: 3 },
{ key: "agent:beta:dashboard:beta-recent", kind: "direct", updatedAt: 2 },
{ key: "agent:beta:main", kind: "direct", updatedAt: 1 },
],
};
const container = document.createElement("div");
render(renderChatSessionSelect(state, onSwitchSession), container);
const agentSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-agent-filter="true"]',
);
const sessionSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-session-select="true"]',
);
expect(agentSelect?.value).toBe("alpha");
expect([...sessionSelect!.options].map((option) => option.value)).toEqual([
"agent:alpha:main",
"agent:alpha:dashboard:alpha-recent",
]);
agentSelect!.value = "beta";
agentSelect!.dispatchEvent(new Event("change", { bubbles: true }));
expect(onSwitchSession).toHaveBeenCalledWith(state, "agent:beta:dashboard:beta-recent");
});
it("falls back to the selected agent's main session when no sessions exist yet", async () => {
const { state } = createChatHeaderState();
const onSwitchSession = vi.fn();
state.sessionKey = "agent:alpha:main";
state.agentsList = {
defaultId: "alpha",
mainKey: "agent:alpha:main",
scope: "all",
agents: [
{ id: "alpha", name: "Deep Chat" },
{ id: "beta", name: "Coding" },
],
};
state.sessionsResult = {
ts: 0,
path: "",
count: 1,
defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null },
sessions: [{ key: "agent:alpha:main", kind: "direct", updatedAt: 4 }],
};
const container = document.createElement("div");
render(renderChatSessionSelect(state, onSwitchSession), container);
const agentSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-agent-filter="true"]',
);
expect(agentSelect).not.toBeNull();
agentSelect!.value = "beta";
agentSelect!.dispatchEvent(new Event("change", { bubbles: true }));
expect(onSwitchSession).toHaveBeenCalledWith(state, "agent:beta:main");
});
it("patches the current session model and refreshes active tool visibility", async () => {
const { state, request } = createChatHeaderState();
state.agentsPanel = "tools";
@@ -935,6 +1018,44 @@ describe("chat session controls", () => {
expect(thinkingSelect?.title).toBe("Default (adaptive)");
});
it("always renders full thinking labels", () => {
const { state } = createChatHeaderState({
model: "gpt-5.5",
modelProvider: "openai-codex",
thinkingDefault: "high",
});
state.sessionsResult = createSessionsListResult({
defaultsModel: "gpt-5.5",
defaultsProvider: "openai-codex",
defaultsThinkingDefault: "high",
defaultsThinkingLevels: [
{ id: "off", label: "off" },
{ id: "low", label: "low" },
{ id: "medium", label: "medium" },
{ id: "high", label: "high" },
{ id: "xhigh", label: "xhigh" },
],
});
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const thinkingSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-thinking-select="true"]',
);
expect(container.querySelector('select[data-chat-thinking-select-compact="true"]')).toBeNull();
expect(thinkingSelect?.value).toBe("");
expect(thinkingSelect?.title).toBe("Default (high)");
expect([...thinkingSelect!.options].map((option) => option.textContent?.trim())).toEqual([
"Default (high)",
"off",
"low",
"medium",
"high",
"xhigh",
]);
});
it("labels chat thinking default from session defaults when the row is absent", () => {
const { state } = createChatHeaderState({
defaultsThinkingDefault: "adaptive",