mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
improve(ui): consolidate dashboard chat visibility into one Chat/Split/Dashboard toggle (#120487)
* improve(ui): consolidate dashboard chat visibility into one Chat/Split/Dashboard toggle The dashboard face spread chat visibility across three separate affordances: a two-option face toggle, an icon-only dock dropdown among the header actions, and a floating vertically-rotated "Show chat" button on the board edge that users could not discover. One concept, three controls. The header now renders a single segmented control - Chat | Split | Dashboard - where Split is dashboard plus docked chat and Dashboard is board-only (the former "Hide chat"). A compact caret attached to the pill picks the dock side (left/right/bottom) and only appears in Split mode when dock mutation is allowed; read-only sessions keep the plain two-option Chat | Dashboard toggle. The floating reopen button, the standalone dock menu button, and their CSS (including the mobile overrides) are deleted, so the header ends up with fewer buttons than before. Verified in the mock-gateway dev harness: mode mapping, caret dropdown, unhide-to-remembered-side, and hidden dock rendering board-only. * fix(ci): unexport board dock settings union and repair stale plugin-sdk baseline BoardVisibleChatDock lost its last external consumer when the board view switch consolidated dock controls; keep it file-local so the production unused-export scan stays clean. Also regenerate docs/.generated/plugin-sdk-api-baseline.sha256: the session-catalog module hash was stale on main (check fails on clean origin/main after the agent-attribution reverts), which blocks every PR's check-plugin-sdk-api-baseline job. * docs(web): describe the Chat/Split/Dashboard switch and dock-side picker
This commit is contained in:
committed by
GitHub
parent
90acd2bc90
commit
2d5ff4768c
@@ -58,10 +58,11 @@ Principles:
|
||||
- **Graduation:** agent calls `show_widget` in any chat → widget renders inline
|
||||
in the transcript exactly as today → hover shows **Pin to dashboard** → widget
|
||||
appears on the session's board. The agent can pass `pin: true` to do the same.
|
||||
- **Board view:** a session with a board gets a face toggle (Chat / Dashboard).
|
||||
Board view = tab strip (only when >1 tab) + fluid grid + docked chat pane.
|
||||
Chat dock is resizable, movable (left/right/bottom), and collapsible exactly
|
||||
like the sidebar. Per-tab dock state is remembered.
|
||||
- **Board view:** a session with a board gets a view switch (Chat / Split /
|
||||
Dashboard). Split = tab strip (only when >1 tab) + fluid grid + docked chat
|
||||
pane; Dashboard is the same without the chat. The chat dock is resizable and
|
||||
movable (left/right/bottom) via the switch's dock picker. Per-tab dock state
|
||||
is remembered.
|
||||
- **Drag:** user drags widgets; grid auto-compacts (widgets float up, neighbors
|
||||
reflow). Resize by handle snaps to size steps. No pixel placement — for
|
||||
anyone.
|
||||
|
||||
@@ -8,9 +8,10 @@ title: "Session Dashboards"
|
||||
|
||||
Every thread in the Control UI has two faces: the conversation you know, and a
|
||||
**dashboard** — a grid of live widgets your agent builds for you. A thread with
|
||||
no widgets is just chat. The moment a widget is pinned, a **Chat | Dashboard**
|
||||
toggle appears in the header, and the dashboard becomes the main surface with
|
||||
your chat docked beside it.
|
||||
no widgets is just chat. The moment a widget is pinned, a **Chat | Split |
|
||||
Dashboard** switch appears in the header: Chat is the conversation alone, Split
|
||||
shows the dashboard with your chat docked beside it, and Dashboard gives the
|
||||
board the whole surface.
|
||||
|
||||
There is nothing to set up and no separate app to configure: dashboards are a
|
||||
core feature, owned by the thread, stored with the agent, and they survive
|
||||
@@ -59,9 +60,10 @@ never needs the agent.
|
||||
- **Tabs.** A board can have several pages — say, an overview tab and a
|
||||
focused tab with one big widget. Each tab remembers its own chat-dock
|
||||
position.
|
||||
- **Docked chat.** On the dashboard face, your conversation docks to the
|
||||
left, right, or bottom, resizes like the sidebar, and can be hidden
|
||||
entirely — the agent still hears you when you bring it back.
|
||||
- **Docked chat.** In Split, your conversation docks to the left, right, or
|
||||
bottom — pick the side from the small arrow on the header switch — and
|
||||
resizes like the sidebar. Choose Dashboard to hide the chat entirely; the
|
||||
agent still hears you when you bring it back.
|
||||
- **Agent parity.** Everything you can do, the agent can do with its
|
||||
`dashboard` tool: add, update, move, resize, and remove widgets, manage
|
||||
tabs, switch the visible tab, and move or hide the chat dock. Ask "put the
|
||||
|
||||
@@ -4465,14 +4465,13 @@ export const en: TranslationMap = {
|
||||
board: {
|
||||
faceLabel: "Thread face",
|
||||
chatFace: "Chat",
|
||||
splitFace: "Split",
|
||||
dashboardFace: "Dashboard",
|
||||
dockMenu: "Chat dock: {dock}",
|
||||
dockLeft: "Dock chat left",
|
||||
dockRight: "Dock chat right",
|
||||
dockBottom: "Dock chat bottom",
|
||||
dockHidden: "Hide chat",
|
||||
resizeDock: "Resize chat dock",
|
||||
reopenChat: "Show chat",
|
||||
workboardCard: "Workboard card: {title}, {status}",
|
||||
defaultTab: "Main",
|
||||
mockPlaceholder: "Board view seam · {tabs} tabs · {widgets} widgets",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { SessionBoardFace } from "../../../../src/shared/session-types.js";
|
||||
|
||||
export type BoardFace = SessionBoardFace;
|
||||
export type BoardVisibleChatDock = "bottom" | "left" | "right";
|
||||
// Persisted-settings union; the render-layer equivalent is VisibleBoardDock
|
||||
// (chat-pane-shared.ts), derived from the protocol BoardTab shape.
|
||||
type BoardVisibleChatDock = "bottom" | "left" | "right";
|
||||
|
||||
export type BoardSessionView = {
|
||||
activeTabId?: string;
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
import { html, render } from "lit";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { boardProviderForSession } from "../../lib/board/provider.ts";
|
||||
import type { BoardTab } from "../../lib/board/types.ts";
|
||||
import { installBrowserHistoryIsolation } from "../../test-helpers/browser-history.ts";
|
||||
import { renderBoardFaceToggle, renderBoardSessionSurface } from "./board-session-surface.ts";
|
||||
import { renderBoardSessionSurface, renderBoardViewSwitch } from "./board-session-surface.ts";
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
@@ -41,7 +40,6 @@ describe("board session shell", () => {
|
||||
snapshot: provider.snapshot$.value,
|
||||
activeTabId: "main",
|
||||
dock: "right" as const,
|
||||
reopenDock: "right" as const,
|
||||
dockSize: { height: 300 },
|
||||
chat: html`<div>chat</div>`,
|
||||
divider: html`<div></div>`,
|
||||
@@ -53,7 +51,6 @@ describe("board session shell", () => {
|
||||
selectTab: () => {},
|
||||
},
|
||||
widgetFrameUrl: (name: string, revision: number) => provider.widgetFrameUrl(name, revision),
|
||||
onDockChange: () => {},
|
||||
};
|
||||
|
||||
render(
|
||||
@@ -77,35 +74,142 @@ describe("board session shell", () => {
|
||||
expect(unlinked.querySelector("openclaw-workboard-card-chip")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the face toggle only when a board exists", () => {
|
||||
const withoutBoard = createContainer();
|
||||
const withBoard = createContainer();
|
||||
|
||||
it("renders nothing without a board", () => {
|
||||
const container = createContainer();
|
||||
render(
|
||||
renderBoardFaceToggle(false, "chat", () => {}),
|
||||
withoutBoard,
|
||||
);
|
||||
render(
|
||||
renderBoardFaceToggle(true, "chat", () => {}),
|
||||
withBoard,
|
||||
renderBoardViewSwitch({
|
||||
hasBoard: false,
|
||||
face: "chat",
|
||||
dock: "right",
|
||||
canChangeDock: true,
|
||||
onSelectMode: () => {},
|
||||
onDockSideChange: () => {},
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(withoutBoard.querySelector("wa-radio-group")).toBeNull();
|
||||
expect(withBoard.querySelectorAll("wa-radio")).toHaveLength(2);
|
||||
expect(container.querySelector(".chat-pane__face-switch")).toBeNull();
|
||||
expect(container.querySelector("wa-radio-group")).toBeNull();
|
||||
expect(container.querySelector("wa-dropdown")).toBeNull();
|
||||
});
|
||||
|
||||
it("supports keyboard face selection", () => {
|
||||
it.each([
|
||||
["chat", "left", "chat"],
|
||||
["chat", "right", "chat"],
|
||||
["chat", "bottom", "chat"],
|
||||
["chat", "hidden", "chat"],
|
||||
["dashboard", "left", "split"],
|
||||
["dashboard", "right", "split"],
|
||||
["dashboard", "bottom", "split"],
|
||||
["dashboard", "hidden", "dashboard"],
|
||||
] as const)("maps %s with a %s dock to the %s mode", (face, dock, activeMode) => {
|
||||
const container = createContainer();
|
||||
const onChange = vi.fn();
|
||||
render(renderBoardFaceToggle(true, "chat", onChange), container);
|
||||
render(
|
||||
renderBoardViewSwitch({
|
||||
hasBoard: true,
|
||||
face,
|
||||
dock,
|
||||
canChangeDock: true,
|
||||
onSelectMode: () => {},
|
||||
onDockSideChange: () => {},
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(
|
||||
[...container.querySelectorAll("wa-radio")].map((radio) => radio.getAttribute("value")),
|
||||
).toEqual(["chat", "split", "dashboard"]);
|
||||
expect(
|
||||
container.querySelector("wa-radio.settings-segmented__btn--active")?.getAttribute("value"),
|
||||
).toBe(activeMode);
|
||||
});
|
||||
|
||||
it("falls back to the two face options when dock changes are unavailable", () => {
|
||||
const container = createContainer();
|
||||
render(
|
||||
renderBoardViewSwitch({
|
||||
hasBoard: true,
|
||||
face: "dashboard",
|
||||
dock: "right",
|
||||
canChangeDock: false,
|
||||
onSelectMode: () => {},
|
||||
onDockSideChange: () => {},
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(
|
||||
[...container.querySelectorAll("wa-radio")].map((radio) => radio.getAttribute("value")),
|
||||
).toEqual(["chat", "dashboard"]);
|
||||
expect(
|
||||
container.querySelector("wa-radio.settings-segmented__btn--active")?.getAttribute("value"),
|
||||
).toBe("dashboard");
|
||||
expect(container.querySelector("wa-dropdown")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["chat", "right", true, false],
|
||||
["dashboard", "right", true, true],
|
||||
["dashboard", "hidden", true, false],
|
||||
["dashboard", "right", false, false],
|
||||
] as const)(
|
||||
"shows the dock caret for face=%s dock=%s canChangeDock=%s: %s",
|
||||
(face, dock, canChangeDock, hasCaret) => {
|
||||
const container = createContainer();
|
||||
const onDockSideChange = vi.fn();
|
||||
render(
|
||||
renderBoardViewSwitch({
|
||||
hasBoard: true,
|
||||
face,
|
||||
dock,
|
||||
canChangeDock,
|
||||
onSelectMode: () => {},
|
||||
onDockSideChange,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const dropdown = container.querySelector("wa-dropdown.chat-pane__dock-caret");
|
||||
expect(dropdown !== null).toBe(hasCaret);
|
||||
if (!dropdown) {
|
||||
return;
|
||||
}
|
||||
const items = [...dropdown.querySelectorAll("wa-dropdown-item")];
|
||||
expect(items.map((item) => item.getAttribute("value"))).toEqual(["left", "right", "bottom"]);
|
||||
expect(items.find((item) => item.hasAttribute("checked"))?.getAttribute("value")).toBe(
|
||||
"right",
|
||||
);
|
||||
const left = dropdown.querySelector('wa-dropdown-item[value="left"]');
|
||||
Reflect.set(left ?? {}, "value", "left");
|
||||
dropdown.dispatchEvent(
|
||||
new CustomEvent("wa-select", { bubbles: true, detail: { item: left } }),
|
||||
);
|
||||
expect(onDockSideChange).toHaveBeenCalledWith("left");
|
||||
},
|
||||
);
|
||||
|
||||
it("emits the selected view mode", () => {
|
||||
const container = createContainer();
|
||||
const onSelectMode = vi.fn();
|
||||
render(
|
||||
renderBoardViewSwitch({
|
||||
hasBoard: true,
|
||||
face: "chat",
|
||||
dock: "right",
|
||||
canChangeDock: true,
|
||||
onSelectMode,
|
||||
onDockSideChange: () => {},
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const group = container.querySelector<HTMLElement & { value: string }>("wa-radio-group");
|
||||
if (group) {
|
||||
group.value = "dashboard";
|
||||
group.value = "split";
|
||||
group.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("dashboard");
|
||||
expect(onSelectMode).toHaveBeenCalledWith("split");
|
||||
});
|
||||
|
||||
it.each(["left", "right", "bottom"] as const)("lays out the %s dock", (dock) => {
|
||||
@@ -116,7 +220,6 @@ describe("board session shell", () => {
|
||||
snapshot: provider.snapshot$.value,
|
||||
activeTabId: "main",
|
||||
dock,
|
||||
reopenDock: "right",
|
||||
dockSize: { height: 300 },
|
||||
chat: html`<div data-test-chat>chat</div>`,
|
||||
divider: html`<div class="board-session-surface__divider" data-test-divider></div>`,
|
||||
@@ -128,7 +231,6 @@ describe("board session shell", () => {
|
||||
selectTab: () => {},
|
||||
},
|
||||
widgetFrameUrl: (name, revision) => provider.widgetFrameUrl(name, revision),
|
||||
onDockChange: () => {},
|
||||
}),
|
||||
container,
|
||||
);
|
||||
@@ -139,16 +241,14 @@ describe("board session shell", () => {
|
||||
expect(container.querySelector("openclaw-board-view")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("collapses hidden chat to a reopen affordance", () => {
|
||||
it("renders the hidden dock as board-only", () => {
|
||||
const container = createContainer();
|
||||
const provider = boardProviderForSession("agent:main:main");
|
||||
const onDockChange = vi.fn<(dock: BoardTab["chatDock"]) => void>();
|
||||
render(
|
||||
renderBoardSessionSurface({
|
||||
snapshot: provider.snapshot$.value,
|
||||
activeTabId: "main",
|
||||
dock: "hidden",
|
||||
reopenDock: "left",
|
||||
dockSize: { height: 300 },
|
||||
chat: html`<div data-test-chat>chat</div>`,
|
||||
divider: html`<div class="board-session-surface__divider"></div>`,
|
||||
@@ -160,16 +260,13 @@ describe("board session shell", () => {
|
||||
selectTab: () => {},
|
||||
},
|
||||
widgetFrameUrl: (name, revision) => provider.widgetFrameUrl(name, revision),
|
||||
onDockChange,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector("[data-test-chat]")).toBeNull();
|
||||
expect(container.querySelector(".board-session-surface--dock-hidden")).not.toBeNull();
|
||||
const reopen = container.querySelector<HTMLButtonElement>(".board-session-surface__reopen");
|
||||
reopen?.click();
|
||||
expect(onDockChange).toHaveBeenCalledWith("left");
|
||||
expect(container.querySelector("openclaw-board-view")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("preserves the board while the bottom chat mounts only for that dock", () => {
|
||||
@@ -178,7 +275,6 @@ describe("board session shell", () => {
|
||||
const props = {
|
||||
snapshot: provider.snapshot$.value,
|
||||
activeTabId: "main",
|
||||
reopenDock: "left" as const,
|
||||
dockSize: { height: 300 },
|
||||
chat: html`<div data-test-chat>chat</div>`,
|
||||
divider: html`<div class="board-session-surface__divider"></div>`,
|
||||
@@ -190,7 +286,6 @@ describe("board session shell", () => {
|
||||
selectTab: () => {},
|
||||
},
|
||||
widgetFrameUrl: (name: string, revision: number) => provider.widgetFrameUrl(name, revision),
|
||||
onDockChange: () => {},
|
||||
};
|
||||
|
||||
render(renderBoardSessionSurface({ ...props, dock: "right" }), container);
|
||||
|
||||
@@ -5,13 +5,14 @@ import { icons } from "../../components/icons.ts";
|
||||
import { renderSettingsSegmented } from "../../components/settings-ui.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { isMockBoardEnabled, type BoardViewCallbacks } from "../../lib/board/provider.ts";
|
||||
import type { BoardFace, BoardVisibleChatDock } from "../../lib/board/settings.ts";
|
||||
import type { BoardFace } from "../../lib/board/settings.ts";
|
||||
import type { BoardTab } from "../../lib/board/types.ts";
|
||||
import type {
|
||||
BoardObserverContext,
|
||||
BoardViewSnapshot,
|
||||
BoardWidgetFrameUrl,
|
||||
} from "../../lib/board/view-types.ts";
|
||||
import type { VisibleBoardDock } from "./chat-pane-shared.ts";
|
||||
|
||||
export type BoardChatDockSize = {
|
||||
height: number;
|
||||
@@ -28,7 +29,6 @@ type BoardSessionSurfaceProps = {
|
||||
observer?: BoardObserverContext;
|
||||
activeTabId: string;
|
||||
dock: BoardTab["chatDock"];
|
||||
reopenDock: BoardVisibleChatDock;
|
||||
dockSize: BoardChatDockSize;
|
||||
chat: TemplateResult;
|
||||
divider: TemplateResult;
|
||||
@@ -37,7 +37,6 @@ type BoardSessionSurfaceProps = {
|
||||
callbacks: BoardViewCallbacks;
|
||||
widgetFrameUrl: BoardWidgetFrameUrl;
|
||||
workboardCardChip?: WorkboardCardChipProps | null;
|
||||
onDockChange: (dock: BoardTab["chatDock"]) => void;
|
||||
};
|
||||
|
||||
let boardViewLoad: Promise<unknown> | null = null;
|
||||
@@ -60,93 +59,94 @@ export async function ensureBoardViewElement(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function renderBoardFaceToggle(
|
||||
hasBoard: boolean,
|
||||
face: BoardFace,
|
||||
onChange: (face: BoardFace) => void,
|
||||
) {
|
||||
if (!hasBoard) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<div class="chat-pane__face-switch">
|
||||
${renderSettingsSegmented<BoardFace>({
|
||||
value: face,
|
||||
ariaLabel: t("chat.board.faceLabel"),
|
||||
options: [
|
||||
{ value: "chat", label: t("chat.board.chatFace") },
|
||||
{ value: "dashboard", label: t("chat.board.dashboardFace") },
|
||||
],
|
||||
onChange: (value) => onChange(value),
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
type BoardViewMode = "chat" | "split" | "dashboard";
|
||||
|
||||
function dockIcon(dock: BoardTab["chatDock"]) {
|
||||
if (dock === "left") {
|
||||
return icons.panelLeftOpen;
|
||||
}
|
||||
if (dock === "bottom") {
|
||||
return icons.panelBottomOpen;
|
||||
}
|
||||
if (dock === "hidden") {
|
||||
return icons.eyeOff;
|
||||
}
|
||||
return icons.panelRightOpen;
|
||||
}
|
||||
|
||||
function dockLabel(dock: BoardTab["chatDock"]): string {
|
||||
function dockLabel(dock: VisibleBoardDock): string {
|
||||
if (dock === "left") {
|
||||
return t("chat.board.dockLeft");
|
||||
}
|
||||
if (dock === "bottom") {
|
||||
return t("chat.board.dockBottom");
|
||||
}
|
||||
if (dock === "hidden") {
|
||||
return t("chat.board.dockHidden");
|
||||
}
|
||||
return t("chat.board.dockRight");
|
||||
}
|
||||
|
||||
export function renderBoardDockMenu(
|
||||
hasBoard: boolean,
|
||||
face: BoardFace,
|
||||
dock: BoardTab["chatDock"],
|
||||
onChange: (dock: BoardTab["chatDock"]) => void,
|
||||
) {
|
||||
if (!hasBoard || face !== "dashboard") {
|
||||
export function renderBoardViewSwitch(props: {
|
||||
hasBoard: boolean;
|
||||
face: BoardFace;
|
||||
dock: BoardTab["chatDock"];
|
||||
canChangeDock: boolean;
|
||||
onSelectMode: (mode: BoardViewMode) => void;
|
||||
onDockSideChange: (dock: VisibleBoardDock) => void;
|
||||
}) {
|
||||
if (!props.hasBoard) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const mode: BoardViewMode =
|
||||
props.face === "chat" ? "chat" : props.dock === "hidden" ? "dashboard" : "split";
|
||||
const segmented = props.canChangeDock
|
||||
? renderSettingsSegmented<BoardViewMode>({
|
||||
value: mode,
|
||||
ariaLabel: t("chat.board.faceLabel"),
|
||||
options: [
|
||||
{ value: "chat", label: t("chat.board.chatFace") },
|
||||
{ value: "split", label: t("chat.board.splitFace") },
|
||||
{ value: "dashboard", label: t("chat.board.dashboardFace") },
|
||||
],
|
||||
onChange: (value) => props.onSelectMode(value),
|
||||
})
|
||||
: renderSettingsSegmented<BoardFace>({
|
||||
value: props.face,
|
||||
ariaLabel: t("chat.board.faceLabel"),
|
||||
options: [
|
||||
{ value: "chat", label: t("chat.board.chatFace") },
|
||||
{ value: "dashboard", label: t("chat.board.dashboardFace") },
|
||||
],
|
||||
onChange: (value) => props.onSelectMode(value),
|
||||
});
|
||||
const visibleDock = props.dock === "hidden" ? null : props.dock;
|
||||
const showDockCaret = mode === "split" && props.canChangeDock && visibleDock !== null;
|
||||
|
||||
return html`
|
||||
<wa-dropdown
|
||||
class="chat-pane__board-dock-menu"
|
||||
placement="bottom-end"
|
||||
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
|
||||
const value = event.detail.item.value;
|
||||
if (value === "left" || value === "right" || value === "bottom" || value === "hidden") {
|
||||
onChange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
slot="trigger"
|
||||
type="button"
|
||||
class="btn btn--ghost btn--icon chat-icon-btn"
|
||||
data-board-dock-menu
|
||||
title=${dockLabel(dock)}
|
||||
aria-label=${t("chat.board.dockMenu", { dock: dockLabel(dock) })}
|
||||
>
|
||||
${dockIcon(dock)}
|
||||
</button>
|
||||
${(["left", "right", "bottom", "hidden"] as const).map(
|
||||
(candidate) => html`
|
||||
<wa-dropdown-item value=${candidate} type="checkbox" ?checked=${candidate === dock}>
|
||||
${dockLabel(candidate)}
|
||||
</wa-dropdown-item>
|
||||
`,
|
||||
)}
|
||||
</wa-dropdown>
|
||||
<div class="chat-pane__face-switch ${showDockCaret ? "chat-pane__face-switch--split" : ""}">
|
||||
${segmented}
|
||||
${showDockCaret && visibleDock
|
||||
? html`
|
||||
<wa-dropdown
|
||||
class="chat-pane__dock-caret"
|
||||
placement="bottom-end"
|
||||
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
|
||||
const value = event.detail.item.value;
|
||||
if (value === "left" || value === "right" || value === "bottom") {
|
||||
props.onDockSideChange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
slot="trigger"
|
||||
type="button"
|
||||
class="btn btn--ghost btn--icon chat-icon-btn chat-pane__dock-caret-trigger"
|
||||
title=${dockLabel(visibleDock)}
|
||||
aria-label=${t("chat.board.dockMenu", { dock: dockLabel(visibleDock) })}
|
||||
>
|
||||
${icons.chevronDown}
|
||||
</button>
|
||||
${(["left", "right", "bottom"] as const).map(
|
||||
(candidate) => html`
|
||||
<wa-dropdown-item
|
||||
value=${candidate}
|
||||
type="checkbox"
|
||||
?checked=${candidate === visibleDock}
|
||||
>
|
||||
${dockLabel(candidate)}
|
||||
</wa-dropdown-item>
|
||||
`,
|
||||
)}
|
||||
</wa-dropdown>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -186,17 +186,6 @@ export function renderBoardSessionSurface(props: BoardSessionSurfaceProps) {
|
||||
<div class="board-session-surface board-session-surface--dock-${props.dock}">
|
||||
${renderBoardView(props)}
|
||||
${props.dock === "bottom" ? html`${props.divider}${renderChatDock(props)}` : nothing}
|
||||
<button
|
||||
type="button"
|
||||
class="board-session-surface__reopen board-session-surface__reopen--${props.reopenDock}"
|
||||
aria-label=${t("chat.board.reopenChat")}
|
||||
title=${t("chat.board.reopenChat")}
|
||||
?hidden=${props.dock !== "hidden"}
|
||||
?disabled=${!props.canMutate}
|
||||
@click=${() => props.onDockChange(props.reopenDock)}
|
||||
>
|
||||
${icons.messageSquare}<span>${t("chat.board.chatFace")}</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { copyToClipboard } from "../../lib/clipboard.ts";
|
||||
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
|
||||
import { readSessionMethodAccess } from "../../lib/session-method-access.ts";
|
||||
import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts";
|
||||
import { renderBoardDockMenu, renderBoardFaceToggle } from "./board-session-surface.ts";
|
||||
import { renderBoardViewSwitch } from "./board-session-surface.ts";
|
||||
import { ChatPaneContext } from "./chat-pane-context.ts";
|
||||
import { headerPlatformByClient } from "./chat-pane-shared.ts";
|
||||
import { readChatSessionActionAccess } from "./chat-session-action-access.ts";
|
||||
@@ -60,6 +60,8 @@ export abstract class ChatPaneHeader extends ChatPaneContext {
|
||||
workspaceGit: boolean,
|
||||
) {
|
||||
const board = this.resolveBoardView();
|
||||
const canChangeBoardDock =
|
||||
board.hasBoard && !board.activeTabReadOnly && board.provider.canMutate;
|
||||
const workspace = resolveChatPaneWorkspace({
|
||||
session: row,
|
||||
agentWorkspace: row?.worktree ? undefined : agentWorkspace,
|
||||
@@ -193,9 +195,35 @@ export abstract class ChatPaneHeader extends ChatPaneContext {
|
||||
variant="session"
|
||||
></openclaw-viewer-facepile>`
|
||||
: nothing,
|
||||
faceControl: renderBoardFaceToggle(board.hasBoard, board.face, (face) => {
|
||||
this.syncChatSidebarForDock(face === "dashboard" ? board.dock : "hidden");
|
||||
this.persistBoardSessionView({ face });
|
||||
faceControl: renderBoardViewSwitch({
|
||||
hasBoard: board.hasBoard,
|
||||
face: board.face,
|
||||
dock: board.dock,
|
||||
canChangeDock: canChangeBoardDock,
|
||||
onSelectMode: (mode) => {
|
||||
if (!canChangeBoardDock) {
|
||||
const face = mode === "chat" ? "chat" : "dashboard";
|
||||
this.syncChatSidebarForDock(face === "dashboard" ? board.dock : "hidden");
|
||||
this.persistBoardSessionView({ face });
|
||||
return;
|
||||
}
|
||||
if (mode === "chat") {
|
||||
this.syncChatSidebarForDock("hidden");
|
||||
this.persistBoardSessionView({ face: "chat" });
|
||||
return;
|
||||
}
|
||||
this.persistBoardSessionView({ face: "dashboard" });
|
||||
if (mode === "split") {
|
||||
if (board.dock === "hidden") {
|
||||
this.handleBoardDockChange(board.reopenDock);
|
||||
} else {
|
||||
this.syncChatSidebarForDock(board.dock);
|
||||
}
|
||||
} else if (board.dock !== "hidden") {
|
||||
this.handleBoardDockChange("hidden");
|
||||
}
|
||||
},
|
||||
onDockSideChange: (dock) => this.handleBoardDockChange(dock),
|
||||
}),
|
||||
sharingControl: sharingMethodsSupported
|
||||
? renderChatSessionSharing({
|
||||
@@ -222,12 +250,6 @@ export abstract class ChatPaneHeader extends ChatPaneContext {
|
||||
row && void this.setSessionMember(row, identityId, member),
|
||||
})
|
||||
: nothing,
|
||||
boardDockAction: renderBoardDockMenu(
|
||||
board.hasBoard && !board.activeTabReadOnly && board.provider.canMutate,
|
||||
board.face,
|
||||
board.dock,
|
||||
(dock) => this.handleBoardDockChange(dock),
|
||||
),
|
||||
nativeGateways: this.nativeGateways,
|
||||
gatewaysSnapshot: this.gatewaysSnapshot,
|
||||
onboarding: this.onboarding,
|
||||
|
||||
@@ -581,7 +581,6 @@ export class ChatPane extends ChatPaneHeader {
|
||||
},
|
||||
activeTabId: board.activeTabId,
|
||||
dock: board.dock,
|
||||
reopenDock: board.reopenDock,
|
||||
dockSize: this.boardChatDockSize,
|
||||
chat,
|
||||
divider: this.renderBoardDivider("bottom"),
|
||||
@@ -601,7 +600,6 @@ export class ChatPane extends ChatPaneHeader {
|
||||
} satisfies BoardViewCallbacks,
|
||||
widgetFrameUrl: (name, revision) => board.provider.widgetFrameUrl(name, revision),
|
||||
workboardCardChip,
|
||||
onDockChange: (dock) => this.handleBoardDockChange(dock),
|
||||
})
|
||||
: chat;
|
||||
const discussion = this.buildSessionDiscussionPanel(state, state.sessionKey.trim());
|
||||
|
||||
@@ -52,7 +52,6 @@ type ChatPaneHeaderProps = {
|
||||
presence?: TemplateResult | typeof nothing;
|
||||
faceControl?: TemplateResult | typeof nothing;
|
||||
sharingControl?: TemplateResult | typeof nothing;
|
||||
boardDockAction?: TemplateResult | typeof nothing;
|
||||
nativeGateways?: NativeGatewaysCapability | null;
|
||||
gatewaysSnapshot?: NativeGatewaysSnapshot | null;
|
||||
onboarding?: boolean;
|
||||
@@ -432,7 +431,7 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) {
|
||||
: nothing}
|
||||
${renderGatewayPicker(props)}
|
||||
<div class="chat-pane__actions">
|
||||
${props.boardDockAction ?? nothing} ${props.terminalAction} ${props.discussionAction}
|
||||
${props.terminalAction} ${props.discussionAction}
|
||||
${props.catalog
|
||||
? nothing
|
||||
: html`${props.diffAction} ${props.backgroundTasksAction} ${props.workspaceAction}`}
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
border-radius: var(--radius-full);
|
||||
background: color-mix(in srgb, var(--panel-strong) 76%, transparent);
|
||||
}
|
||||
|
||||
.chat-pane__face-switch .settings-segmented {
|
||||
flex-wrap: nowrap;
|
||||
border-radius: var(--radius-full);
|
||||
background: color-mix(in srgb, var(--panel-strong) 76%, transparent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-pane__face-switch--split .settings-segmented {
|
||||
border-radius: var(--radius-full) 0 0 var(--radius-full);
|
||||
}
|
||||
|
||||
.chat-pane__face-switch .settings-segmented__btn {
|
||||
@@ -25,12 +31,21 @@
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.chat-pane__board-dock-menu {
|
||||
.chat-pane__dock-caret {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.chat-pane__board-dock-menu svg,
|
||||
.board-session-surface__reopen svg {
|
||||
.chat-pane__dock-caret-trigger {
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
min-height: 24px;
|
||||
padding: 0;
|
||||
border-left: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
|
||||
border-radius: 0 var(--radius-full) var(--radius-full) 0;
|
||||
}
|
||||
|
||||
.chat-pane__dock-caret svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
fill: none;
|
||||
@@ -163,66 +178,6 @@ openclaw-workboard-card-chip {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.board-session-surface__reopen[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.board-session-surface__reopen {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 34px;
|
||||
min-height: 34px;
|
||||
padding: 7px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--muted);
|
||||
background: color-mix(in srgb, var(--panel-strong) 92%, transparent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.board-session-surface__reopen:hover,
|
||||
.board-session-surface__reopen:focus-visible {
|
||||
color: var(--text);
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
|
||||
}
|
||||
|
||||
.board-session-surface__reopen:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.board-session-surface__reopen span {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
.board-session-surface__reopen--left {
|
||||
top: 50%;
|
||||
left: 8px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.board-session-surface__reopen--right {
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.board-session-surface__reopen--bottom {
|
||||
right: 12px;
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
.board-session-surface__reopen--bottom span {
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.chat-pane__face-switch .settings-segmented__btn {
|
||||
padding-inline: 7px;
|
||||
|
||||
@@ -148,18 +148,6 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-
|
||||
box-shadow: 0 -12px 32px color-mix(in srgb, black 16%, transparent);
|
||||
}
|
||||
|
||||
.shell--mobile-nav .board-session-surface__reopen {
|
||||
top: auto;
|
||||
right: 12px;
|
||||
bottom: 8px;
|
||||
left: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.shell--mobile-nav .board-session-surface__reopen span {
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
|
||||
.shell--mobile-nav .sidebar-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user