Skip to content

Commit ea4e005

Browse files
authored
fix(ui): unify split-view controls in one toolbar row (#102492)
* fix(ui): unify split-view toolbar * chore: remove release-owned changelog entry
1 parent e7492c6 commit ea4e005

9 files changed

Lines changed: 357 additions & 168 deletions

File tree

docs/web/control-ui.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -246,10 +246,10 @@ The terminal is also available as a full-screen, terminal-only document at `/?vi
246246
- 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.
247247
- 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.
248248
- 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.
249-
- **Split view:** open it from the composer controls, then split any pane right or down for as many panes as fit. Each pane has its own session, transcript, composer, and tool stream.
249+
- **Split view:** open it from the bottom-right page action, then split the active pane right or down for as many panes as fit. Each pane has its own session, transcript, composer, and tool stream.
250250
- Drag a session from the sidebar into chat to open it in a pane. An animated drop preview glides between zones and labels the outcome — "Split" over the exact half a new pane will occupy, "Open here" over a whole pane — and drops also work from single-pane mode.
251-
- The active split pane drives the sidebar selection and URL. Dividers resize columns and stacked panes, and the browser stores the layout locally across reloads.
252-
- On narrow screens, split view keeps the layout but renders only the active pane; its pane header still provides session switching and close controls.
251+
- The active split pane drives the sidebar selection and URL. The global toolbar shows each pane's session and pane controls in one row; dividers resize columns and stacked panes, and the browser stores the layout locally across reloads.
252+
- On narrow screens, split view keeps the layout but renders only the active pane; the global toolbar still provides session switching and close controls.
253253
- If you send a message while a model picker change for the same session is still saving, the composer waits for that session patch before calling `chat.send` so the send uses the selected model.
254254
- Typing `/new` creates and switches to the same fresh dashboard session as New Chat, except when `session.dmScope: "main"` is configured and the current parent is the agent's main session; then it resets the main session in place. Typing `/reset` keeps the Gateway's explicit in-place reset for the current session.
255255
- The chat model picker requests the Gateway's configured model view. If `agents.defaults.models` is present, that allowlist drives the picker, including `provider/*` entries that keep provider-scoped catalogs dynamic. 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"`.

ui/src/e2e/chat-composer-redesign.e2e.test.ts

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,19 @@ describeControlUiE2e("Control UI chat composer redesign", () => {
120120
const camera = composerShell.locator(".agent-chat__camera-btn");
121121
const takePhoto = composerShell.getByRole("menuitem", { name: "Take photo" });
122122
const settings = composer.getByRole("button", { name: "Chat settings", exact: true });
123-
const splitView = composer.getByRole("button", { name: "Open split view" });
123+
const splitView = page.getByRole("button", { name: "Open split view" });
124124
const voice = page.getByRole("button", { name: "Start voice input" });
125125

126126
await expect.poll(() => model.isVisible()).toBe(true);
127127
await expect.poll(() => contextUsage.isVisible()).toBe(true);
128128
await expect.poll(() => usage.isVisible()).toBe(false);
129129
await expect.poll(() => settings.isVisible()).toBe(true);
130+
await expect.poll(() => splitView.isVisible()).toBe(true);
131+
await expect
132+
.poll(() =>
133+
splitView.evaluate((node) => node.closest(".agent-chat__composer-shell") == null),
134+
)
135+
.toBe(true);
130136
await expect.poll(() => attach.isVisible()).toBe(true);
131137
await expect.poll(() => camera.isVisible()).toBe(false);
132138
await expect.poll(() => voice.isVisible()).toBe(true);
@@ -381,37 +387,56 @@ describeControlUiE2e("Control UI chat composer redesign", () => {
381387
progress.evaluate((node) => node.closest(".agent-chat__composer-controls") != null),
382388
)
383389
.toBe(true);
384-
const [activeSettingsBox, activeSplitViewBox, activeProgressBox, activeModelBox] =
385-
await Promise.all([
386-
settings.boundingBox(),
387-
splitView.boundingBox(),
388-
progress.boundingBox(),
389-
model.boundingBox(),
390-
]);
390+
const [
391+
activeSettingsBox,
392+
activeSplitViewBox,
393+
activeProgressBox,
394+
activeModelBox,
395+
activeChatContentBox,
396+
] = await Promise.all([
397+
settings.boundingBox(),
398+
splitView.boundingBox(),
399+
progress.boundingBox(),
400+
model.boundingBox(),
401+
chatContent.boundingBox(),
402+
]);
391403
expect(activeSettingsBox).not.toBeNull();
392404
expect(activeSplitViewBox).not.toBeNull();
393405
expect(activeProgressBox).not.toBeNull();
394406
expect(activeModelBox).not.toBeNull();
395-
if (!activeSettingsBox || !activeSplitViewBox || !activeProgressBox || !activeModelBox) {
396-
throw new Error(
397-
"expected settings, split view, progress, and model controls to have layout boxes",
398-
);
407+
expect(activeChatContentBox).not.toBeNull();
408+
if (
409+
!activeSettingsBox ||
410+
!activeSplitViewBox ||
411+
!activeProgressBox ||
412+
!activeModelBox ||
413+
!activeChatContentBox
414+
) {
415+
throw new Error("expected chat content and composer controls to have layout boxes");
399416
}
400-
expect(activeSplitViewBox.x).toBeGreaterThanOrEqual(
401-
activeSettingsBox.x + activeSettingsBox.width - 1,
402-
);
403-
expect(
404-
activeSplitViewBox.x - (activeSettingsBox.x + activeSettingsBox.width),
405-
).toBeLessThanOrEqual(8);
406417
expect(activeProgressBox.x).toBeGreaterThanOrEqual(
407-
activeSplitViewBox.x + activeSplitViewBox.width - 1,
418+
activeSettingsBox.x + activeSettingsBox.width - 1,
408419
);
409420
expect(
410-
activeProgressBox.x - (activeSplitViewBox.x + activeSplitViewBox.width),
421+
activeProgressBox.x - (activeSettingsBox.x + activeSettingsBox.width),
411422
).toBeLessThanOrEqual(8);
412423
expect(activeModelBox.x).toBeGreaterThanOrEqual(
413424
activeProgressBox.x + activeProgressBox.width - 1,
414425
);
426+
expect(
427+
Math.abs(
428+
activeChatContentBox.x +
429+
activeChatContentBox.width -
430+
(activeSplitViewBox.x + activeSplitViewBox.width),
431+
),
432+
).toBeLessThanOrEqual(24);
433+
expect(
434+
Math.abs(
435+
activeChatContentBox.y +
436+
activeChatContentBox.height -
437+
(activeSplitViewBox.y + activeSplitViewBox.height),
438+
),
439+
).toBeLessThanOrEqual(24);
415440
expect(
416441
Math.abs(
417442
activeProgressBox.y +

ui/src/e2e/chat-flow.e2e.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,105 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
223223
await closeOpenBrowserContexts();
224224
});
225225

226+
it("uses one global toolbar row for split view", async () => {
227+
const context = await newBrowserContext({
228+
locale: "en-US",
229+
serviceWorkers: "block",
230+
viewport: { height: 900, width: 1440 },
231+
});
232+
const page = await context.newPage();
233+
await installMockGateway(page, {
234+
historyMessages: [
235+
{
236+
content: [{ type: "text", text: "Split toolbar proof." }],
237+
role: "assistant",
238+
timestamp: Date.now(),
239+
},
240+
],
241+
});
242+
243+
try {
244+
await page.goto(`${server.baseUrl}chat`);
245+
await page.getByText("Split toolbar proof.").waitFor({ timeout: 10_000 });
246+
247+
const splitEntry = page.getByRole("button", { name: "Open split view" });
248+
await expect.poll(() => splitEntry.isVisible()).toBe(true);
249+
await page.setViewportSize({ height: 900, width: 1100 });
250+
await expect.poll(() => splitEntry.isVisible()).toBe(true);
251+
await page.setViewportSize({ height: 900, width: 1440 });
252+
await expect
253+
.poll(() =>
254+
splitEntry.evaluate((node) => node.closest(".agent-chat__composer-shell") == null),
255+
)
256+
.toBe(true);
257+
await splitEntry.click();
258+
259+
const topbar = page.locator(".topbar");
260+
const toolbar = page.locator(".chat-split-toolbar");
261+
const toolbarPanes = page.locator(".chat-split-toolbar__pane");
262+
await expect.poll(() => page.locator(".chat-split-view__pane").count()).toBe(2);
263+
await expect.poll(() => toolbarPanes.count()).toBe(2);
264+
await expect
265+
.poll(async () => {
266+
const visible = await Promise.all(
267+
(await toolbarPanes.all()).map((pane) => pane.isVisible()),
268+
);
269+
return visible.every(Boolean);
270+
})
271+
.toBe(true);
272+
await expect.poll(() => page.locator(".dashboard-header").isVisible()).toBe(false);
273+
await expect.poll(() => splitEntry.count()).toBe(0);
274+
275+
const [topbarBox, toolbarBox] = await Promise.all([
276+
topbar.boundingBox(),
277+
toolbar.boundingBox(),
278+
]);
279+
expect(topbarBox).not.toBeNull();
280+
expect(toolbarBox).not.toBeNull();
281+
if (!topbarBox || !toolbarBox) {
282+
throw new Error("expected the split toolbar and global topbar to have layout boxes");
283+
}
284+
expect(Math.abs(topbarBox.y - toolbarBox.y)).toBeLessThanOrEqual(1);
285+
expect(Math.abs(topbarBox.height - toolbarBox.height)).toBeLessThanOrEqual(1);
286+
287+
await toolbarPanes.first().getByRole("combobox").focus();
288+
await expect.poll(() => toolbarPanes.first().getAttribute("class")).toContain("--active");
289+
290+
await page.evaluate(() => {
291+
document.documentElement.style.setProperty("--safe-area-top", "20px");
292+
document.documentElement.style.setProperty("--safe-area-left", "24px");
293+
document.documentElement.style.setProperty("--safe-area-right", "24px");
294+
document.body.style.paddingTop = "20px";
295+
document.body.style.paddingRight = "24px";
296+
document.body.style.paddingLeft = "24px";
297+
});
298+
const insetToolbarBox = await toolbar.boundingBox();
299+
expect(insetToolbarBox?.y).toBe(20);
300+
expect(insetToolbarBox?.x).toBeGreaterThan(topbarBox.x);
301+
302+
await page.setViewportSize({ height: 900, width: 1100 });
303+
const navToggle = page.getByRole("button", { name: "Expand sidebar" });
304+
await expect.poll(() => navToggle.isVisible()).toBe(true);
305+
const [navToggleBox, narrowToolbarBox] = await Promise.all([
306+
navToggle.boundingBox(),
307+
toolbar.boundingBox(),
308+
]);
309+
expect(navToggleBox).not.toBeNull();
310+
expect(narrowToolbarBox).not.toBeNull();
311+
if (!navToggleBox || !narrowToolbarBox) {
312+
throw new Error("expected the drawer toggle and split toolbar to have layout boxes");
313+
}
314+
expect(narrowToolbarBox.x).toBeGreaterThanOrEqual(navToggleBox.x + navToggleBox.width);
315+
316+
const firstPane = page.locator(".chat-split-view__pane").first();
317+
await firstPane.click({ position: { x: 20, y: 80 } });
318+
await expect.poll(() => firstPane.getAttribute("class")).toContain("--active");
319+
await expect.poll(() => toolbarPanes.first().getAttribute("class")).toContain("--active");
320+
} finally {
321+
await closeBrowserContext(context);
322+
}
323+
});
324+
226325
it("sends a chat turn through the GUI and renders the final Gateway event", async () => {
227326
const context = await newBrowserContext({
228327
locale: "en-US",

ui/src/pages/chat/chat-page.test.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ type RenderedPane = HTMLElement & {
2222
paneId: string;
2323
sessionKey: string;
2424
active: boolean;
25-
chrome: "none" | "pane";
2625
};
2726

2827
function setLayout(page: ChatPage, layout: ChatSplitLayout | undefined) {
@@ -97,8 +96,8 @@ describe("chat page split layout host", () => {
9796
expect(panes[0].paneId).toBe("single");
9897
expect(panes[0].sessionKey).toBe("main");
9998
expect(panes[0].active).toBe(true);
100-
expect(panes[0].chrome).toBe("none");
10199
expect(page.querySelector("resizable-divider")).toBeNull();
100+
expect(page.querySelector(".chat-open-split-view")).toBeInstanceOf(HTMLButtonElement);
102101
});
103102

104103
it("passes an empty session key while route data is still unresolved", async () => {
@@ -123,10 +122,13 @@ describe("chat page split layout host", () => {
123122
const panes = [...page.querySelectorAll<RenderedPane>("openclaw-chat-pane")];
124123
const dividers = page.querySelectorAll<ResizableDivider>("resizable-divider");
125124
expect(panes.map((pane) => pane.paneId)).toEqual(["p1", "p2"]);
126-
expect(panes.map((pane) => pane.chrome)).toEqual(["pane", "pane"]);
127125
expect(panes.map((pane) => pane.active)).toEqual([false, true]);
128126
expect(dividers).toHaveLength(1);
129127
expect(dividers[0].orientation).toBe("vertical");
128+
expect(page.querySelector(".chat-split-view__pane--active")).toBe(panes[1]);
129+
expect(page.querySelectorAll(".chat-split-toolbar__pane")).toHaveLength(2);
130+
expect(page.querySelector(".chat-split-toolbar__pane--active")).not.toBeNull();
131+
expect(page.querySelector(".chat-open-split-view")).toBeNull();
130132
});
131133

132134
it("renders only the active pane from a preserved split on narrow viewports", async () => {
@@ -140,10 +142,46 @@ describe("chat page split layout host", () => {
140142
const panes = [...page.querySelectorAll<RenderedPane>("openclaw-chat-pane")];
141143
expect(panes.map((pane) => pane.paneId)).toEqual(["p2"]);
142144
expect(panes[0].active).toBe(true);
143-
expect(panes[0].chrome).toBe("pane");
145+
expect(page.querySelectorAll(".chat-split-toolbar__pane")).toHaveLength(1);
144146
expect(page.querySelector("resizable-divider")).toBeNull();
145147
});
146148

149+
it("refreshes split toolbar sessions after the shared list loads", async () => {
150+
const page = new ChatPage();
151+
const cleanup = vi.fn();
152+
const sessionsState: { result: { sessions: Array<{ key: string }> } | null } = {
153+
result: null,
154+
};
155+
let notify = () => {};
156+
(page as unknown as { context: unknown }).context = {
157+
sessions: {
158+
state: sessionsState,
159+
subscribe: (listener: () => void) => {
160+
notify = listener;
161+
return cleanup;
162+
},
163+
},
164+
};
165+
page.data = { sessionKey: "main" };
166+
document.body.append(page);
167+
setLayout(page, createSplitLayout("main"));
168+
await page.updateComplete;
169+
170+
sessionsState.result = {
171+
sessions: [{ key: "agent:main:work" }, { key: "main" }],
172+
};
173+
notify();
174+
await page.updateComplete;
175+
176+
const selects = [...page.querySelectorAll<HTMLSelectElement>(".chat-pane__session-select")];
177+
expect(selects).toHaveLength(2);
178+
expect(selects.map((select) => select.options.length)).toEqual([2, 2]);
179+
expect(selects.map((select) => select.value)).toEqual(["main", "main"]);
180+
181+
page.remove();
182+
expect(cleanup).toHaveBeenCalledOnce();
183+
});
184+
147185
it("routes a classic-mode center drop without creating a layout", () => {
148186
const page = new ChatPage();
149187
page.data = { sessionKey: "main" };

0 commit comments

Comments
 (0)