diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 540b0df51603..7a2fa6607968 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -246,10 +246,10 @@ The terminal is also available as a full-screen, terminal-only document at `/?vi - 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. - - **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. + - **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. - 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. - - 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. - - On narrow screens, split view keeps the layout but renders only the active pane; its pane header still provides session switching and close controls. + - 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. + - On narrow screens, split view keeps the layout but renders only the active pane; the global toolbar still provides session switching and close controls. - 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. - 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. - 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"`. diff --git a/ui/src/e2e/chat-composer-redesign.e2e.test.ts b/ui/src/e2e/chat-composer-redesign.e2e.test.ts index d14688f17923..a4779f9c6d7b 100644 --- a/ui/src/e2e/chat-composer-redesign.e2e.test.ts +++ b/ui/src/e2e/chat-composer-redesign.e2e.test.ts @@ -120,13 +120,19 @@ describeControlUiE2e("Control UI chat composer redesign", () => { const camera = composerShell.locator(".agent-chat__camera-btn"); const takePhoto = composerShell.getByRole("menuitem", { name: "Take photo" }); const settings = composer.getByRole("button", { name: "Chat settings", exact: true }); - const splitView = composer.getByRole("button", { name: "Open split view" }); + const splitView = page.getByRole("button", { name: "Open split view" }); const voice = page.getByRole("button", { name: "Start voice input" }); await expect.poll(() => model.isVisible()).toBe(true); await expect.poll(() => contextUsage.isVisible()).toBe(true); await expect.poll(() => usage.isVisible()).toBe(false); await expect.poll(() => settings.isVisible()).toBe(true); + await expect.poll(() => splitView.isVisible()).toBe(true); + await expect + .poll(() => + splitView.evaluate((node) => node.closest(".agent-chat__composer-shell") == null), + ) + .toBe(true); await expect.poll(() => attach.isVisible()).toBe(true); await expect.poll(() => camera.isVisible()).toBe(false); await expect.poll(() => voice.isVisible()).toBe(true); @@ -381,37 +387,56 @@ describeControlUiE2e("Control UI chat composer redesign", () => { progress.evaluate((node) => node.closest(".agent-chat__composer-controls") != null), ) .toBe(true); - const [activeSettingsBox, activeSplitViewBox, activeProgressBox, activeModelBox] = - await Promise.all([ - settings.boundingBox(), - splitView.boundingBox(), - progress.boundingBox(), - model.boundingBox(), - ]); + const [ + activeSettingsBox, + activeSplitViewBox, + activeProgressBox, + activeModelBox, + activeChatContentBox, + ] = await Promise.all([ + settings.boundingBox(), + splitView.boundingBox(), + progress.boundingBox(), + model.boundingBox(), + chatContent.boundingBox(), + ]); expect(activeSettingsBox).not.toBeNull(); expect(activeSplitViewBox).not.toBeNull(); expect(activeProgressBox).not.toBeNull(); expect(activeModelBox).not.toBeNull(); - if (!activeSettingsBox || !activeSplitViewBox || !activeProgressBox || !activeModelBox) { - throw new Error( - "expected settings, split view, progress, and model controls to have layout boxes", - ); + expect(activeChatContentBox).not.toBeNull(); + if ( + !activeSettingsBox || + !activeSplitViewBox || + !activeProgressBox || + !activeModelBox || + !activeChatContentBox + ) { + throw new Error("expected chat content and composer controls to have layout boxes"); } - expect(activeSplitViewBox.x).toBeGreaterThanOrEqual( + expect(activeProgressBox.x).toBeGreaterThanOrEqual( activeSettingsBox.x + activeSettingsBox.width - 1, ); expect( - activeSplitViewBox.x - (activeSettingsBox.x + activeSettingsBox.width), - ).toBeLessThanOrEqual(8); - expect(activeProgressBox.x).toBeGreaterThanOrEqual( - activeSplitViewBox.x + activeSplitViewBox.width - 1, - ); - expect( - activeProgressBox.x - (activeSplitViewBox.x + activeSplitViewBox.width), + activeProgressBox.x - (activeSettingsBox.x + activeSettingsBox.width), ).toBeLessThanOrEqual(8); expect(activeModelBox.x).toBeGreaterThanOrEqual( activeProgressBox.x + activeProgressBox.width - 1, ); + expect( + Math.abs( + activeChatContentBox.x + + activeChatContentBox.width - + (activeSplitViewBox.x + activeSplitViewBox.width), + ), + ).toBeLessThanOrEqual(24); + expect( + Math.abs( + activeChatContentBox.y + + activeChatContentBox.height - + (activeSplitViewBox.y + activeSplitViewBox.height), + ), + ).toBeLessThanOrEqual(24); expect( Math.abs( activeProgressBox.y + diff --git a/ui/src/e2e/chat-flow.e2e.test.ts b/ui/src/e2e/chat-flow.e2e.test.ts index a0ce48081775..6db78ada9c11 100644 --- a/ui/src/e2e/chat-flow.e2e.test.ts +++ b/ui/src/e2e/chat-flow.e2e.test.ts @@ -223,6 +223,105 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { await closeOpenBrowserContexts(); }); + it("uses one global toolbar row for split view", async () => { + const context = await newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1440 }, + }); + const page = await context.newPage(); + await installMockGateway(page, { + historyMessages: [ + { + content: [{ type: "text", text: "Split toolbar proof." }], + role: "assistant", + timestamp: Date.now(), + }, + ], + }); + + try { + await page.goto(`${server.baseUrl}chat`); + await page.getByText("Split toolbar proof.").waitFor({ timeout: 10_000 }); + + const splitEntry = page.getByRole("button", { name: "Open split view" }); + await expect.poll(() => splitEntry.isVisible()).toBe(true); + await page.setViewportSize({ height: 900, width: 1100 }); + await expect.poll(() => splitEntry.isVisible()).toBe(true); + await page.setViewportSize({ height: 900, width: 1440 }); + await expect + .poll(() => + splitEntry.evaluate((node) => node.closest(".agent-chat__composer-shell") == null), + ) + .toBe(true); + await splitEntry.click(); + + const topbar = page.locator(".topbar"); + const toolbar = page.locator(".chat-split-toolbar"); + const toolbarPanes = page.locator(".chat-split-toolbar__pane"); + await expect.poll(() => page.locator(".chat-split-view__pane").count()).toBe(2); + await expect.poll(() => toolbarPanes.count()).toBe(2); + await expect + .poll(async () => { + const visible = await Promise.all( + (await toolbarPanes.all()).map((pane) => pane.isVisible()), + ); + return visible.every(Boolean); + }) + .toBe(true); + await expect.poll(() => page.locator(".dashboard-header").isVisible()).toBe(false); + await expect.poll(() => splitEntry.count()).toBe(0); + + const [topbarBox, toolbarBox] = await Promise.all([ + topbar.boundingBox(), + toolbar.boundingBox(), + ]); + expect(topbarBox).not.toBeNull(); + expect(toolbarBox).not.toBeNull(); + if (!topbarBox || !toolbarBox) { + throw new Error("expected the split toolbar and global topbar to have layout boxes"); + } + expect(Math.abs(topbarBox.y - toolbarBox.y)).toBeLessThanOrEqual(1); + expect(Math.abs(topbarBox.height - toolbarBox.height)).toBeLessThanOrEqual(1); + + await toolbarPanes.first().getByRole("combobox").focus(); + await expect.poll(() => toolbarPanes.first().getAttribute("class")).toContain("--active"); + + await page.evaluate(() => { + document.documentElement.style.setProperty("--safe-area-top", "20px"); + document.documentElement.style.setProperty("--safe-area-left", "24px"); + document.documentElement.style.setProperty("--safe-area-right", "24px"); + document.body.style.paddingTop = "20px"; + document.body.style.paddingRight = "24px"; + document.body.style.paddingLeft = "24px"; + }); + const insetToolbarBox = await toolbar.boundingBox(); + expect(insetToolbarBox?.y).toBe(20); + expect(insetToolbarBox?.x).toBeGreaterThan(topbarBox.x); + + await page.setViewportSize({ height: 900, width: 1100 }); + const navToggle = page.getByRole("button", { name: "Expand sidebar" }); + await expect.poll(() => navToggle.isVisible()).toBe(true); + const [navToggleBox, narrowToolbarBox] = await Promise.all([ + navToggle.boundingBox(), + toolbar.boundingBox(), + ]); + expect(navToggleBox).not.toBeNull(); + expect(narrowToolbarBox).not.toBeNull(); + if (!navToggleBox || !narrowToolbarBox) { + throw new Error("expected the drawer toggle and split toolbar to have layout boxes"); + } + expect(narrowToolbarBox.x).toBeGreaterThanOrEqual(navToggleBox.x + navToggleBox.width); + + const firstPane = page.locator(".chat-split-view__pane").first(); + await firstPane.click({ position: { x: 20, y: 80 } }); + await expect.poll(() => firstPane.getAttribute("class")).toContain("--active"); + await expect.poll(() => toolbarPanes.first().getAttribute("class")).toContain("--active"); + } finally { + await closeBrowserContext(context); + } + }); + it("sends a chat turn through the GUI and renders the final Gateway event", async () => { const context = await newBrowserContext({ locale: "en-US", diff --git a/ui/src/pages/chat/chat-page.test.ts b/ui/src/pages/chat/chat-page.test.ts index a906ad8be993..3f84cdd5a522 100644 --- a/ui/src/pages/chat/chat-page.test.ts +++ b/ui/src/pages/chat/chat-page.test.ts @@ -22,7 +22,6 @@ type RenderedPane = HTMLElement & { paneId: string; sessionKey: string; active: boolean; - chrome: "none" | "pane"; }; function setLayout(page: ChatPage, layout: ChatSplitLayout | undefined) { @@ -97,8 +96,8 @@ describe("chat page split layout host", () => { expect(panes[0].paneId).toBe("single"); expect(panes[0].sessionKey).toBe("main"); expect(panes[0].active).toBe(true); - expect(panes[0].chrome).toBe("none"); expect(page.querySelector("resizable-divider")).toBeNull(); + expect(page.querySelector(".chat-open-split-view")).toBeInstanceOf(HTMLButtonElement); }); it("passes an empty session key while route data is still unresolved", async () => { @@ -123,10 +122,13 @@ describe("chat page split layout host", () => { const panes = [...page.querySelectorAll("openclaw-chat-pane")]; const dividers = page.querySelectorAll("resizable-divider"); expect(panes.map((pane) => pane.paneId)).toEqual(["p1", "p2"]); - expect(panes.map((pane) => pane.chrome)).toEqual(["pane", "pane"]); expect(panes.map((pane) => pane.active)).toEqual([false, true]); expect(dividers).toHaveLength(1); expect(dividers[0].orientation).toBe("vertical"); + expect(page.querySelector(".chat-split-view__pane--active")).toBe(panes[1]); + expect(page.querySelectorAll(".chat-split-toolbar__pane")).toHaveLength(2); + expect(page.querySelector(".chat-split-toolbar__pane--active")).not.toBeNull(); + expect(page.querySelector(".chat-open-split-view")).toBeNull(); }); it("renders only the active pane from a preserved split on narrow viewports", async () => { @@ -140,10 +142,46 @@ describe("chat page split layout host", () => { const panes = [...page.querySelectorAll("openclaw-chat-pane")]; expect(panes.map((pane) => pane.paneId)).toEqual(["p2"]); expect(panes[0].active).toBe(true); - expect(panes[0].chrome).toBe("pane"); + expect(page.querySelectorAll(".chat-split-toolbar__pane")).toHaveLength(1); expect(page.querySelector("resizable-divider")).toBeNull(); }); + it("refreshes split toolbar sessions after the shared list loads", async () => { + const page = new ChatPage(); + const cleanup = vi.fn(); + const sessionsState: { result: { sessions: Array<{ key: string }> } | null } = { + result: null, + }; + let notify = () => {}; + (page as unknown as { context: unknown }).context = { + sessions: { + state: sessionsState, + subscribe: (listener: () => void) => { + notify = listener; + return cleanup; + }, + }, + }; + page.data = { sessionKey: "main" }; + document.body.append(page); + setLayout(page, createSplitLayout("main")); + await page.updateComplete; + + sessionsState.result = { + sessions: [{ key: "agent:main:work" }, { key: "main" }], + }; + notify(); + await page.updateComplete; + + const selects = [...page.querySelectorAll(".chat-pane__session-select")]; + expect(selects).toHaveLength(2); + expect(selects.map((select) => select.options.length)).toEqual([2, 2]); + expect(selects.map((select) => select.value)).toEqual(["main", "main"]); + + page.remove(); + expect(cleanup).toHaveBeenCalledOnce(); + }); + it("routes a classic-mode center drop without creating a layout", () => { const page = new ChatPage(); page.data = { sessionKey: "main" }; diff --git a/ui/src/pages/chat/chat-page.ts b/ui/src/pages/chat/chat-page.ts index aef30e735bef..d21c2f371e4b 100644 --- a/ui/src/pages/chat/chat-page.ts +++ b/ui/src/pages/chat/chat-page.ts @@ -4,8 +4,11 @@ import { property, state } from "lit/decorators.js"; import { repeat } from "lit/directives/repeat.js"; import { applicationContext, type ApplicationContext } from "../../app/context.ts"; import { loadSettings, patchSettings } from "../../app/settings.ts"; +import { icons } from "../../components/icons.ts"; import "../../components/resizable-divider.ts"; +import "../../components/tooltip.ts"; import { t } from "../../i18n/index.ts"; +import { resolveSessionDisplayName } from "../../lib/session-display.ts"; import { readSessionDragData, sessionDragActive } from "../../lib/sessions/drag.ts"; import { searchForSession } from "../../lib/sessions/index.ts"; import "./chat-pane.ts"; @@ -49,6 +52,7 @@ export class ChatPage extends LitElement { @state() private dropIndicator: DropIndicator | null = null; private mediaQuery: MediaQueryList | null = null; + private sessionsCleanup: (() => void) | null = null; // Light-DOM enter/leave events bubble from every nested child, so only clear // the shared preview after the whole balanced drag has left the page. private dragDepth = 0; @@ -65,6 +69,7 @@ export class ChatPage extends LitElement { this.mediaQuery = window.matchMedia(NARROW_SPLIT_QUERY); this.narrow = this.mediaQuery.matches; this.mediaQuery.addEventListener("change", this.handleViewportChange); + this.sessionsCleanup = this.context?.sessions?.subscribe(() => this.requestUpdate()) ?? null; this.addEventListener("dragenter", this.handleDragEnter); this.addEventListener("dragover", this.handleDragOver); this.addEventListener("dragleave", this.handleDragLeave); @@ -76,6 +81,8 @@ export class ChatPage extends LitElement { override disconnectedCallback() { this.mediaQuery?.removeEventListener("change", this.handleViewportChange); this.mediaQuery = null; + this.sessionsCleanup?.(); + this.sessionsCleanup = null; this.removeEventListener("dragenter", this.handleDragEnter); this.removeEventListener("dragover", this.handleDragOver); this.removeEventListener("dragleave", this.handleDragLeave); @@ -89,6 +96,12 @@ export class ChatPage extends LitElement { if (changedProperties.has("data")) { this.syncRouteToActivePane(); } + for (const select of this.querySelectorAll(".chat-pane__session-select")) { + const sessionKey = select.dataset.sessionKey; + if (sessionKey && select.value !== sessionKey) { + select.value = sessionKey; + } + } } private readonly handleViewportChange = (event: MediaQueryListEvent) => { @@ -371,27 +384,108 @@ export class ChatPage extends LitElement { }; private renderPane(pane: ChatSplitPane, active: boolean, weight: number) { - // Narrow viewports render only the active pane, so splitting there would - // create invisible panes; keep session switching and close available. - const canSplit = !this.narrow; return html` `; } + private renderSplitToolbar(layout: ChatSplitLayout) { + const sessions = this.context?.sessions?.state.result?.sessions ?? []; + const panes = this.narrow + ? [findPane(layout, layout.activePaneId)?.pane].filter( + (pane): pane is ChatSplitPane => pane != null, + ) + : panesOf(layout); + return html` +
+ ${panes.map((pane) => { + const active = pane.id === layout.activePaneId; + const currentSession = sessions.find((row) => row.key === pane.sessionKey); + const options = currentSession ? sessions : [{ key: pane.sessionKey }, ...sessions]; + return html` +
this.handleFocusPane(pane.id)} + @focusin=${() => this.handleFocusPane(pane.id)} + > + +
+ ${!this.narrow + ? html` + + + + + + + ` + : nothing} + + + +
+
+ `; + })} +
+ `; + } + private renderSplitLayout(layout: ChatSplitLayout) { if (this.narrow) { const activePane = findPane(layout, layout.activePaneId)?.pane; @@ -472,6 +566,7 @@ export class ChatPage extends LitElement { const indicator = this.dropIndicator; return html`
+ ${this.layout ? this.renderSplitToolbar(this.layout) : nothing} ${this.layout ? this.renderSplitLayout(this.layout) : html` @@ -479,13 +574,25 @@ export class ChatPage extends LitElement { .paneId=${"single"} .sessionKey=${this.data?.sessionKey ?? ""} .active=${true} - .chrome=${"none"} .draft=${this.data?.draft} .onFocusPane=${this.handleFocusPane} .onPaneSessionChange=${this.handlePaneSessionChange} - .onOpenSplitView=${this.narrow ? undefined : this.openSplitView} > `} + ${!this.layout && !this.narrow + ? html` + + + + ` + : nothing} ${indicator ? html`
void; @property({ attribute: false }) onPaneSessionChange?: ( @@ -132,10 +129,6 @@ class ChatPane extends LitElement { nextSessionKey: string, options?: PaneSessionChangeOptions, ) => void; - @property({ attribute: false }) onSplitRight?: (paneId: string) => void; - @property({ attribute: false }) onSplitDown?: (paneId: string) => void; - @property({ attribute: false }) onClosePane?: (paneId: string) => void; - @property({ attribute: false }) onOpenSplitView?: () => void; private readonly chatState = new ChatStateController(this); private state: ChatPageHost | undefined; @@ -665,16 +658,6 @@ class ChatPane extends LitElement { } } - override updated() { - // The header { - const nextSessionKey = (event.target as HTMLSelectElement).value; - if (nextSessionKey && nextSessionKey !== state.sessionKey) { - this.onPaneSessionChange?.(this.paneId, nextSessionKey); - } - }} - > - ${options.map( - (row) => html` - - `, - )} - - -
- ${this.onSplitDown - ? html` - - - - ` - : null} - ${this.onSplitRight - ? html` - - - - ` - : null} - ${this.onClosePane - ? html` - - - - ` - : null} -
-
- `; - } - override render() { const state = this.state; if (!state) { @@ -1068,7 +969,6 @@ class ChatPane extends LitElement { state.sessionsHideCron = !state.sessionsHideCron; state.requestUpdate?.(); }, - onOpenSplitView: this.onOpenSplitView, }), sessionWorkspace: createSessionWorkspaceProps(state), taskSuggestions: this.taskSuggestions, @@ -1181,7 +1081,7 @@ class ChatPane extends LitElement { onAssistantAttachmentLoaded: () => state.scrollToBottom(), basePath: state.basePath, }; - return html`${this.renderPaneHeader(state)}${renderChat(props)}`; + return renderChat(props); } } diff --git a/ui/src/pages/chat/components/chat-controls.ts b/ui/src/pages/chat/components/chat-controls.ts index 92558bb0ee32..2ed86035d4c1 100644 --- a/ui/src/pages/chat/components/chat-controls.ts +++ b/ui/src/pages/chat/components/chat-controls.ts @@ -53,7 +53,6 @@ type ChatControlsProps = { options?: { trigger?: HTMLElement | null; restoreFocus?: boolean }, ) => void; onToggleCronSessions?: () => void; - onOpenSplitView?: () => void; }; function chatAutoScrollLabel(mode: ChatAutoScrollMode) { @@ -371,19 +370,5 @@ export function renderChatControls(props: ChatControlsProps) { > ${renderChatModelControls(props.model)}
- ${props.onOpenSplitView - ? html` - - - - ` - : ""} `; } diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 83103218c815..2ad4bf8485e4 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -1135,12 +1135,6 @@ openclaw-chat-page { order: 3; } -/* openclaw-tooltip renders as display: contents, so this button participates - directly in the parent flex layout. */ -.agent-chat__composer-controls .chat-open-split-view { - order: 1; -} - .agent-chat__composer-run-status .agent-chat__run-status { max-width: min(240px, 30vw); } diff --git a/ui/src/styles/chat/split-view.css b/ui/src/styles/chat/split-view.css index e8573ab758aa..e5f47ac0ca3b 100644 --- a/ui/src/styles/chat/split-view.css +++ b/ui/src/styles/chat/split-view.css @@ -38,6 +38,10 @@ openclaw-chat-pane { overflow: hidden; } +.chat-split-view__pane--active { + box-shadow: inset 0 2px 0 var(--accent); +} + .chat-split-view--narrow .chat-split-view__pane { min-height: 0; } @@ -107,21 +111,39 @@ openclaw-chat-pane { } } -.chat-pane__header { +.chat-split-toolbar { + position: fixed; + top: var(--safe-area-top); + right: calc(58px + var(--safe-area-right)); + left: calc(var(--shell-nav-width) + 12px + var(--safe-area-left)); + z-index: 41; display: flex; align-items: center; - gap: 8px; - flex: 0 0 auto; - min-width: 0; - min-height: 36px; - padding: 4px 6px 4px 10px; - border-bottom: 1px solid var(--border); - background: var(--panel); + height: var(--shell-topbar-height); } -.chat-pane__header.chat-pane--active { - border-top: 2px solid var(--accent); - padding-top: 2px; +.chat-split-toolbar__pane { + display: flex; + flex: 1 1 0; + align-items: center; + gap: 8px; + min-width: 0; + height: 100%; + padding: 0 4px 0 10px; +} + +.chat-split-toolbar__pane + .chat-split-toolbar__pane { + border-left: 1px solid color-mix(in srgb, var(--border) 74%, transparent); +} + +.chat-split-toolbar__pane--active { + box-shadow: inset 0 2px 0 var(--accent); +} + +/* Pane controls replace the breadcrumb hierarchy in split mode, while the + shell keeps ownership of the one shared toolbar row and its global action. */ +.shell--chat:has(.chat-split-toolbar) .topnav-shell__content { + visibility: hidden; } .chat-pane__session-label { @@ -155,6 +177,25 @@ openclaw-chat-pane { padding: 5px; } +.chat-open-split-view { + position: absolute; + right: 16px; + bottom: 16px; + z-index: 12; + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); + background: color-mix(in srgb, var(--panel) 92%, transparent); + box-shadow: var(--shadow-sm); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +@media (max-width: 1100px) { + .chat-split-toolbar { + right: calc(62px + var(--safe-area-right)); + left: calc(62px + var(--safe-area-left)); + } +} + @media (max-width: 1099px) { .chat-open-split-view { display: none;