fix(control-ui): order chat agent before session

This commit is contained in:
Val Alexander
2026-05-04 03:44:00 -05:00
parent e181f7aa97
commit 45401c929a
7 changed files with 219 additions and 14 deletions

View File

@@ -21,7 +21,7 @@ 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: add an agent-first filter to the chat session picker, keep chat controls/composer responsive across phone/tablet/desktop widths, keep desktop chat controls on one row, avoid duplicate avatar refreshes during initial chat load, 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.

View File

@@ -158,7 +158,7 @@ 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.
- The chat header shows the agent filter before the session picker, and the 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.

View File

@@ -1050,9 +1050,9 @@
.chat-controls__session-row {
display: grid;
grid-template-columns:
minmax(132px, 7fr) minmax(116px, 5fr) minmax(132px, 5fr)
minmax(116px, 5fr) minmax(132px, 7fr) minmax(132px, 5fr)
minmax(128px, 4fr);
grid-template-areas: "session agent model thinking";
grid-template-areas: "agent session model thinking";
align-items: center;
gap: 8px;
width: 100%;

View File

@@ -30,9 +30,9 @@
.chat-controls__session-row {
grid-template-columns:
minmax(112px, 7fr) minmax(96px, 5fr) minmax(116px, 5fr)
minmax(96px, 5fr) minmax(112px, 7fr) minmax(116px, 5fr)
minmax(112px, 4fr);
grid-template-areas: "session agent model thinking";
grid-template-areas: "agent session model thinking";
align-items: center;
gap: 8px;
width: 100%;
@@ -441,9 +441,9 @@
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session-row {
display: grid;
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
grid-template-areas:
"session agent"
"agent session"
"model thinking";
gap: 8px;
width: 100%;

View File

@@ -0,0 +1,203 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
import { connectGateway } from "./app-gateway.ts";
import type { GatewayHelloOk } from "./gateway.ts";
import type { Tab } from "./navigation.ts";
const refreshActiveTabMock = vi.hoisted(() => vi.fn(async () => undefined));
const refreshChatAvatarMock = vi.hoisted(() => vi.fn(async () => undefined));
const loadControlUiBootstrapConfigMock = vi.hoisted(() => vi.fn(async () => undefined));
const loadAgentsMock = vi.hoisted(() => vi.fn(async () => undefined));
const loadAssistantIdentityMock = vi.hoisted(() => vi.fn(async () => undefined));
const loadDevicesMock = vi.hoisted(() => vi.fn(async () => undefined));
const loadHealthStateMock = vi.hoisted(() => vi.fn(async () => undefined));
const loadNodesMock = vi.hoisted(() => vi.fn(async () => undefined));
const subscribeSessionsMock = vi.hoisted(() => vi.fn(async () => undefined));
const verifyPushMock = vi.hoisted(() => vi.fn(async () => undefined));
type GatewayClientMock = {
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
emitHello: (hello?: GatewayHelloOk) => void;
};
const gatewayClients: GatewayClientMock[] = [];
vi.mock("./gateway.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("./gateway.ts")>();
class GatewayBrowserClient {
readonly start = vi.fn();
readonly stop = vi.fn();
readonly request = vi.fn(async () => ({}));
constructor(private opts: { onHello?: (hello: GatewayHelloOk) => void }) {
gatewayClients.push({
start: this.start,
stop: this.stop,
emitHello: (hello) => {
this.opts.onHello?.(
hello ?? {
type: "hello-ok",
protocol: 3,
snapshot: {},
auth: { role: "operator", scopes: [] },
},
);
},
});
}
}
return {
...actual,
GatewayBrowserClient,
resolveGatewayErrorDetailCode: () => null,
};
});
vi.mock("./app-chat.ts", () => ({
CHAT_SESSIONS_ACTIVE_MINUTES: 60,
clearPendingQueueItemsForRun: vi.fn(),
flushChatQueueForEvent: vi.fn(),
refreshChatAvatar: refreshChatAvatarMock,
}));
vi.mock("./app-settings.ts", () => ({
applySettings: vi.fn(),
loadCron: vi.fn(),
refreshActiveTab: refreshActiveTabMock,
setLastActiveSessionKey: vi.fn(),
}));
vi.mock("./controllers/agents.ts", () => ({
loadAgents: loadAgentsMock,
}));
vi.mock("./controllers/assistant-identity.ts", () => ({
loadAssistantIdentity: loadAssistantIdentityMock,
}));
vi.mock("./controllers/control-ui-bootstrap.ts", () => ({
loadControlUiBootstrapConfig: loadControlUiBootstrapConfigMock,
}));
vi.mock("./controllers/devices.ts", () => ({
loadDevices: loadDevicesMock,
}));
vi.mock("./controllers/exec-approval.ts", () => ({
addExecApproval: vi.fn((queue, entry) => [...queue, entry]),
parseExecApprovalRequested: vi.fn(() => null),
parseExecApprovalResolved: vi.fn(() => null),
parsePluginApprovalRequested: vi.fn(() => null),
pruneExecApprovalQueue: vi.fn((queue) => queue),
removeExecApproval: vi.fn((queue) => queue),
}));
vi.mock("./controllers/health.ts", () => ({
loadHealthState: loadHealthStateMock,
}));
vi.mock("./controllers/nodes.ts", () => ({
loadNodes: loadNodesMock,
}));
vi.mock("./controllers/sessions.ts", () => ({
applySessionsChangedEvent: vi.fn(() => ({ applied: false })),
loadSessions: vi.fn(async () => undefined),
subscribeSessions: subscribeSessionsMock,
}));
function createHost(tab: Tab) {
return {
settings: {
gatewayUrl: "ws://127.0.0.1:18789",
token: "",
sessionKey: "main",
},
password: "",
clientInstanceId: "control-ui-test",
client: null,
connected: false,
hello: null,
lastError: null,
lastErrorCode: null,
eventLogBuffer: [],
eventLog: [],
tab,
presenceEntries: [],
presenceError: null,
presenceStatus: null,
agentsLoading: false,
agentsList: null,
agentsError: null,
healthLoading: false,
healthResult: null,
healthError: null,
debugHealth: null,
assistantName: "OpenClaw",
assistantAvatar: null,
assistantAgentId: null,
serverVersion: null,
pendingUpdateExpectedVersion: null,
updateStatusBanner: null,
sessionKey: "main",
chatRunId: null,
chatStream: null,
chatStreamSegments: [],
chatStreamStartedAt: null,
chatToolMessages: [],
toolStreamById: new Map(),
toolStreamOrder: [],
toolStreamSyncTimer: null,
pendingAbort: null,
refreshSessionsAfterChat: new Set<string>(),
execApprovalQueue: [],
execApprovalError: null,
updateAvailable: null,
reconcileWebPushState: verifyPushMock,
} as unknown as Parameters<typeof connectGateway>[0];
}
function connectHost(tab: Tab) {
const host = createHost(tab);
connectGateway(host);
const client = gatewayClients[0];
expect(client).toBeDefined();
return { host, client };
}
beforeEach(() => {
gatewayClients.length = 0;
refreshActiveTabMock.mockClear();
refreshChatAvatarMock.mockClear();
loadControlUiBootstrapConfigMock.mockClear();
loadAgentsMock.mockClear();
loadAssistantIdentityMock.mockClear();
loadDevicesMock.mockClear();
loadHealthStateMock.mockClear();
loadNodesMock.mockClear();
subscribeSessionsMock.mockClear();
verifyPushMock.mockClear();
});
describe("connectGateway chat load startup work", () => {
it("lets the active chat refresh own avatar loading on initial chat hello", () => {
const { host, client } = connectHost("chat");
client.emitHello();
expect(refreshActiveTabMock).toHaveBeenCalledWith(host);
expect(refreshChatAvatarMock).not.toHaveBeenCalled();
});
it("still preloads the chat avatar when connecting outside the chat tab", () => {
const { host, client } = connectHost("overview");
client.emitHello();
expect(refreshActiveTabMock).toHaveBeenCalledWith(host);
expect(refreshChatAvatarMock).toHaveBeenCalledWith(host);
});
});

View File

@@ -483,7 +483,9 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
}
void subscribeSessions(host as unknown as SessionsState);
void loadAssistantIdentity(host as unknown as AssistantIdentityState);
void refreshChatAvatar(host as unknown as Parameters<typeof refreshChatAvatar>[0]);
if (host.tab !== "chat") {
void refreshChatAvatar(host as unknown as Parameters<typeof refreshChatAvatar>[0]);
}
void loadAgents(host as unknown as AgentsState);
void loadHealthState(host as unknown as HealthState);
void loadNodes(host as unknown as NodesState, { quiet: true });

View File

@@ -87,12 +87,12 @@ function chatHeaderControlsHtml(hidden = false) {
<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__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__model">
<select data-chat-model-select="true" aria-label="Chat model"><option>gpt-5.5</option></select>
</label>
@@ -260,7 +260,7 @@ describe("chat responsive browser layout", () => {
].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.agent!.x).toBeLessThan(controls.session!.x);
expect(controls.session!.width / controls.agent!.width).toBeGreaterThan(1.25);
expect(controls.session!.width / controls.agent!.width).toBeLessThan(1.55);
} finally {
@@ -342,7 +342,7 @@ describe("chat responsive browser layout", () => {
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.agent!.x).toBeLessThan(mobileControls.session!.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");