mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
fix(ui): unify split-view controls in one toolbar row (#102492)
* fix(ui): unify split-view toolbar * chore: remove release-owned changelog entry
This commit is contained in:
parent
e7492c6fca
commit
ea4e005008
9 changed files with 359 additions and 170 deletions
|
|
@ -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"`.
|
||||
|
|
|
|||
|
|
@ -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 +
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<RenderedPane>("openclaw-chat-pane")];
|
||||
const dividers = page.querySelectorAll<ResizableDivider>("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<RenderedPane>("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<HTMLSelectElement>(".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" };
|
||||
|
|
|
|||
|
|
@ -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<HTMLSelectElement>(".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`
|
||||
<openclaw-chat-pane
|
||||
class="chat-split-view__pane"
|
||||
class="chat-split-view__pane ${active ? "chat-split-view__pane--active" : ""}"
|
||||
style="flex: ${weight} 1 0"
|
||||
.paneId=${pane.id}
|
||||
.sessionKey=${pane.sessionKey}
|
||||
.active=${active}
|
||||
.chrome=${"pane"}
|
||||
.draft=${active ? this.data?.draft : undefined}
|
||||
.onFocusPane=${this.handleFocusPane}
|
||||
.onPaneSessionChange=${this.handlePaneSessionChange}
|
||||
.onSplitRight=${canSplit ? this.handleSplitRight : undefined}
|
||||
.onSplitDown=${canSplit ? this.handleSplitDown : undefined}
|
||||
.onClosePane=${this.handleClosePane}
|
||||
></openclaw-chat-pane>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="chat-split-toolbar">
|
||||
${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`
|
||||
<div
|
||||
class="chat-split-toolbar__pane ${active ? "chat-split-toolbar__pane--active" : ""}"
|
||||
@pointerdown=${() => this.handleFocusPane(pane.id)}
|
||||
@focusin=${() => this.handleFocusPane(pane.id)}
|
||||
>
|
||||
<label class="chat-pane__session-label">
|
||||
<span class="agent-chat__sr-only">${t("chat.splitView.sessionSelect")}</span>
|
||||
<select
|
||||
class="chat-pane__session-select"
|
||||
data-session-key=${pane.sessionKey}
|
||||
aria-label=${t("chat.splitView.sessionSelect")}
|
||||
.value=${pane.sessionKey}
|
||||
@change=${(event: Event) => {
|
||||
const nextSessionKey = (event.target as HTMLSelectElement).value;
|
||||
if (nextSessionKey && nextSessionKey !== pane.sessionKey) {
|
||||
this.handlePaneSessionChange(pane.id, nextSessionKey);
|
||||
}
|
||||
}}
|
||||
>
|
||||
${options.map(
|
||||
(row) => html`
|
||||
<option value=${row.key}>
|
||||
${resolveSessionDisplayName(
|
||||
row.key,
|
||||
sessions.find((session) => session.key === row.key),
|
||||
)}
|
||||
</option>
|
||||
`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<div class="chat-pane__actions">
|
||||
${!this.narrow
|
||||
? html`
|
||||
<openclaw-tooltip .content=${t("chat.splitView.splitDown")}>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon"
|
||||
type="button"
|
||||
aria-label=${t("chat.splitView.splitDown")}
|
||||
@click=${() => this.handleSplitDown(pane.id)}
|
||||
>
|
||||
${icons.panelBottomOpen}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
<openclaw-tooltip .content=${t("chat.splitView.splitRight")}>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon"
|
||||
type="button"
|
||||
aria-label=${t("chat.splitView.splitRight")}
|
||||
@click=${() => this.handleSplitRight(pane.id)}
|
||||
>
|
||||
${icons.panelRightOpen}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
`
|
||||
: nothing}
|
||||
<openclaw-tooltip .content=${t("chat.splitView.closePane")}>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon"
|
||||
type="button"
|
||||
aria-label=${t("chat.splitView.closePane")}
|
||||
@click=${() => this.handleClosePane(pane.id)}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="chat-split-view__drop-container">
|
||||
${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}
|
||||
></openclaw-chat-pane>
|
||||
`}
|
||||
${!this.layout && !this.narrow
|
||||
? html`
|
||||
<openclaw-tooltip .content=${t("chat.splitView.open")}>
|
||||
<button
|
||||
class="btn btn--sm btn--icon chat-open-split-view"
|
||||
type="button"
|
||||
aria-label=${t("chat.splitView.open")}
|
||||
@click=${this.openSplitView}
|
||||
>
|
||||
${icons.panelRightOpen}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
`
|
||||
: nothing}
|
||||
${indicator
|
||||
? html`<div
|
||||
class="chat-split-view__drop-indicator ${indicator.zone.kind === "center"
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ import {
|
|||
COMMAND_PALETTE_TARGET_EVENT,
|
||||
type CommandPaletteTargetDetail,
|
||||
} from "../../components/command-palette.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import "../../components/tooltip.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
|
||||
import { resolveSessionDisplayName } from "../../lib/session-display.ts";
|
||||
|
|
@ -124,7 +122,6 @@ class ChatPane extends LitElement {
|
|||
// before route data resolves).
|
||||
@property({ attribute: false }) sessionKey = "";
|
||||
@property({ attribute: false }) active = false;
|
||||
@property({ attribute: false }) chrome: "none" | "pane" = "none";
|
||||
@property({ attribute: false }) draft?: string;
|
||||
@property({ attribute: false }) onFocusPane?: (paneId: string) => 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<ChatPageHost>(this);
|
||||
private state: ChatPageHost | undefined;
|
||||
|
|
@ -665,16 +658,6 @@ class ChatPane extends LitElement {
|
|||
}
|
||||
}
|
||||
|
||||
override updated() {
|
||||
// The header <select> options arrive after the sessions list loads; a
|
||||
// .value template binding committed before the options exist leaves the
|
||||
// browser on the first option, so re-sync after every render.
|
||||
const select = this.querySelector<HTMLSelectElement>(".chat-pane__session-select");
|
||||
if (select && this.state && select.value !== this.state.sessionKey) {
|
||||
select.value = this.state.sessionKey;
|
||||
}
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
this.nativeDraftCleanup?.();
|
||||
this.nativeDraftCleanup = null;
|
||||
|
|
@ -861,88 +844,6 @@ class ChatPane extends LitElement {
|
|||
state.requestUpdate?.();
|
||||
}
|
||||
|
||||
private renderPaneHeader(state: ChatPageHost) {
|
||||
if (this.chrome !== "pane") {
|
||||
return null;
|
||||
}
|
||||
const sessions = state.sessionsResult?.sessions ?? [];
|
||||
const currentSession = sessions.find((row) => row.key === state.sessionKey);
|
||||
const options = currentSession ? sessions : [{ key: state.sessionKey }, ...sessions];
|
||||
return html`
|
||||
<div class="chat-pane__header ${this.active ? "chat-pane--active" : ""}">
|
||||
<label class="chat-pane__session-label">
|
||||
<span class="agent-chat__sr-only">${t("chat.splitView.sessionSelect")}</span>
|
||||
<select
|
||||
class="chat-pane__session-select"
|
||||
aria-label=${t("chat.splitView.sessionSelect")}
|
||||
.value=${state.sessionKey}
|
||||
@change=${(event: Event) => {
|
||||
const nextSessionKey = (event.target as HTMLSelectElement).value;
|
||||
if (nextSessionKey && nextSessionKey !== state.sessionKey) {
|
||||
this.onPaneSessionChange?.(this.paneId, nextSessionKey);
|
||||
}
|
||||
}}
|
||||
>
|
||||
${options.map(
|
||||
(row) => html`
|
||||
<option value=${row.key}>
|
||||
${resolveSessionDisplayName(
|
||||
row.key,
|
||||
sessions.find((session) => session.key === row.key),
|
||||
)}
|
||||
</option>
|
||||
`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<div class="chat-pane__actions">
|
||||
${this.onSplitDown
|
||||
? html`
|
||||
<openclaw-tooltip .content=${t("chat.splitView.splitDown")}>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon"
|
||||
type="button"
|
||||
aria-label=${t("chat.splitView.splitDown")}
|
||||
@click=${() => this.onSplitDown?.(this.paneId)}
|
||||
>
|
||||
${icons.panelBottomOpen}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
`
|
||||
: null}
|
||||
${this.onSplitRight
|
||||
? html`
|
||||
<openclaw-tooltip .content=${t("chat.splitView.splitRight")}>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon"
|
||||
type="button"
|
||||
aria-label=${t("chat.splitView.splitRight")}
|
||||
@click=${() => this.onSplitRight?.(this.paneId)}
|
||||
>
|
||||
${icons.panelRightOpen}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
`
|
||||
: null}
|
||||
${this.onClosePane
|
||||
? html`
|
||||
<openclaw-tooltip .content=${t("chat.splitView.closePane")}>
|
||||
<button
|
||||
class="btn btn--ghost btn--icon"
|
||||
type="button"
|
||||
aria-label=${t("chat.splitView.closePane")}
|
||||
@click=${() => this.onClosePane?.(this.paneId)}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
</div>
|
||||
${props.onOpenSplitView
|
||||
? html`
|
||||
<openclaw-tooltip .content=${t("chat.splitView.open")}>
|
||||
<button
|
||||
class="btn btn--sm btn--icon chat-open-split-view"
|
||||
type="button"
|
||||
aria-label=${t("chat.splitView.open")}
|
||||
@click=${props.onOpenSplitView}
|
||||
>
|
||||
${icons.panelRightOpen}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue