feat(ui): restore sidebar chrome, delete the pane workspace strip, compact native narrow header (#103561)

* feat(ui): restore sidebar chrome and remove the pane workspace strip

Reverse course from #103426 on the app chrome: the left sidebar owns
brand, pinned navigation + More, New session, sessions, and the footer
(status dot, Settings, Docs, pairing, theme) again, and the desktop
topbar is gone. What stays from that PR: the dockable workspace rail
(right/bottom, drag or button), the in-flow split-pane headers, and
Cmd+B now hides the sidebar entirely (no 78px icon rail) with a
floating expand control.

The real target of the original request: the vertical icon strip at
each pane's right edge is deleted. A collapsed workspace rail renders
nothing; the toggle (with a changed-file badge) lives in the split-pane
header next to split/close, or floats at the top-right in single-pane
chat. Shift+Cmd+B still toggles.

Narrow native macOS windows (e.g. the in-app link browser splitting
the window) previously stacked 50px of injected titlebar padding on
top of the 58px drawer row; the web CSS now folds that into one
compact 58px row beside the traffic lights, with selectors that
outrank the rules shipped Mac apps inject.

* chore(ui): regenerate locale bundles and docs map for sidebar restore

* fix(ui): bind showPaneHeader explicitly on the classic single pane

* chore(ui): reconcile locale metadata after rebase onto lobster wild cards
This commit is contained in:
Peter Steinberger
2026-07-10 09:35:14 +01:00
committed by GitHub
parent ae63a48e94
commit 9ecb2f3a84
64 changed files with 738 additions and 1369 deletions
@@ -456,17 +456,18 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
topDragRegion.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 78),
topDragRegion.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -380),
topDragRegion.topAnchor.constraint(equalTo: container.topAnchor),
// Thin edge strip only: the web topbar's controls sit vertically
// centered in its 48px row, so an 8px strip stays clear of them
// while still offering a grab edge across the window.
topDragRegion.heightAnchor.constraint(equalToConstant: 8),
// Thin edge strip only: the web UI has no desktop topbar row, so a
// taller region would swallow clicks meant for the top of the
// content column (chat thread, page headers). The sidebar region
// below stays the primary drag surface it floats over the 50px
// strip the native chrome CSS reserves in the web sidebar. At
// narrow widths the compact drawer topbar keeps x 78-254 passive
// (its brand strip), so the region stays click-safe there too.
topDragRegion.heightAnchor.constraint(equalToConstant: 12),
topRightDragRegion.leadingAnchor.constraint(equalTo: topDragRegion.trailingAnchor),
topRightDragRegion.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8),
topRightDragRegion.topAnchor.constraint(equalTo: container.topAnchor),
topRightDragRegion.heightAnchor.constraint(equalToConstant: 6),
// Primary drag surface: floats over the web topbar's brand strip,
// which the Control UI keeps non-interactive and >=176px wide on
// native macOS (layout.css html.openclaw-native-macos rules).
sidebarDragRegion.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 78),
sidebarDragRegion.topAnchor.constraint(equalTo: container.topAnchor),
sidebarDragRegion.widthAnchor.constraint(equalToConstant: 176),
@@ -490,25 +491,20 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
}
private static func installNativeChromeScript(into userContentController: WKUserContentController) {
// Desktop widths need no rules here: the Control UI's own
// `html.openclaw-native-macos` styles inset the topbar past the
// traffic lights and keep the brand strip passive under the drag
// regions installed in makeWindow (layout.css).
// Narrow widths need no rules here: the Control UI's own
// `html.openclaw-native-macos` styles fold the titlebar clearance into
// the drawer topbar row (layout.mobile.css); their body-qualified
// !important selectors also outrank the rules older app builds inject.
let css = """
html.openclaw-native-macos {
--openclaw-native-titlebar-height: 50px;
}
@media (max-width: 1100px) {
/* The drawer topbar replaces the desktop bar below this breakpoint.
Move its controls below AppKit's traffic lights and drag overlay. */
html.openclaw-native-macos .shell {
--shell-topbar-height: calc(58px + var(--openclaw-native-titlebar-height));
}
html.openclaw-native-macos .topbar {
padding: var(--openclaw-native-titlebar-height) 12px 0 !important;
}
html.openclaw-native-macos .topnav-shell {
min-height: 58px;
@media (min-width: 700px) {
/* Both desktop navigation surfaces must clear AppKit's window controls
and drag regions or their first interactive row becomes unreachable. */
html.openclaw-native-macos .sidebar-shell,
html.openclaw-native-macos .settings-sidebar__header {
padding-top: max(14px, var(--openclaw-native-titlebar-height)) !important;
}
}
"""
@@ -313,7 +313,7 @@ struct DashboardWindowSmokeTests {
#expect(dashboardLogString(for: url) == "http://127.0.0.1:18789/control/")
}
@Test func `dashboard native chrome offsets the drawer topbar`() throws {
@Test func `dashboard native chrome clears both desktop sidebars`() throws {
let url = try #require(URL(string: "http://127.0.0.1:18789/control/"))
let controller = DashboardWindowController(
url: url,
@@ -322,13 +322,14 @@ struct DashboardWindowSmokeTests {
$0.source.contains("openclaw-native-macos-chrome")
})
// Desktop widths are styled by the Control UI's own
// html.openclaw-native-macos rules; only the drawer topbar needs
// Narrow widths are styled by the Control UI's own compact drawer-row
// rules (layout.mobile.css); only the desktop sidebar surfaces need
// native padding injected here.
#expect(chromeScript.source.contains(".topbar"))
#expect(chromeScript.source.contains("max-width: 1100px"))
#expect(chromeScript.source.contains(".sidebar-shell"))
#expect(chromeScript.source.contains(".settings-sidebar__header"))
#expect(chromeScript.source.contains("min-width: 700px"))
#expect(chromeScript.source.contains("--openclaw-native-titlebar-height"))
#expect(!chromeScript.source.contains(".sidebar-shell"))
#expect(!chromeScript.source.contains("max-width: 1100px"))
}
@Test func `dashboard titlebar hosts back and forward controls`() throws {
+1 -1
View File
@@ -9923,7 +9923,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Gateway host status
- H2: Language support
- H2: Appearance themes
- H2: Navigation
- H2: Sidebar navigation
- H2: What it can do (today)
- H2: MCP page
- H2: Activity tab
+6 -8
View File
@@ -115,15 +115,13 @@ Imported themes are stored only in the current browser profile; they are not wri
Appearance also has a browser-local Text size setting, stored with the rest of Control UI preferences. It applies to chat text, composer text, tool cards, and chat sidebars, and keeps text inputs at least 16px so mobile Safari does not auto-zoom on focus.
## Navigation
## Sidebar navigation
On desktop, a slim top bar is the app chrome: the OpenClaw mark (with a connection-status dot), navigation pills for **Chat** plus every pinned destination, a **More** menu with the remaining destinations, and global actions on the right — the command-palette **Search** pill (⌘K), mobile pairing, the light/dark/system color-mode toggle, and **Settings**. **Docs** and **Edit pinned items** live in the More menu; right-clicking the navigation pills also opens the pin editor. **Overview** is the only destination pinned by default, and the pinned set is stored in the current browser profile.
Below the top bar, the side panel is a scrollable session list split into **Pinned**, one section per custom group (the session `category`), and **Ungrouped** for the rest. Every active session loaded for the selected agent stays visible inline; opening a session moves the selection highlight without reordering the rows. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. Each session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Fork, Move to group (including New group and Remove from group), Archive, and Delete; touch layouts keep the direct pin and menu controls visible. Drag a session onto a custom group or **Ungrouped** to move it. Group headers can be collapsed, expanded, or dragged to reorder them; the collapsed state and custom order are stored in the current browser profile. Group headers also have a menu (kebab button or right-click) with Rename group, New group, and Delete group; renaming or deleting a group updates every member session, including archived ones, and deleting a group keeps its sessions and moves them back to Ungrouped. Groups created from the header start empty and stay visible as move targets. The sort control in the session list header also has a Group by toggle: Custom groups (default) or None for one flat list (Pinned stays separate); the choice is stored in the current browser profile. Multi-agent setups show a compact scope control in the session-list header.
The sidebar pins navigation above a scrollable session list split into **Pinned**, one section per custom group (the session `category`), and **Ungrouped** for the rest. Every active session loaded for the selected agent stays visible inline; opening a session moves the selection highlight without reordering the rows. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. Each session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Fork, Move to group (including New group and Remove from group), Archive, and Delete; touch layouts keep the direct pin and menu controls visible. Drag a session onto a custom group or **Ungrouped** to move it. Group headers can be collapsed, expanded, or dragged to reorder them; the collapsed state and custom order are stored in the current browser profile. Group headers also have a menu (kebab button or right-click) with Rename group, New group, and Delete group; renaming or deleting a group updates every member session, including archived ones, and deleting a group keeps its sessions and moves them back to Ungrouped. Groups created from the header start empty and stay visible as move targets. The sort control in the session list header also has a Group by toggle: Custom groups (default) or None for one flat list (Pinned stays separate); the choice is stored in the current browser profile. Multi-agent setups show a compact scope control in the session-list header. **Overview** is the only destination pinned by default; expand **More** to reach every other destination. Select **Edit pinned items** under More, or right-click the navigation area, to pin or unpin destinations and restore the defaults. The pinned set and More expansion state are stored in the current browser profile and survive reloads.
Inside **Settings**, the dedicated sidebar starts with a **Search settings** field for quickly finding settings sections.
The top bar's panel toggle (⌘B) hides the session panel entirely for a full-width workspace; navigation stays in the top bar. Narrow viewports swap the session panel for a slide-over drawer (holding brand, pinned and More navigation, sessions, and the utility footer) behind a compact header row with the drawer toggle, brand, and command-palette search. Navigation uses regular browser history, so the browser's back/forward buttons traverse it; the macOS app adds native back/forward buttons next to the window controls, plus trackpad swipe gestures.
A **Search** field at the top of the sidebar opens the command palette (⌘K). The compact footer keeps connection status, **Settings**, **Docs**, mobile pairing, and the light/dark/system color-mode toggle together. The sidebar header also holds the collapse toggle (⌘B); collapsing hides the sidebar entirely for a full-width workspace, and a floating expand control (or ⌘B) brings it back. The sidebar is the only navigation chrome on desktop, with no top bar. Narrow viewports swap the sidebar for a slide-over drawer behind a compact header row holding the drawer toggle, brand, and command-palette search; in the macOS app that header row folds the titlebar clearance into a single compact strip beside the window controls. Navigation uses regular browser history, so the browser's back/forward buttons traverse it; the macOS app adds native back/forward buttons next to the window controls, plus trackpad swipe gestures.
## What it can do (today)
@@ -247,14 +245,14 @@ The terminal is also available as a full-screen, terminal-only document at `/?vi
- The sidebar lists every loaded active session by pinned/custom/ungrouped section with a New Session action. Opening a visible row moves only the highlight. Custom groups are collapsible and drag-reorderable, and sessions can be dropped onto a group or Ungrouped; the browser preserves the group order and collapsed state across reloads. A new dashboard session asynchronously gets a concise generated title from its first non-command message; explicit names are never replaced. Set `agents.defaults.utilityModel` (or `agents.list[].utilityModel`) to route this separate model call to a lower-cost model. Switching the compact agent scope shows only sessions tied to that agent and falls back to that agent's main session when it has no saved dashboard sessions yet.
- Session search lives in the command palette (⌘K, or the Search field at the top of the sidebar): typing a query follows a bounded number of matching pages across agents, filters internal child/cron rows, and lists visible matches next to navigation commands. The Sessions page keeps the exhaustive searchable list with filters.
- Each sidebar row keeps direct pin access plus a full context menu for unread state, rename, fork, grouping, archive, and delete. An active run and an agent's main session cannot be archived. Archiving or deleting the currently selected session switches Chat back to that agent's main session.
- In the macOS app, the top bar's brand strip sits next to the native window controls and stays non-interactive so it doubles as the window drag area.
- In the macOS app, the OpenClaw mark uses the otherwise-empty native titlebar strip next to the window controls instead of consuming a sidebar row.
- 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 session workspace rail in each Chat pane lists session files, project files, and artifacts. It docks to the pane's right edge by default; drag its header (or use the dock button) to move it to the bottom, and the choice is stored in the current browser profile. Press ⇧⌘B to expand or collapse the active pane's rail; the collapsed strip keeps working expand, files (with a changed-file count), and terminal buttons. The separate file, tool, and Canvas detail panel is unaffected.
- The session workspace rail in each Chat pane lists session files, project files, and artifacts. It docks to the pane's right edge by default; drag its header (or use the dock button) to move it to the bottom, and the choice is stored in the current browser profile. A collapsed rail takes no space at all: reopen it with ⇧⌘B, the files toggle in the split-pane header, or the floating files button in single-pane chat (both carry a changed-file count badge). The separate file, tool, and Canvas detail panel is unaffected.
- 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 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. Each pane carries its own header row with the session title and split/close controls; dividers resize columns and stacked panes, and the browser stores the layout locally across reloads.
- The active split pane drives the sidebar selection and URL. Each pane carries its own header row with the session title plus workspace-rail, split, and close controls; 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, including its header with the close control.
- 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.
-11
View File
@@ -1,5 +1,4 @@
// Control UI app navigation defines sidebar and settings presentation metadata.
import type { GatewayControlUiPluginTab } from "./api/gateway.ts";
import type { RouteId } from "./app-route-paths.ts";
import type { IconName } from "./components/icons.ts";
import { t } from "./i18n/index.ts";
@@ -63,16 +62,6 @@ export function sidebarMoreRoutes(pinned: readonly SidebarNavRoute[]): SidebarNa
return SIDEBAR_NAV_ROUTES.filter((routeId) => !pinned.includes(routeId));
}
/** Dynamic plugin tabs stay in "More"; only stable static route ids can be
* persisted as pins. Grouped ordering keeps related tabs adjacent. */
export function orderedControlUiPluginTabs(
tabs: readonly GatewayControlUiPluginTab[],
): GatewayControlUiPluginTab[] {
return ["chat", "control", "agent", "settings"].flatMap((group) =>
tabs.filter((tab) => (tab.group ?? "control") === group),
);
}
type SettingsNavigationGroup = {
/** i18n key for the group heading; null renders the group without a label. */
labelKey: string | null;
+19 -25
View File
@@ -14,17 +14,14 @@ import "../components/resizable-divider.ts";
import "../components/terminal/terminal-panel.ts";
import "../components/tooltip.ts";
import "../components/update-banner.ts";
import {
isSettingsNavigationRoute,
orderedControlUiPluginTabs,
type SidebarNavRoute,
} from "../app-navigation.ts";
import { isSettingsNavigationRoute, type SidebarNavRoute } from "../app-navigation.ts";
import { APP_ROUTE_IDS, isRouteId, type RouteId } from "../app-routes.ts";
import {
COMMAND_PALETTE_TARGET_EVENT,
type CommandPalette,
type CommandPaletteTargetDetail,
} from "../components/command-palette.ts";
import { icons } from "../components/icons.ts";
import { renderSettingsSidebar } from "../components/settings-sidebar.ts";
import type { ThemeModeChangeDetail } from "../components/theme-mode-toggle.ts";
import { t } from "../i18n/index.ts";
@@ -822,28 +819,24 @@ class OpenClawShell extends OpenClawLightDomElement {
.searchDisabled=${false}
.navDrawerOpen=${navDrawerOpen}
.onboarding=${this.onboarding}
.activeRouteId=${activeRoute}
.activePluginTabId=${activePluginTabId}
.enabledRouteIds=${this.enabledRouteIds()}
.pinnedRoutes=${navigationSnapshot.sidebarPinnedRoutes}
.pluginTabs=${orderedControlUiPluginTabs(gatewaySnapshot.hello?.controlUiTabs ?? [])}
.sessionKey=${this.activeSessionKey}
.connected=${gatewaySnapshot.connected}
.canPairDevice=${gatewaySnapshot.connected &&
hasOperatorAdminAccess(gatewaySnapshot.hello?.auth ?? null)}
.themeMode=${context.theme.mode}
.navCollapsed=${navCollapsed}
.onOpenPalette=${this.openPalette}
.onToggleDrawer=${(trigger: HTMLElement) => this.toggleNavigationSurface(trigger)}
.onToggleSidebar=${() => this.toggleNavigationSurface()}
.onPairMobile=${() => void context.overlays.openDevicePairSetup()}
.onNavigate=${(routeId: string, options?: ApplicationNavigationOptions) =>
this.navigate(routeId, options)}
.onPreloadRoute=${(routeId: string) =>
isRouteId(routeId) ? context.preload(routeId) : Promise.resolve()}
.onUpdatePinnedRoutes=${(routes: SidebarNavRoute[]) =>
context.navigation.update({ sidebarPinnedRoutes: routes })}
></openclaw-app-topbar>
${navCollapsed && !this.onboarding
? html`
<openclaw-tooltip .content=${`${t("nav.expand")} (⌘B)`}>
<button
type="button"
class="shell-nav-expand"
aria-label=${t("nav.expand")}
aria-expanded="false"
@click=${() => this.toggleNavigationSurface()}
>
${icons.panelLeftOpen}
</button>
</openclaw-tooltip>
`
: nothing}
<div class="shell-nav">
${settingsTakeover
? renderSettingsSidebar({
@@ -868,7 +861,6 @@ class OpenClawShell extends OpenClawLightDomElement {
.activeRouteId=${activeRoute}
.activePluginTabId=${activePluginTabId}
.enabledRouteIds=${this.enabledRouteIds()}
.variant=${isMobileNavLayout() ? "drawer" : "panel"}
.sessionKey=${this.activeSessionKey}
.connected=${gatewaySnapshot.connected}
.canPairDevice=${gatewaySnapshot.connected &&
@@ -881,6 +873,8 @@ class OpenClawShell extends OpenClawLightDomElement {
.gatewayVersion=${context.config.current.serverVersion ??
gatewaySnapshot.hello?.server?.version ??
null}
.onOpenPalette=${this.openPalette}
.onToggleSidebar=${() => this.toggleNavigationSurface()}
.onToggleMore=${() =>
context.navigation.update({
sidebarMoreExpanded: !context.navigation.snapshot.sidebarMoreExpanded,
+44 -31
View File
@@ -9,7 +9,6 @@ import {
DEFAULT_SIDEBAR_PINNED_ROUTES,
isSettingsNavigationRoute,
navigationIconForRoute,
orderedControlUiPluginTabs,
scheduleRoutePreload,
type NavigationRouteId,
SIDEBAR_NAV_ROUTES,
@@ -115,6 +114,10 @@ const SIDEBAR_SESSION_GROUPING_STORAGE_KEY = "openclaw:sidebar:sessions:grouping
const SIDEBAR_SESSION_COLLAPSED_SECTIONS_STORAGE_KEY =
"openclaw:sidebar:sessions:collapsed-sections";
const PALETTE_SHORTCUT = /Mac|iP(hone|ad|od)/i.test(globalThis.navigator?.platform ?? "")
? "⌘K"
: "Ctrl K";
function loadStoredSidebarSessionsGrouping(): SidebarSessionsGrouping {
return normalizeSidebarSessionsGrouping(
getSafeLocalStorage()?.getItem(SIDEBAR_SESSION_GROUPING_STORAGE_KEY),
@@ -167,9 +170,6 @@ class AppSidebar extends OpenClawLightDomContentsElement {
@property({ attribute: false }) activeRouteId?: NavigationRouteId;
@property({ attribute: false }) activePluginTabId = "";
@property({ attribute: false }) enabledRouteIds?: readonly NavigationRouteId[];
/** "panel" (desktop): sessions-only column below the topbar, which owns nav.
* "drawer" (narrow viewports): full nav + sessions + footer slide-over. */
@property({ attribute: false }) variant: "panel" | "drawer" = "panel";
@property({ attribute: false }) connected = false;
@property({ attribute: false }) canPairDevice = false;
@property({ attribute: false }) sessionKey = "";
@@ -180,6 +180,8 @@ class AppSidebar extends OpenClawLightDomContentsElement {
@property({ attribute: false }) lobsterPetVisits = true;
@property({ attribute: false }) lobsterPetSounds = false;
@property({ attribute: false }) gatewayVersion: string | null = null;
@property({ attribute: false }) onOpenPalette?: () => void;
@property({ attribute: false }) onToggleSidebar?: () => void;
@property({ attribute: false }) onToggleMore?: () => void;
@property({ attribute: false }) onUpdatePinnedRoutes?: (routes: SidebarNavRoute[]) => void;
@property({ attribute: false }) onPairMobile?: () => void;
@@ -323,6 +325,7 @@ class AppSidebar extends OpenClawLightDomContentsElement {
}
private renderBrand() {
const collapseLabel = t("nav.collapse");
return html`
<div class="sidebar-brand">
<div class="sidebar-brand__identity">
@@ -334,10 +337,42 @@ class AppSidebar extends OpenClawLightDomContentsElement {
/>
<span class="sidebar-brand__title">OpenClaw</span>
</div>
<div class="sidebar-brand__actions">
${this.renderSearch()}
<openclaw-tooltip .content=${`${collapseLabel} (⌘B)`}>
<button
class="sidebar-brand__icon"
type="button"
@click=${() => this.onToggleSidebar?.()}
aria-label=${collapseLabel}
aria-expanded="true"
>
${icons.panelLeftClose}
</button>
</openclaw-tooltip>
</div>
</div>
`;
}
/** Command palette entry point; the palette itself is owned by the shell. */
private renderSearch() {
const tooltip = `${t("chat.openCommandPalette")} (${PALETTE_SHORTCUT})`;
return html`
<openclaw-tooltip .content=${tooltip}>
<button
type="button"
class="sidebar-brand__icon sidebar-search"
?disabled=${!this.onOpenPalette}
aria-label=${t("chat.openCommandPalette")}
@click=${() => this.onOpenPalette?.()}
>
${icons.search}
</button>
</openclaw-tooltip>
`;
}
private getRouteSessionKey(): string {
return this.sessionKey.trim() || this.context?.gateway.snapshot.sessionKey.trim() || "";
}
@@ -1195,8 +1230,12 @@ class AppSidebar extends OpenClawLightDomContentsElement {
`;
}
/** Dynamic plugin tabs stay in More; only stable static route ids can be persisted as pins. */
private pluginTabs(): GatewayControlUiPluginTab[] {
return orderedControlUiPluginTabs(this.context?.gateway.snapshot.hello?.controlUiTabs ?? []);
const tabs = this.context?.gateway.snapshot.hello?.controlUiTabs ?? [];
return ["chat", "control", "agent", "settings"].flatMap((group) =>
tabs.filter((tab) => (tab.group ?? "control") === group),
);
}
private renderPluginTab(tab: GatewayControlUiPluginTab) {
@@ -1633,32 +1672,6 @@ class AppSidebar extends OpenClawLightDomContentsElement {
}
override render() {
// Desktop sessions panel: the topbar owns brand, navigation, and global
// actions, so the panel is just the session list plus the resident pet.
// Its container deliberately avoids the .sidebar-shell class: the Mac app
// injects titlebar padding onto .sidebar-shell for the drawer, which must
// not leak into the panel that already sits below the topbar.
if (this.variant === "panel") {
return html`
<aside class="sidebar sidebar--panel">
<div class="sidebar-panel">
${this.renderSessions()}
<div class="sidebar-panel__footer">
<openclaw-lobster-pet
.seed=${lobsterPetSeed(this.sessionKey)}
.mode=${resolveLobsterPetMode(this.connected, this.sessionsResult?.sessions)}
.runOutcome=${resolveLobsterRunOutcome(this.sessionsResult?.sessions)}
.visitsEnabled=${this.lobsterPetVisits}
.soundsEnabled=${this.lobsterPetSounds}
.gatewayVersion=${this.gatewayVersion}
></openclaw-lobster-pet>
</div>
</div>
${this.renderCustomizeMenu()} ${this.renderSessionMenu()} ${this.renderSessionGroupMenu()}
${this.renderSessionSortMenu()}
</aside>
`;
}
const gatewayStatus = t("chat.gatewayStatus", {
status: this.connected ? t("common.online") : t("common.offline"),
});
+16 -533
View File
@@ -1,453 +1,23 @@
import { html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { GatewayControlUiPluginTab } from "../api/gateway.ts";
import {
cancelRoutePreload,
DEFAULT_SIDEBAR_PINNED_ROUTES,
isSettingsNavigationRoute,
navigationIconForRoute,
scheduleRoutePreload,
SIDEBAR_NAV_ROUTES,
sidebarMoreRoutes,
titleForRoute,
type NavigationRouteId,
type SidebarNavRoute,
} from "../app-navigation.ts";
import { pathForRoute } from "../app-route-paths.ts";
import type { ApplicationNavigationOptions } from "../app/context.ts";
import { property } from "lit/decorators.js";
import { controlUiPublicAssetPath } from "../app/public-assets.ts";
import type { ThemeMode } from "../app/theme.ts";
import { t } from "../i18n/index.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../lib/external-link.ts";
import { searchForSession } from "../lib/sessions/index.ts";
import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
import { pluginTabKey, pluginTabSearch } from "../pages/plugin/route.ts";
import { icons, type IconName } from "./icons.ts";
import "./theme-mode-toggle.ts";
import { icons } from "./icons.ts";
import "./tooltip.ts";
const PALETTE_SHORTCUT = /Mac|iP(hone|ad|od)/i.test(globalThis.navigator?.platform ?? "")
? "⌘K"
: "Ctrl K";
function shouldHandleNavigationClick(event: MouseEvent): boolean {
return (
!event.defaultPrevented &&
event.button === 0 &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
);
}
/** App-wide header bar: brand + primary navigation + global actions.
* Desktop shows the full bar (nav pills, search, status); narrow viewports
* collapse to drawer toggle + brand + search (layout.mobile.css). */
/** Narrow-viewport header: drawer toggle, brand, and command-palette search.
* Desktop hides it entirely (layout.css) — the sidebar owns navigation there. */
class AppTopbar extends OpenClawLightDomContentsElement {
@property({ attribute: false }) navDrawerOpen = false;
@property({ attribute: false }) onboarding = false;
@property({ attribute: false }) basePath = "";
@property({ attribute: false }) activeRouteId?: NavigationRouteId;
@property({ attribute: false }) activePluginTabId = "";
@property({ attribute: false }) enabledRouteIds?: readonly NavigationRouteId[];
@property({ attribute: false }) pinnedRoutes: readonly SidebarNavRoute[] =
DEFAULT_SIDEBAR_PINNED_ROUTES;
@property({ attribute: false }) pluginTabs: readonly GatewayControlUiPluginTab[] = [];
@property({ attribute: false }) sessionKey = "";
@property({ attribute: false }) connected = false;
@property({ attribute: false }) canPairDevice = false;
@property({ attribute: false }) themeMode: ThemeMode = "system";
@property({ attribute: false }) navCollapsed = false;
@property({ attribute: false }) searchDisabled = false;
@property({ attribute: false }) onToggleDrawer?: (trigger: HTMLElement) => void;
@property({ attribute: false }) onToggleSidebar?: () => void;
@property({ attribute: false }) onOpenPalette?: () => void;
@property({ attribute: false }) onPairMobile?: () => void;
@property({ attribute: false })
onNavigate?: (routeId: NavigationRouteId, options?: ApplicationNavigationOptions) => void;
@property({ attribute: false }) onPreloadRoute?: (routeId: NavigationRouteId) => Promise<void>;
@property({ attribute: false }) onUpdatePinnedRoutes?: (routes: SidebarNavRoute[]) => void;
@state() private moreMenuPosition: { x: number; y: number } | null = null;
@state() private customizeMenuPosition: { x: number; y: number } | null = null;
private moreMenuTrigger: HTMLElement | null = null;
private customizeMenuTrigger: HTMLElement | null = null;
private readonly routePreloadTimers = new Map<
EventTarget,
ReturnType<typeof globalThis.setTimeout>
>();
override disconnectedCallback() {
this.closeMoreMenu();
this.closeCustomizeMenu();
for (const timer of this.routePreloadTimers.values()) {
globalThis.clearTimeout(timer);
}
this.routePreloadTimers.clear();
super.disconnectedCallback();
}
private isRouteEnabled(routeId: NavigationRouteId): boolean {
return this.enabledRouteIds?.includes(routeId) ?? true;
}
private preloadRoute(routeId: NavigationRouteId, event: Event, immediate = false) {
scheduleRoutePreload(
this.routePreloadTimers,
routeId,
event,
(nextRouteId) => this.onPreloadRoute?.(nextRouteId),
routeId === this.activeRouteId || !this.isRouteEnabled(routeId),
immediate,
);
}
private readonly cancelPreload = (event: Event) => {
cancelRoutePreload(this.routePreloadTimers, event);
};
private isRouteActive(routeId: NavigationRouteId): boolean {
if (routeId === "config") {
return this.activeRouteId !== undefined && isSettingsNavigationRoute(this.activeRouteId);
}
return this.activeRouteId === routeId;
}
private navigate(routeId: NavigationRouteId, options?: ApplicationNavigationOptions) {
this.closeMoreMenu();
this.closeCustomizeMenu();
this.onNavigate?.(routeId, options);
}
private openMoreMenu(trigger: HTMLElement) {
if (this.moreMenuPosition) {
this.closeMoreMenu();
return;
}
const menuWidth = 240;
const rect = trigger.getBoundingClientRect();
this.closeCustomizeMenu();
this.moreMenuTrigger = trigger;
this.moreMenuPosition = {
x: Math.max(8, Math.min(rect.left, window.innerWidth - menuWidth - 8)),
y: rect.bottom + 6,
};
this.listenForDismissal();
void this.updateComplete.then(() => {
this.querySelector<HTMLElement>(".topbar-menu .topbar-menu__item")?.focus();
});
}
private closeMoreMenu(options: { restoreFocus?: boolean } = {}) {
const trigger = this.moreMenuTrigger;
this.moreMenuTrigger = null;
this.moreMenuPosition = null;
this.syncDismissListeners();
if (options.restoreFocus) {
trigger?.focus();
}
}
private openCustomizeMenu(x: number, y: number, trigger: HTMLElement | null = null) {
const menuWidth = 240;
const menuMaxHeight = 420;
this.closeMoreMenu();
this.customizeMenuTrigger = trigger;
this.customizeMenuPosition = {
x: Math.max(8, Math.min(x, window.innerWidth - menuWidth - 8)),
y: Math.max(8, Math.min(y, window.innerHeight - menuMaxHeight - 8)),
};
this.listenForDismissal();
void this.updateComplete.then(() => {
this.querySelector<HTMLElement>(".sidebar-customize-menu__item")?.focus();
});
}
private closeCustomizeMenu(options: { restoreFocus?: boolean } = {}) {
const trigger = this.customizeMenuTrigger;
this.customizeMenuTrigger = null;
this.customizeMenuPosition = null;
this.syncDismissListeners();
if (options.restoreFocus) {
trigger?.focus();
}
}
private listenForDismissal() {
document.addEventListener("pointerdown", this.handleDocumentPointerDown, true);
document.addEventListener("keydown", this.handleDocumentKeydown, true);
window.addEventListener("resize", this.handleWindowResize);
}
private syncDismissListeners() {
if (this.moreMenuPosition || this.customizeMenuPosition) {
return;
}
document.removeEventListener("pointerdown", this.handleDocumentPointerDown, true);
document.removeEventListener("keydown", this.handleDocumentKeydown, true);
window.removeEventListener("resize", this.handleWindowResize);
}
private readonly handleWindowResize = () => {
// Fixed-position menus are anchored to desktop-only controls. Close them
// whenever that geometry changes so an overlay cannot outlive its anchor.
this.closeMoreMenu();
this.closeCustomizeMenu();
};
private readonly handleDocumentPointerDown = (event: PointerEvent) => {
const path = event.composedPath();
if (this.moreMenuTrigger && path.includes(this.moreMenuTrigger)) {
return;
}
const menu = this.querySelector(".topbar-menu, .sidebar-customize-menu");
if (menu && path.includes(menu)) {
return;
}
this.closeMoreMenu();
this.closeCustomizeMenu();
};
private readonly handleDocumentKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
this.closeMoreMenu({ restoreFocus: true });
this.closeCustomizeMenu({ restoreFocus: true });
return;
}
if (event.key === "Tab") {
// Menu items stay outside the page tab order. Restore the durable trigger
// before the browser performs its normal forward/backward Tab movement.
this.closeMoreMenu({ restoreFocus: true });
this.closeCustomizeMenu({ restoreFocus: true });
return;
}
this.moveMenuFocus(event);
};
private moveMenuFocus(event: KeyboardEvent) {
const menu = this.querySelector<HTMLElement>(".topbar-menu, .sidebar-customize-menu");
if (!menu) {
return;
}
const items = Array.from(
menu.querySelectorAll<HTMLElement>('[role="menuitem"], [role="menuitemcheckbox"]'),
);
if (items.length === 0) {
return;
}
const activeIndex = items.indexOf(document.activeElement as HTMLElement);
let nextIndex: number;
if (event.key === "ArrowDown") {
nextIndex = (activeIndex + 1) % items.length;
} else if (event.key === "ArrowUp") {
nextIndex = activeIndex <= 0 ? items.length - 1 : activeIndex - 1;
} else if (event.key === "Home") {
nextIndex = 0;
} else if (event.key === "End") {
nextIndex = items.length - 1;
} else {
return;
}
event.preventDefault();
event.stopPropagation();
items[nextIndex]?.focus();
}
private readonly openCustomizeMenuFromContext = (event: MouseEvent) => {
event.preventDefault();
this.openCustomizeMenu(event.clientX, event.clientY);
};
private togglePinnedRoute(routeId: SidebarNavRoute) {
const pinned = this.pinnedRoutes;
const next = pinned.includes(routeId)
? pinned.filter((route) => route !== routeId)
: [...pinned, routeId];
this.onUpdatePinnedRoutes?.(next);
}
private renderNavItem(routeId: NavigationRouteId, menuItem = false) {
if (!this.isRouteEnabled(routeId)) {
return nothing;
}
const active = this.isRouteActive(routeId);
const routeSessionKey = routeId === "chat" ? this.sessionKey.trim() : "";
const href = routeSessionKey
? `${pathForRoute("chat", this.basePath)}${searchForSession(routeSessionKey)}`
: pathForRoute(routeId, this.basePath);
const label = titleForRoute(routeId);
const classes = menuItem
? `topbar-menu__item ${active ? "topbar-menu__item--active" : ""}`
: `topnav-item ${active ? "topnav-item--active" : ""}`;
return html`
<a
href=${href}
class=${classes}
role=${menuItem ? "menuitem" : nothing}
tabindex=${menuItem ? "-1" : nothing}
aria-current=${active ? "page" : nothing}
@focus=${(event: Event) => this.preloadRoute(routeId, event)}
@blur=${this.cancelPreload}
@pointerenter=${(event: Event) => this.preloadRoute(routeId, event)}
@pointerleave=${this.cancelPreload}
@touchstart=${(event: TouchEvent) => this.preloadRoute(routeId, event, true)}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
this.navigate(
routeId,
routeSessionKey ? { search: searchForSession(routeSessionKey) } : undefined,
);
}}
>
<span class="nav-item__icon" aria-hidden="true"
>${icons[navigationIconForRoute(routeId)]}</span
>
<span class="topnav-item__text">${label}</span>
</a>
`;
}
private renderPluginTabItem(tab: GatewayControlUiPluginTab) {
const ref = { pluginId: tab.pluginId, id: tab.id };
const search = pluginTabSearch(ref);
const href = `${pathForRoute("plugin", this.basePath)}${search}`;
const active = this.activeRouteId === "plugin" && this.activePluginTabId === pluginTabKey(ref);
const iconName = tab.icon && Object.hasOwn(icons, tab.icon) ? (tab.icon as IconName) : "puzzle";
return html`
<a
href=${href}
class="topbar-menu__item ${active ? "topbar-menu__item--active" : ""}"
role="menuitem"
tabindex="-1"
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
this.navigate("plugin", { search });
}}
>
<span class="nav-item__icon" aria-hidden="true">${icons[iconName]}</span>
<span class="topnav-item__text">${tab.label}</span>
</a>
`;
}
private renderMoreMenu() {
const position = this.moreMenuPosition;
if (!position) {
return nothing;
}
const moreRoutes = sidebarMoreRoutes(this.pinnedRoutes);
return html`
<div
class="topbar-menu"
role="menu"
aria-label=${t("nav.more")}
style="left: ${position.x}px; top: ${position.y}px;"
>
${moreRoutes.map((routeId) => this.renderNavItem(routeId, true))}
${this.pluginTabs.map((tab) => this.renderPluginTabItem(tab))}
<div class="topbar-menu__separator" role="separator"></div>
<a
class="topbar-menu__item"
role="menuitem"
tabindex="-1"
href="https://docs.openclaw.ai"
target=${EXTERNAL_LINK_TARGET}
rel=${buildExternalLinkRel()}
>
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
<span class="topnav-item__text">${t("common.docs")}</span>
<span class="nav-item__external-icon" aria-hidden="true">${icons.externalLink}</span>
</a>
<button
type="button"
class="topbar-menu__item"
role="menuitem"
tabindex="-1"
@click=${(event: MouseEvent) => {
const trigger = event.currentTarget as HTMLElement;
const rect = trigger.getBoundingClientRect();
this.openCustomizeMenu(rect.left, rect.top, this.moreMenuTrigger);
}}
>
<span class="nav-item__icon" aria-hidden="true">${icons.penLine}</span>
<span class="topnav-item__text">${t("nav.customize")}</span>
</button>
</div>
`;
}
private renderCustomizeMenu() {
const position = this.customizeMenuPosition;
if (!position) {
return nothing;
}
return html`
<div
class="sidebar-customize-menu"
role="menu"
aria-label=${t("nav.customize")}
style="left: ${position.x}px; top: ${position.y}px;"
>
<div class="sidebar-customize-menu__title">${t("nav.customize")}</div>
${SIDEBAR_NAV_ROUTES.filter((routeId) => this.isRouteEnabled(routeId)).map((routeId) => {
const pinned = this.pinnedRoutes.includes(routeId);
return html`
<button
type="button"
class="sidebar-customize-menu__item"
role="menuitemcheckbox"
tabindex="-1"
aria-checked=${String(pinned)}
@click=${() => this.togglePinnedRoute(routeId)}
>
<span class="sidebar-customize-menu__check" aria-hidden="true">
${pinned ? icons.check : nothing}
</span>
<span class="nav-item__icon" aria-hidden="true"
>${icons[navigationIconForRoute(routeId)]}</span
>
<span class="sidebar-customize-menu__text">${titleForRoute(routeId)}</span>
</button>
`;
})}
<div class="sidebar-customize-menu__separator" role="separator"></div>
<button
type="button"
class="sidebar-customize-menu__item"
role="menuitem"
tabindex="-1"
@click=${() => {
this.onUpdatePinnedRoutes?.([...DEFAULT_SIDEBAR_PINNED_ROUTES]);
this.closeCustomizeMenu({ restoreFocus: true });
}}
>
<span class="sidebar-customize-menu__check" aria-hidden="true"></span>
<span class="nav-item__icon" aria-hidden="true">${icons.refresh}</span>
<span class="sidebar-customize-menu__text">${t("nav.customizeReset")}</span>
</button>
</div>
`;
}
@property({ attribute: false }) searchDisabled = false;
override render() {
const drawerLabel = this.navDrawerOpen ? t("nav.collapse") : t("nav.expand");
const panelLabel = this.navCollapsed ? t("nav.expand") : t("nav.collapse");
const gatewayStatus = t("chat.gatewayStatus", {
status: this.connected ? t("common.online") : t("common.offline"),
});
const settingsActive =
this.activeRouteId !== undefined && isSettingsNavigationRoute(this.activeRouteId);
const searchTooltip = `${t("chat.openCommandPalette")} (${PALETTE_SHORTCUT})`;
return html`
<header
class="topbar"
@@ -467,61 +37,19 @@ class AppTopbar extends OpenClawLightDomContentsElement {
<span class="nav-collapse-toggle__icon" aria-hidden="true">${icons.menu}</span>
</button>
</openclaw-tooltip>
<!-- Brand stays non-interactive: on native macOS an AppKit drag
region floats over this strip (DashboardWindowController), so
anything clickable here would be unreachable in the Mac app. -->
<div class="topbar-brand" aria-label="OpenClaw">
<openclaw-tooltip .content=${gatewayStatus}>
<span class="topbar-brand__logo-wrap">
<img
class="topbar-brand__logo"
src=${controlUiPublicAssetPath("apple-touch-icon.png", this.basePath)}
alt=""
aria-hidden="true"
/>
<span
class="topbar-brand__status ${this.connected
? "topbar-brand__status--online"
: "topbar-brand__status--offline"}"
role="img"
aria-live="polite"
aria-label=${gatewayStatus}
></span>
</span>
</openclaw-tooltip>
<span class="topbar-brand__title">OpenClaw</span>
<div class="topnav-shell__content">
<div class="topbar-brand" aria-label="OpenClaw">
<img
class="topbar-brand__logo"
src=${controlUiPublicAssetPath("apple-touch-icon.png", this.basePath)}
alt=""
aria-hidden="true"
/>
<span class="topbar-brand__title">OpenClaw</span>
</div>
</div>
${settingsActive
? nothing
: html`
<openclaw-tooltip .content=${`${panelLabel} (⌘B)`}>
<button
type="button"
class="topbar-icon-btn topbar-panel-toggle"
@click=${() => this.onToggleSidebar?.()}
aria-label=${panelLabel}
aria-expanded=${String(!this.navCollapsed)}
>
${this.navCollapsed ? icons.panelLeftOpen : icons.panelLeftClose}
</button>
</openclaw-tooltip>
`}
<nav class="topbar-nav" @contextmenu=${this.openCustomizeMenuFromContext}>
${this.renderNavItem("chat")}
${this.pinnedRoutes.map((routeId) => this.renderNavItem(routeId))}
<button
type="button"
class="topnav-item topnav-item--more"
aria-haspopup="menu"
aria-expanded=${String(this.moreMenuPosition !== null)}
@click=${(event: MouseEvent) => this.openMoreMenu(event.currentTarget as HTMLElement)}
>
<span class="topnav-item__text">${t("nav.more")}</span>
<span class="topnav-item__chevron" aria-hidden="true">${icons.chevronDown}</span>
</button>
</nav>
<div class="topnav-shell__actions">
<openclaw-tooltip .content=${searchTooltip}>
<openclaw-tooltip .content=${t("chat.commandPaletteTitle")}>
<button
class="topbar-search"
?disabled=${this.searchDisabled || !this.onOpenPalette}
@@ -529,55 +57,10 @@ class AppTopbar extends OpenClawLightDomContentsElement {
aria-label=${t("chat.openCommandPalette")}
>
${icons.search}
<span class="topbar-search__label">${t("nav.search")}</span>
<kbd class="topbar-search__kbd">${PALETTE_SHORTCUT}</kbd>
</button>
</openclaw-tooltip>
<openclaw-tooltip
.content=${this.canPairDevice
? t("nodes.pairing.button")
: t("nodes.pairing.adminRequired")}
>
<button
class="topbar-icon-btn topbar-action topbar-pair-mobile"
type="button"
aria-label=${t("nodes.pairing.button")}
?disabled=${!this.canPairDevice}
@click=${() => this.onPairMobile?.()}
>
${icons.smartphone}
</button>
</openclaw-tooltip>
<span class="topbar-action topbar-mode-switch">
<openclaw-theme-mode-toggle .mode=${this.themeMode}></openclaw-theme-mode-toggle>
</span>
<openclaw-tooltip .content=${titleForRoute("config")}>
<a
href=${pathForRoute("config", this.basePath)}
class="topbar-icon-btn topbar-action ${settingsActive
? "topbar-icon-btn--active"
: ""}"
aria-label=${titleForRoute("config")}
aria-current=${settingsActive ? "page" : nothing}
@focus=${(event: Event) => this.preloadRoute("config", event)}
@blur=${this.cancelPreload}
@pointerenter=${(event: Event) => this.preloadRoute("config", event)}
@pointerleave=${this.cancelPreload}
@touchstart=${(event: TouchEvent) => this.preloadRoute("config", event, true)}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
this.navigate("config");
}}
>
${icons.settings}
</a>
</openclaw-tooltip>
</div>
</div>
${this.renderMoreMenu()} ${this.renderCustomizeMenu()}
</header>
`;
}
+1 -1
View File
@@ -101,7 +101,7 @@ export function renderSettingsSidebar(props: SettingsSidebarProps) {
const navigationGroups = filterSettingsNavigationGroups(props.searchQuery);
return html`
<aside class="settings-sidebar">
<header class="settings-sidebar__head">
<header class="settings-sidebar__header">
<button type="button" class="settings-sidebar__back" @click=${() => props.onExit()}>
<span class="settings-sidebar__back-icon" aria-hidden="true">${icons.arrowLeft}</span>
${t("nav.exitSettings")}
+27 -43
View File
@@ -232,7 +232,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
await closeOpenBrowserContexts();
});
it("keeps the topbar persistent and renders per-pane headers in split view", async () => {
it("renders per-pane headers in split view without desktop topbar chrome", async () => {
const context = await newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
@@ -253,14 +253,8 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
await page.goto(`${server.baseUrl}chat`);
await page.getByText("Split toolbar proof.").waitFor({ timeout: 10_000 });
// The topbar is the app chrome on desktop: always visible, carrying
// brand + primary navigation + global actions.
const topbar = page.locator(".topbar");
await expect.poll(() => topbar.isVisible()).toBe(true);
await expect
.poll(() => page.locator(".topbar-nav .topnav-item").count())
.toBeGreaterThanOrEqual(2);
await expect.poll(() => page.locator(".topbar-search").isVisible()).toBe(true);
// Desktop renders no topbar row: the sidebar owns navigation.
await expect.poll(() => page.locator(".topbar").isVisible()).toBe(false);
const splitEntry = page.getByRole("button", { name: "Open split view" });
await expect.poll(() => splitEntry.isVisible()).toBe(true);
@@ -274,8 +268,8 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
.toBe(true);
await splitEntry.click();
// Each pane owns an in-flow header (title + split/close actions) below
// the topbar; no fixed toolbar layer mirrors the split geometry.
// Each pane owns an in-flow header (title + workspace/split/close
// actions); no fixed toolbar layer mirrors the split geometry.
const headers = page.locator(".chat-pane__header");
await expect.poll(() => page.locator(".chat-split-view__pane").count()).toBe(2);
await expect.poll(() => headers.count()).toBe(2);
@@ -286,17 +280,10 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
})
.toBe(true);
await expect.poll(() => splitEntry.count()).toBe(0);
const [topbarBox, headerBox] = await Promise.all([
topbar.boundingBox(),
headers.first().boundingBox(),
]);
expect(topbarBox).not.toBeNull();
expect(headerBox).not.toBeNull();
if (!topbarBox || !headerBox) {
throw new Error("expected the topbar and pane header to have layout boxes");
}
expect(headerBox.y).toBeGreaterThanOrEqual(topbarBox.y + topbarBox.height - 1);
// The pane header hosts the session workspace toggle (the old collapsed
// rail strip is gone).
await expect.poll(() => headers.first().locator(".chat-workspace-toggle").count()).toBe(1);
await expect.poll(() => page.locator(".chat-workspace-rail").count()).toBe(0);
// Pane headers render a static session title; keyboard focus lands on
// the pane buttons and marks the pane active.
@@ -980,7 +967,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
try {
await page.goto(`${server.baseUrl}chat`);
await page.getByRole("button", { name: "Expand session workspace" }).click();
await page.locator(".chat-workspace-open").click();
await page.getByText("AGENTS.md").waitFor({ timeout: 10_000 });
await page.getByRole("button", { name: "Copy path" }).click();
@@ -1049,23 +1036,18 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
try {
await page.goto(`${server.baseUrl}chat`);
await page.getByRole("button", { name: "Expand session workspace" }).waitFor({
timeout: 10_000,
});
// Collapsed rails render nothing; the floating opener (with the
// changed-file badge) is the only pointer affordance.
const opener = page.locator(".chat-workspace-open");
await opener.waitFor({ timeout: 10_000 });
expect(await gateway.getRequests("sessions.files.list")).toHaveLength(0);
expect(await page.locator(".chat-workspace-rail__file").count()).toBe(0);
expect(await page.locator(".chat-workspace-rail__files svg").count()).toBe(1);
// The rail docks flush to the window edge in both states (no content gutter).
const railEdgeGap = () =>
page.locator(".chat-workspace-rail").evaluate((element) => {
return window.innerWidth - element.getBoundingClientRect().right;
});
expect(await railEdgeGap()).toBe(0);
expect(await page.locator(".chat-workspace-rail").count()).toBe(0);
await page.getByRole("button", { name: "Expand session workspace" }).click();
await opener.click();
await page.getByRole("button", { name: "Collapse session workspace" }).waitFor({
timeout: 10_000,
});
expect(await opener.count()).toBe(0);
await page.getByText("AGENTS.md").waitFor({ timeout: 10_000 });
await page.getByText("preview.png").waitFor({ timeout: 10_000 });
await page.getByText("Project files").waitFor({ timeout: 10_000 });
@@ -1074,16 +1056,18 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
});
expect(await gateway.getRequests("sessions.files.list")).toHaveLength(1);
expect(await gateway.getRequests("artifacts.list")).toHaveLength(1);
expect(await railEdgeGap()).toBe(0);
// The rail docks flush to the window edge (no content gutter).
expect(
await page.locator(".chat-workspace-rail").evaluate((element) => {
return window.innerWidth - element.getBoundingClientRect().right;
}),
).toBe(0);
await page.getByRole("button", { name: "Collapse session workspace" }).click();
await page.getByRole("button", { name: "Expand session workspace" }).waitFor({
timeout: 10_000,
});
expect(await page.locator(".chat-workspace-rail__file").count()).toBe(0);
expect(await page.locator(".chat-workspace-rail__files svg").count()).toBe(1);
await opener.waitFor({ timeout: 10_000 });
expect(await page.locator(".chat-workspace-rail").count()).toBe(0);
await page.getByRole("button", { name: "Expand session workspace" }).click();
await opener.click();
await page.getByRole("button", { name: "Collapse session workspace" }).waitFor({
timeout: 10_000,
});
@@ -1126,7 +1110,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
try {
await page.goto(`${server.baseUrl}chat`);
await page.getByRole("button", { name: "Expand session workspace" }).click();
await page.locator(".chat-workspace-open").click();
await page.locator(".chat-workspace-rail__file-name", { hasText: "file-60.ts" }).waitFor({
timeout: 10_000,
});
+4 -4
View File
@@ -72,11 +72,11 @@ describeControlUiE2e("Control UI mobile pairing mocked Gateway E2E", () => {
const response = await page.goto(`${server.baseUrl}chat`);
expect(response?.status()).toBe(200);
const topbarPairingButton = page.locator(".topbar-pair-mobile");
await topbarPairingButton.waitFor();
await expect.poll(async () => topbarPairingButton.isEnabled()).toBe(true);
const sidebarPairingButton = page.locator(".sidebar-pair-mobile");
await sidebarPairingButton.waitFor();
await expect.poll(async () => sidebarPairingButton.isEnabled()).toBe(true);
await gateway.deferNext("device.pair.list");
await topbarPairingButton.click();
await sidebarPairingButton.click();
const dialog = page.getByRole("dialog", { name: "OpenClaw mobile" });
const qr = page.getByAltText("OpenClaw mobile pairing QR code");
+72 -46
View File
@@ -113,13 +113,11 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
await page.goto(`${server.baseUrl}overview`);
const sidebar = page.locator("openclaw-app-sidebar");
// Desktop chrome: the topbar owns brand + navigation + global actions;
// the side panel below it is sessions-only.
const navItems = page.locator(".topbar-nav .topnav-item");
await expect.poll(() => page.locator(".topbar").isVisible()).toBe(true);
await expect.poll(() => trimmedTextContents(navItems)).toEqual(["Chat", "Overview", "More"]);
await expect.poll(() => sidebar.locator(".sidebar-brand").count()).toBe(0);
await expect.poll(() => sidebar.locator(".sidebar-panel").count()).toBe(1);
const pinnedItems = sidebar.locator(".sidebar-nav > .nav-section__items > .nav-item");
await expect.poll(() => trimmedTextContents(pinnedItems)).toEqual(["Overview"]);
await expect.poll(() => sidebar.locator(".sidebar-brand").count()).toBe(1);
// Desktop renders no topbar row: the sidebar owns navigation.
await expect.poll(() => page.locator(".topbar").isVisible()).toBe(false);
const shellNav = page.locator(".shell-nav");
const sidebarResizer = page.getByRole("separator", { name: "Resize sidebar" });
await expect.poll(() => roundedWidth(shellNav)).toBe(258);
@@ -158,11 +156,9 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
await expect.poll(() => roundedWidth(shellNav)).toBe(240);
await page.keyboard.press("End");
await expect.poll(() => roundedWidth(shellNav)).toBe(400);
// Settings takes over the whole app: the sessions panel yields to the
// Settings takes over the whole app: the regular sidebar yields to the
// settings sidebar until "Back to app" (or Escape) exits.
const settingsLink = page
.locator(".topnav-shell__actions")
.getByRole("link", { name: "Settings" });
const settingsLink = sidebar.getByRole("link", { name: "Settings" });
await expect.poll(() => settingsLink.isVisible()).toBe(true);
await settingsLink.click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/general");
@@ -242,23 +238,34 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
await holdUiProof(page);
await settingsSidebar.getByRole("button", { name: "Back to app" }).click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/overview");
await page.locator(".topbar-nav").getByRole("link", { name: "Overview" }).click();
await sidebar.getByRole("link", { name: "Overview" }).click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/overview");
await captureUiProof(page, "01-default-pinned.png");
const moreButton = page.locator(".topbar-nav").getByRole("button", { name: "More" });
const moreMenu = page.locator(".topbar-menu");
const moreMenuItems = moreMenu.locator(".topbar-menu__item");
const moreButton = sidebar.getByRole("button", { name: "More" });
await expect.poll(() => moreButton.getAttribute("aria-expanded")).toBe("false");
await moreButton.click();
await expect.poll(() => moreButton.getAttribute("aria-expanded")).toBe("true");
await expect.poll(() => trimmedTextContents(moreMenuItems)).toContain("Logbook");
await expect.poll(() => trimmedTextContents(navItems)).not.toContain("Logbook");
await expect
.poll(() =>
trimmedTextContents(
sidebar.locator(".nav-section--more .nav-section__items > .nav-item"),
),
)
.toContain("Logbook");
await expect.poll(() => trimmedTextContents(pinnedItems)).not.toContain("Logbook");
// Workboard ships disabled, so it stays hidden from navigation entirely.
await expect.poll(() => trimmedTextContents(moreMenuItems)).not.toContain("Workboard");
await expect
.poll(() =>
trimmedTextContents(
sidebar.locator(".nav-section--more .nav-section__items > .nav-item"),
),
)
.not.toContain("Workboard");
await moreMenu.getByRole("menuitem", { name: "Edit pinned items" }).click();
const menu = page.getByRole("menu", { name: "Edit pinned items" });
const customizeButton = sidebar.getByRole("button", { name: "Edit pinned items" });
await customizeButton.click();
const menu = sidebar.getByRole("menu", { name: "Edit pinned items" });
await expect
.poll(() => trimmedTextContents(menu.getByRole("menuitemcheckbox")))
.not.toContain("Workboard");
@@ -272,33 +279,42 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
await captureUiProof(page, "02-customize-menu.png");
await usageItem.click();
await expect
.poll(() => trimmedTextContents(navItems))
.toEqual(["Chat", "Overview", "Usage", "More"]);
await expect.poll(() => trimmedTextContents(pinnedItems)).toEqual(["Overview", "Usage"]);
await overviewItem.click();
await expect.poll(() => trimmedTextContents(navItems)).toEqual(["Chat", "Usage", "More"]);
await expect.poll(() => trimmedTextContents(pinnedItems)).toEqual(["Usage"]);
await page.reload();
await expect.poll(() => trimmedTextContents(navItems)).toEqual(["Chat", "Usage", "More"]);
await moreButton.click();
await expect.poll(() => trimmedTextContents(moreMenuItems)).toContain("Overview");
await expect.poll(() => trimmedTextContents(pinnedItems)).toEqual(["Usage"]);
await expect.poll(() => moreButton.getAttribute("aria-expanded")).toBe("true");
await expect
.poll(() =>
trimmedTextContents(
sidebar.locator(".nav-section--more .nav-section__items > .nav-item"),
),
)
.toContain("Overview");
await captureUiProof(page, "03-persisted-customization.png");
await moreMenu.getByRole("menuitem", { name: "Edit pinned items" }).click();
await customizeButton.click();
await menu.getByRole("menuitem", { name: "Reset pinned items" }).click();
await expect.poll(() => trimmedTextContents(navItems)).toEqual(["Chat", "Overview", "More"]);
await expect.poll(() => trimmedTextContents(pinnedItems)).toEqual(["Overview"]);
// The topbar search pill is the command palette entry point.
const searchButton = page.locator(".topbar-search");
// The sidebar search field is the command palette entry point.
const searchButton = sidebar.locator(".sidebar-search");
await searchButton.click();
const paletteInput = page.locator("#cmd-palette-input");
await expect.poll(() => paletteInput.isVisible()).toBe(true);
await page.keyboard.press("Escape");
await expect.poll(() => paletteInput.isVisible()).toBe(false);
// Collapsing hides the sessions panel entirely; navigation stays in the
// topbar, so no icon rail remains.
const collapseButton = page.locator(".topbar-panel-toggle");
await expect.poll(() => collapseButton.getAttribute("aria-label")).toBe("Collapse sidebar");
// The sidebar toggle lives in the sidebar brand row on desktop.
// Collapsing hides the sidebar entirely; a floating expand control and
// Cmd+B bring it back (there is no icon rail).
const collapseButton = page.getByRole("button", { name: "Collapse sidebar" });
await expect
.poll(() =>
collapseButton.evaluate((element) => Boolean(element.closest(".sidebar-brand"))),
)
.toBe(true);
await collapseButton.click();
await expect
.poll(() => page.locator(".shell").getAttribute("class"))
@@ -312,13 +328,20 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
.toBe("0px");
await expect.poll(() => sidebarResizer.count()).toBe(0);
await expect.poll(() => sidebar.isVisible()).toBe(false);
// The palette entry stays reachable in the topbar.
await expect.poll(() => searchButton.isVisible()).toBe(true);
const navExpand = page.locator(".shell-nav-expand");
await expect.poll(() => navExpand.isVisible()).toBe(true);
await page.reload();
await expect
.poll(() => page.locator(".topbar-panel-toggle").getAttribute("aria-label"))
.toBe("Expand sidebar");
await expect.poll(() => page.locator(".shell-nav-expand").isVisible()).toBe(true);
await captureUiProof(page, "04-persisted-collapsed.png");
await page.locator(".shell-nav-expand").click();
await expect
.poll(() => page.locator(".shell").getAttribute("class"))
.not.toContain("shell--nav-collapsed");
await expect.poll(() => sidebar.isVisible()).toBe(true);
await collapseButton.click();
await expect
.poll(() => page.locator(".shell").getAttribute("class"))
.toContain("shell--nav-collapsed");
await page.setViewportSize({ height: 900, width: 900 });
const drawerButton = page.locator(".topbar-nav-toggle");
@@ -327,9 +350,7 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
await expect
.poll(() => page.locator(".shell").getAttribute("class"))
.toContain("shell--nav-drawer-open");
// The drawer variant carries brand + nav sections + sessions.
await expect.poll(() => sidebar.locator(".sidebar-brand").count()).toBe(1);
await expect.poll(() => sidebar.getByRole("button", { name: "More" }).isVisible()).toBe(true);
await expect.poll(() => moreButton.isVisible()).toBe(true);
await expect.poll(() => sidebarResizer.isVisible()).toBe(false);
await expect
.poll(() =>
@@ -350,7 +371,7 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
// Widening with the drawer open must not leave its stale state blocking
// the desktop collapse control.
await page.setViewportSize({ height: 900, width: 1440 });
await page.locator(".topbar-panel-toggle").click();
await sidebar.getByRole("button", { name: "Collapse sidebar" }).click();
await expect
.poll(() => page.locator(".shell").getAttribute("class"))
.toContain("shell--nav-collapsed");
@@ -401,9 +422,14 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
try {
await page.goto(`${server.baseUrl}overview`);
await page.locator(".topbar-nav").getByRole("button", { name: "More" }).click();
const sidebar = page.locator("openclaw-app-sidebar");
await sidebar.getByRole("button", { name: "More" }).click();
await expect
.poll(() => trimmedTextContents(page.locator(".topbar-menu .topbar-menu__item")))
.poll(() =>
trimmedTextContents(
sidebar.locator(".nav-section--more .nav-section__items > .nav-item"),
),
)
.toContain("Workboard");
} finally {
await context.close();
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:22.249Z",
"generatedAt": "2026-07-10T08:19:15.941Z",
"locale": "ar",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:19.718Z",
"generatedAt": "2026-07-10T08:19:11.851Z",
"locale": "de",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:20.373Z",
"generatedAt": "2026-07-10T08:19:12.401Z",
"locale": "es",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:26.728Z",
"generatedAt": "2026-07-10T08:19:22.383Z",
"locale": "fa",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:21.421Z",
"generatedAt": "2026-07-10T08:19:14.479Z",
"locale": "fr",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:21.817Z",
"generatedAt": "2026-07-10T08:19:15.230Z",
"locale": "hi",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:24.199Z",
"generatedAt": "2026-07-10T08:19:18.615Z",
"locale": "id",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:22.608Z",
"generatedAt": "2026-07-10T08:19:16.594Z",
"locale": "it",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:20.892Z",
"generatedAt": "2026-07-10T08:19:13.014Z",
"locale": "ja-JP",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:21.163Z",
"generatedAt": "2026-07-10T08:19:13.740Z",
"locale": "ko",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:26.364Z",
"generatedAt": "2026-07-10T08:19:21.861Z",
"locale": "nl",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:24.655Z",
"generatedAt": "2026-07-10T08:19:19.496Z",
"locale": "pl",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:19.308Z",
"generatedAt": "2026-07-10T08:19:11.246Z",
"locale": "pt-BR",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:27.358Z",
"generatedAt": "2026-07-10T08:19:23.123Z",
"locale": "ru",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:25.147Z",
"generatedAt": "2026-07-10T08:19:20.263Z",
"locale": "th",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:23.247Z",
"generatedAt": "2026-07-10T08:19:17.364Z",
"locale": "tr",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:23.838Z",
"generatedAt": "2026-07-10T08:19:18.064Z",
"locale": "uk",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:25.578Z",
"generatedAt": "2026-07-10T08:19:21.125Z",
"locale": "vi",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:17.965Z",
"generatedAt": "2026-07-10T08:19:09.917Z",
"locale": "zh-CN",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-10T06:42:18.830Z",
"generatedAt": "2026-07-10T08:19:10.612Z",
"locale": "zh-TW",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "bfd55ea08ff322fd84a74ec86286313a340be06b0b37319a1e17005fbceb029c",
"totalKeys": 1780,
"translatedKeys": 1780,
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
"totalKeys": 1779,
"translatedKeys": 1779,
"workflow": 1
}
-1
View File
@@ -501,7 +501,6 @@ export const ar: TranslationMap = {
expand: "توسيع الشريط الجانبي",
collapse: "طي الشريط الجانبي",
resize: "تغيير حجم الشريط الجانبي",
search: "بحث",
more: "المزيد",
customize: "تخصيص الشريط الجانبي",
customizeReset: "إعادة التعيين إلى الإعدادات الافتراضية",
-1
View File
@@ -506,7 +506,6 @@ export const de: TranslationMap = {
expand: "Seitenleiste ausklappen",
collapse: "Seitenleiste einklappen",
resize: "Seitenleiste in der Größe ändern",
search: "Suchen",
more: "Mehr",
customize: "Seitenleiste anpassen",
customizeReset: "Auf Standardwerte zurücksetzen",
-1
View File
@@ -500,7 +500,6 @@ export const en: TranslationMap = {
expand: "Expand sidebar",
collapse: "Collapse sidebar",
resize: "Resize sidebar",
search: "Search",
more: "More",
customize: "Edit pinned items",
customizeReset: "Reset pinned items",
-1
View File
@@ -503,7 +503,6 @@ export const es: TranslationMap = {
expand: "Expandir barra lateral",
collapse: "Contraer barra lateral",
resize: "Redimensionar la barra lateral",
search: "Buscar",
more: "Más",
customize: "Personalizar barra lateral",
customizeReset: "Restablecer valores predeterminados",
-1
View File
@@ -503,7 +503,6 @@ export const fa: TranslationMap = {
expand: "گسترش نوار کناری",
collapse: "جمع کردن نوار کناری",
resize: "تغییر اندازه نوار کناری",
search: "جستجو",
more: "بیشتر",
customize: "سفارشی‌سازی نوار کناری",
customizeReset: "بازنشانی به پیش‌فرض‌ها",
-1
View File
@@ -508,7 +508,6 @@ export const fr: TranslationMap = {
expand: "Développer la barre latérale",
collapse: "Réduire la barre latérale",
resize: "Redimensionner la barre latérale",
search: "Rechercher",
more: "Plus",
customize: "Personnaliser la barre latérale",
customizeReset: "Réinitialiser les paramètres par défaut",
-1
View File
@@ -502,7 +502,6 @@ export const hi: TranslationMap = {
expand: "साइडबार फैलाएँ",
collapse: "साइडबार समेटें",
resize: "साइडबार का आकार बदलें",
search: "खोजें",
more: "अधिक",
customize: "साइडबार कस्टमाइज़ करें",
customizeReset: "डिफ़ॉल्ट पर रीसेट करें",
-1
View File
@@ -503,7 +503,6 @@ export const id: TranslationMap = {
expand: "Perluas bilah samping",
collapse: "Ciutkan bilah samping",
resize: "Ubah ukuran bilah samping",
search: "Cari",
more: "Lainnya",
customize: "Sesuaikan bilah samping",
customizeReset: "Atur ulang ke default",
-1
View File
@@ -508,7 +508,6 @@ export const it: TranslationMap = {
expand: "Espandi barra laterale",
collapse: "Comprimi barra laterale",
resize: "Ridimensiona barra laterale",
search: "Cerca",
more: "Altro",
customize: "Personalizza barra laterale",
customizeReset: "Ripristina impostazioni predefinite",
-1
View File
@@ -508,7 +508,6 @@ export const ja_JP: TranslationMap = {
expand: "サイドバーを展開",
collapse: "サイドバーを折りたたむ",
resize: "サイドバーのサイズを変更",
search: "検索",
more: "その他",
customize: "サイドバーをカスタマイズ",
customizeReset: "デフォルトに戻す",
-1
View File
@@ -501,7 +501,6 @@ export const ko: TranslationMap = {
expand: "사이드바 펼치기",
collapse: "사이드바 접기",
resize: "사이드바 크기 조절",
search: "검색",
more: "더 보기",
customize: "사이드바 사용자 지정",
customizeReset: "기본값으로 재설정",
-1
View File
@@ -506,7 +506,6 @@ export const nl: TranslationMap = {
expand: "Zijbalk uitvouwen",
collapse: "Zijbalk samenvouwen",
resize: "Zijbalkgrootte aanpassen",
search: "Zoeken",
more: "Meer",
customize: "Zijbalk aanpassen",
customizeReset: "Standaardinstellingen herstellen",
-1
View File
@@ -504,7 +504,6 @@ export const pl: TranslationMap = {
expand: "Rozwiń pasek boczny",
collapse: "Zwiń pasek boczny",
resize: "Zmień rozmiar paska bocznego",
search: "Szukaj",
more: "Więcej",
customize: "Dostosuj pasek boczny",
customizeReset: "Przywróć ustawienia domyślne",
-1
View File
@@ -502,7 +502,6 @@ export const pt_BR: TranslationMap = {
expand: "Expandir barra lateral",
collapse: "Recolher barra lateral",
resize: "Redimensionar barra lateral",
search: "Pesquisar",
more: "Mais",
customize: "Personalizar barra lateral",
customizeReset: "Restaurar padrões",
-1
View File
@@ -507,7 +507,6 @@ export const ru: TranslationMap = {
expand: "Развернуть боковую панель",
collapse: "Свернуть боковую панель",
resize: "Изменить размер боковой панели",
search: "Поиск",
more: "Ещё",
customize: "Настроить боковую панель",
customizeReset: "Сбросить настройки",
-1
View File
@@ -500,7 +500,6 @@ export const th: TranslationMap = {
expand: "ขยายแถบด้านข้าง",
collapse: "ย่อแถบด้านข้าง",
resize: "ปรับขนาดแถบด้านข้าง",
search: "ค้นหา",
more: "เพิ่มเติม",
customize: "ปรับแต่งแถบด้านข้าง",
customizeReset: "รีเซ็ตเป็นค่าเริ่มต้น",
-1
View File
@@ -506,7 +506,6 @@ export const tr: TranslationMap = {
expand: "Kenar çubuğunu genişlet",
collapse: "Kenar çubuğunu daralt",
resize: "Kenar çubuğunu yeniden boyutlandır",
search: "Ara",
more: "Daha fazla",
customize: "Kenar çubuğunu özelleştir",
customizeReset: "Varsayılanlara sıfırla",
-1
View File
@@ -503,7 +503,6 @@ export const uk: TranslationMap = {
expand: "Розгорнути бічну панель",
collapse: "Згорнути бічну панель",
resize: "Змінити розмір бічної панелі",
search: "Пошук",
more: "Більше",
customize: "Налаштувати бічну панель",
customizeReset: "Скинути до типових налаштувань",
-1
View File
@@ -502,7 +502,6 @@ export const vi: TranslationMap = {
expand: "Mở rộng thanh bên",
collapse: "Thu gọn thanh bên",
resize: "Thay đổi kích thước thanh bên",
search: "Tìm kiếm",
more: "Thêm",
customize: "Tùy chỉnh thanh bên",
customizeReset: "Đặt lại về mặc định",
-1
View File
@@ -499,7 +499,6 @@ export const zh_CN: TranslationMap = {
expand: "展开侧边栏",
collapse: "折叠侧边栏",
resize: "调整侧边栏大小",
search: "搜索",
more: "更多",
customize: "自定义侧边栏",
customizeReset: "恢复默认设置",
-1
View File
@@ -499,7 +499,6 @@ export const zh_TW: TranslationMap = {
expand: "展開側邊欄",
collapse: "折疊側邊欄",
resize: "調整側邊欄大小",
search: "搜尋",
more: "更多",
customize: "自訂側邊欄",
customizeReset: "重設為預設值",
+12 -14
View File
@@ -20,6 +20,9 @@ type RenderedPane = HTMLElement & {
paneId: string;
sessionKey: string;
active: boolean;
showPaneHeader: boolean;
paneTitle: string;
narrow: boolean;
};
function setLayout(page: ChatPage, layout: ChatSplitLayout | undefined) {
@@ -94,6 +97,7 @@ 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].showPaneHeader).toBe(false);
expect(page.querySelector("resizable-divider")).toBeNull();
expect(page.querySelector(".chat-open-split-view")).toBeInstanceOf(HTMLButtonElement);
});
@@ -124,8 +128,8 @@ describe("chat page split layout host", () => {
expect(dividers).toHaveLength(1);
expect(dividers[0].orientation).toBe("vertical");
expect(page.querySelector(".chat-split-view__cell--active")?.contains(panes[1])).toBe(true);
expect(page.querySelectorAll(".chat-pane__header")).toHaveLength(2);
expect(page.querySelector(".chat-pane__header--active")).not.toBeNull();
// Panes own their in-flow header row (title + workspace/split/close).
expect(panes.map((pane) => pane.showPaneHeader)).toEqual([true, true]);
expect(page.querySelector(".chat-open-split-view")).toBeNull();
});
@@ -140,7 +144,8 @@ 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(page.querySelectorAll(".chat-pane__header")).toHaveLength(1);
expect(panes[0].showPaneHeader).toBe(true);
expect(panes[0].narrow).toBe(true);
expect(page.querySelector("resizable-divider")).toBeNull();
});
@@ -167,15 +172,9 @@ describe("chat page split layout host", () => {
setLayout(page, createSplitLayout("main"));
await page.updateComplete;
const titlesBefore = [...page.querySelectorAll(".chat-pane__session-title")];
expect(titlesBefore.map((title) => title.textContent?.trim())).toEqual([
"Main Session",
"Main Session",
]);
// The pane header is intentionally a static, non-interactive title: a
// session picker there would fight pane focus. Sessions change via the
// sessions panel or drag-and-drop instead.
expect(page.querySelector(".chat-pane__header select")).toBeNull();
const paneTitles = () =>
[...page.querySelectorAll<RenderedPane>("openclaw-chat-pane")].map((pane) => pane.paneTitle);
expect(paneTitles()).toEqual(["Main Session", "Main Session"]);
sessionsState.result = {
sessions: [{ key: "main", displayName: "Main desk" }],
@@ -183,8 +182,7 @@ describe("chat page split layout host", () => {
notify();
await page.updateComplete;
const titles = [...page.querySelectorAll(".chat-pane__session-title")];
expect(titles.map((title) => title.textContent?.trim())).toEqual(["Main desk", "Main desk"]);
expect(paneTitles()).toEqual(["Main desk", "Main desk"]);
page.remove();
expect(cleanup).toHaveBeenCalledOnce();
+11 -51
View File
@@ -376,61 +376,15 @@ export class ChatPage extends OpenClawLightDomElement {
}
};
private renderPaneHeader(pane: ChatSplitPane, active: boolean) {
/** Header + pane travel together so each pane owns its title bar in-flow
* no fixed toolbar layer mirroring the split geometry. The pane renders its
* own header so the workspace toggle can read per-pane workspace state. */
private renderPaneCell(pane: ChatSplitPane, active: boolean, weight: number) {
const sessions = this.context?.sessions?.state.result?.sessions ?? [];
const title = resolveSessionDisplayName(
pane.sessionKey,
sessions.find((row) => row.key === pane.sessionKey),
);
return html`
<div class="chat-pane__header ${active ? "chat-pane__header--active" : ""}">
<!-- Static text on purpose: an interactive session picker here would
fight pane focus. Panes change sessions via the sessions panel or
drag-and-drop. -->
<span class="chat-pane__session-title" title=${title}>${title}</span>
<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>
`;
}
/** Header + pane travel together so each pane owns its title bar in-flow
* no fixed toolbar layer mirroring the split geometry. */
private renderPaneCell(pane: ChatSplitPane, active: boolean, weight: number) {
return html`
<div
class="chat-split-view__cell ${active ? "chat-split-view__cell--active" : ""}"
@@ -438,13 +392,18 @@ export class ChatPage extends OpenClawLightDomElement {
@pointerdown=${() => this.handleFocusPane(pane.id)}
@focusin=${() => this.handleFocusPane(pane.id)}
>
${this.renderPaneHeader(pane, active)}
<openclaw-chat-pane
class="chat-split-view__pane"
.paneId=${pane.id}
.sessionKey=${pane.sessionKey}
.active=${active}
.draft=${active ? this.data?.draft : undefined}
.showPaneHeader=${true}
.paneTitle=${title}
.narrow=${this.narrow}
.onSplitDown=${this.handleSplitDown}
.onSplitRight=${this.handleSplitRight}
.onClosePane=${this.handleClosePane}
.onFocusPane=${this.handleFocusPane}
.onPaneSessionChange=${this.handlePaneSessionChange}
></openclaw-chat-pane>
@@ -540,6 +499,7 @@ export class ChatPage extends OpenClawLightDomElement {
.sessionKey=${this.data?.sessionKey ?? ""}
.active=${true}
.draft=${this.data?.draft}
.showPaneHeader=${false}
.onFocusPane=${this.handleFocusPane}
.onPaneSessionChange=${this.handlePaneSessionChange}
></openclaw-chat-pane>
+68 -3
View File
@@ -1,5 +1,5 @@
import { consume } from "@lit/context";
import { html } from "lit";
import { html, nothing } from "lit";
import { property } from "lit/decorators.js";
import type {
TaskSuggestion,
@@ -19,6 +19,8 @@ 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";
@@ -73,8 +75,10 @@ import { renderChatControls } from "./components/chat-controls.ts";
import {
createSessionWorkspaceProps,
openSessionWorkspaceFile,
renderSessionWorkspaceToggle,
revealSessionWorkspaceFile,
toggleSessionWorkspace,
type SessionWorkspaceProps,
} from "./components/chat-session-workspace.ts";
import {
CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS,
@@ -138,6 +142,14 @@ class ChatPane extends OpenClawLightDomElement {
nextSessionKey: string,
options?: PaneSessionChangeOptions,
) => void;
/** Split mode renders an in-pane header row (title + workspace/split/close
* controls); classic single-pane mode renders none. */
@property({ attribute: false }) showPaneHeader = false;
@property({ attribute: false }) paneTitle = "";
@property({ attribute: false }) narrow = false;
@property({ attribute: false }) onSplitDown?: (paneId: string) => void;
@property({ attribute: false }) onSplitRight?: (paneId: string) => void;
@property({ attribute: false }) onClosePane?: (paneId: string) => void;
private readonly chatState = new ChatStateController<ChatPageHost>(this);
private state: ChatPageHost | undefined;
@@ -973,6 +985,54 @@ class ChatPane extends OpenClawLightDomElement {
state.requestUpdate?.();
}
private renderPaneHeader(sessionWorkspace: SessionWorkspaceProps) {
return html`
<div class="chat-pane__header ${this.active ? "chat-pane__header--active" : ""}">
<!-- Static text on purpose: an interactive session picker here would
fight pane focus. Panes change sessions via the sidebar or
drag-and-drop. -->
<span class="chat-pane__session-title" title=${this.paneTitle}>${this.paneTitle}</span>
<div class="chat-pane__actions">
${renderSessionWorkspaceToggle(sessionWorkspace, "pane-header")}
${!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.onSplitDown?.(this.paneId)}
>
${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.onSplitRight?.(this.paneId)}
>
${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.onClosePane?.(this.paneId)}
>
${icons.x}
</button>
</openclaw-tooltip>
</div>
</div>
`;
}
override render() {
const state = this.state;
if (!state) {
@@ -995,6 +1055,7 @@ class ChatPane extends OpenClawLightDomElement {
const canOpenRealtimeTalkSettings = hasOperatorAdminAccess(
this.context.gateway.snapshot.hello?.auth ?? null,
);
const sessionWorkspace = createSessionWorkspaceProps(state);
const props: ChatProps = {
paneId: this.paneId,
sessionKey: state.sessionKey,
@@ -1099,7 +1160,8 @@ class ChatPane extends OpenClawLightDomElement {
state.requestUpdate?.();
},
}),
sessionWorkspace: createSessionWorkspaceProps(state),
sessionWorkspace,
paneHeaderActive: this.showPaneHeader,
taskSuggestions: this.taskSuggestions,
taskSuggestionBusyIds: this.taskSuggestionBusyIds,
canAcceptTaskSuggestions:
@@ -1210,7 +1272,10 @@ class ChatPane extends OpenClawLightDomElement {
onAssistantAttachmentLoaded: () => state.scrollToBottom(),
basePath: state.basePath,
};
return renderChat(props);
if (!this.showPaneHeader) {
return renderChat(props);
}
return html`${this.renderPaneHeader(sessionWorkspace)}${renderChat(props)}`;
}
}
+49 -14
View File
@@ -1208,13 +1208,27 @@ describe("chat composer workbench", () => {
expect(container.querySelector('button[aria-label="Session workspace"]')).toBeNull();
});
it("keeps the workspace files rail reachable from the collapsed strip", () => {
it("renders no rail strip while collapsed and reopens via the floating toggle", () => {
const onToggleCollapsed = vi.fn();
const container = renderChatView({
sessionWorkspace: {
collapsed: true,
sessionKey: "agent:main",
list: null,
list: {
sessionKey: "agent:main",
root: "/workspace",
files: [
{
name: "AGENTS.md",
path: "/workspace/AGENTS.md",
kind: "modified",
missing: false,
size: 2048,
},
],
browser: { path: "", entries: [] },
artifacts: [],
},
loading: false,
error: null,
activeId: null,
@@ -1233,25 +1247,46 @@ describe("chat composer workbench", () => {
},
});
expect(container.querySelector(".chat-workspace-rail__list")).toBeNull();
const toggle = container.querySelector<HTMLButtonElement>(
'button[aria-label="Expand session workspace"]',
);
// A collapsed rail renders nothing — no icon strip in the layout.
expect(container.querySelector(".chat-workspace-rail")).toBeNull();
const toggle = container.querySelector<HTMLButtonElement>(".chat-workspace-open");
expect(toggle?.getAttribute("aria-label")).toBe("Show session files");
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
expect(toggle?.getAttribute("aria-keyshortcuts")).toBe("Meta+Shift+B");
expect(toggle?.querySelector(".chat-workspace-toggle__badge")?.textContent?.trim()).toBe("1");
toggle?.click();
expect(onToggleCollapsed).toHaveBeenCalledTimes(1);
});
// The file glyph is a real control (regression: it used to be a dead,
// button-looking span) — clicking it expands the rail too.
const filesButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Show session files"]',
);
expect(filesButton).not.toBeNull();
filesButton?.click();
expect(onToggleCollapsed).toHaveBeenCalledTimes(2);
it("suppresses the floating workspace toggle when a pane header hosts it", () => {
const container = renderChatView({
paneHeaderActive: true,
sessionWorkspace: {
collapsed: true,
sessionKey: "agent:main",
list: null,
loading: false,
error: null,
activeId: null,
dock: "right",
dockDragging: false,
dockDragZone: null,
onToggleCollapsed: () => undefined,
onSetDock: () => undefined,
onDockDragStart: () => undefined,
onRefresh: () => undefined,
onBrowsePath: () => undefined,
onCopyPath: () => undefined,
onOpenFile: () => undefined,
onSearch: () => undefined,
onOpenArtifact: () => undefined,
},
});
expect(container.querySelector(".chat-workspace-open")).toBeNull();
expect(container.querySelector(".chat-workspace-rail")).toBeNull();
});
it("keeps the secondary New session and Export controls suppressed in the composer", () => {
+7
View File
@@ -23,6 +23,7 @@ import {
} from "./components/chat-composer.ts";
import {
renderSessionWorkspaceRail,
renderSessionWorkspaceToggle,
type SessionWorkspaceProps,
} from "./components/chat-session-workspace.ts";
import type {
@@ -147,6 +148,9 @@ export type ChatProps = {
onClearReply?: () => void;
onSetReply?: (target: { messageId: string; text: string; senderLabel?: string | null }) => void;
sessionWorkspace?: SessionWorkspaceProps;
/** True when a split pane header hosts the workspace toggle; suppresses the
* single-pane floating opener so only one affordance renders. */
paneHeaderActive?: boolean;
taskSuggestions?: TaskSuggestion[];
taskSuggestionBusyIds?: ReadonlySet<string>;
canAcceptTaskSuggestions?: boolean;
@@ -348,6 +352,9 @@ export function renderChat(props: ChatProps) {
: ""} ${props.sessionWorkspace?.dock === "bottom" ? "chat-workbench--dock-bottom" : ""}"
>
${renderSessionWorkspaceRail(props.sessionWorkspace)}
${props.sessionWorkspace?.collapsed && !props.paneHeaderActive
? renderSessionWorkspaceToggle(props.sessionWorkspace, "floating")
: nothing}
${props.sessionWorkspace?.dockDragging
? html`
<div class="chat-workbench__dock-zones" aria-hidden="true">
@@ -650,10 +650,56 @@ function renderWorkspaceRailSection(
`;
}
/** Changed-file count shown on the collapsed-rail toggles (pane header /
* floating opener); 0 until the workspace list has loaded. */
export function sessionWorkspaceModifiedCount(
sessionWorkspace: SessionWorkspaceProps | undefined,
): number {
return sessionWorkspace?.list?.files.filter((file) => file.kind === "modified").length ?? 0;
}
/** Toggle used wherever the rail itself is not visible: the split pane header
* and the single-pane floating opener. Collapsed rails render nothing, so
* this button is the only pointer affordance (B still works). */
export function renderSessionWorkspaceToggle(
sessionWorkspace: SessionWorkspaceProps | undefined,
variant: "pane-header" | "floating",
): TemplateResult | typeof nothing {
if (!sessionWorkspace) {
return nothing;
}
const expanded = !sessionWorkspace.collapsed;
const label = expanded ? t("chat.workspaceFiles.collapse") : t("chat.workspaceFiles.showFiles");
const modifiedCount = sessionWorkspaceModifiedCount(sessionWorkspace);
return html`
<openclaw-tooltip .content=${`${label} (⇧⌘B)`}>
<button
class="${variant === "pane-header"
? "btn btn--ghost btn--icon"
: "btn btn--sm btn--icon chat-workspace-open"} chat-workspace-toggle"
type="button"
aria-label=${label}
aria-keyshortcuts="Meta+Shift+B"
aria-expanded=${String(expanded)}
@click=${sessionWorkspace.onToggleCollapsed}
>
${icons.fileText}
${!expanded && modifiedCount > 0
? html`<span class="chat-workspace-toggle__badge" aria-hidden="true"
>${modifiedCount}</span
>`
: nothing}
</button>
</openclaw-tooltip>
`;
}
export function renderSessionWorkspaceRail(
sessionWorkspace: SessionWorkspaceProps | undefined,
): TemplateResult | typeof nothing {
if (!sessionWorkspace) {
// Collapsed rails render nothing at all — no icon strip. Reopening happens
// through renderSessionWorkspaceToggle or ⇧⌘B.
if (!sessionWorkspace || sessionWorkspace.collapsed) {
return nothing;
}
const dock = sessionWorkspace.dock;
@@ -671,47 +717,6 @@ export function renderSessionWorkspaceRail(
</openclaw-tooltip>
`
: nothing;
if (sessionWorkspace.collapsed) {
const modifiedCount =
sessionWorkspace.list?.files.filter((file) => file.kind === "modified").length ?? 0;
return html`
<aside
class="chat-workspace-rail chat-workspace-rail--collapsed"
aria-label=${t("chat.workspaceFiles.label")}
>
<openclaw-tooltip .content=${`${t("chat.workspaceFiles.expand")} (⇧⌘B)`}>
<button
type="button"
class="nav-collapse-toggle chat-workspace-rail__collapse-toggle"
aria-label=${t("chat.workspaceFiles.expand")}
aria-keyshortcuts="Meta+Shift+B"
aria-expanded="false"
@click=${sessionWorkspace.onToggleCollapsed}
>
<span class="nav-collapse-toggle__icon" aria-hidden="true"
>${dock === "bottom" ? icons.panelBottomOpen : icons.panelRightOpen}</span
>
</button>
</openclaw-tooltip>
<openclaw-tooltip .content=${t("chat.workspaceFiles.showFiles")}>
<button
type="button"
class="chat-workspace-rail__files"
aria-label=${t("chat.workspaceFiles.showFiles")}
@click=${sessionWorkspace.onToggleCollapsed}
>
${icons.fileText}
${modifiedCount > 0
? html`<span class="chat-workspace-rail__files-badge" aria-hidden="true"
>${modifiedCount}</span
>`
: nothing}
</button>
</openclaw-tooltip>
${terminalButton}
</aside>
`;
}
const files = sessionWorkspace.list?.files ?? [];
const modifiedFiles = files.filter((file) => file.kind === "modified");
const readFiles = files.filter((file) => file.kind === "read");
+46 -81
View File
@@ -9,10 +9,10 @@
overflow: hidden;
}
/* A collapsed rail renders nothing at all the whole width goes back to the
thread. Reopen via the pane header / floating toggle or B. */
.chat-workbench--workspace-collapsed {
/* Collapsed rail is a slim always-visible icon strip (Cursor-style); it
stays in layout so it never overlays or floats over chat content. */
grid-template-columns: minmax(0, 1fr) 44px;
grid-template-columns: minmax(0, 1fr);
}
/* Bottom dock mirrors the terminal panel: the rail becomes a strip under the
@@ -23,7 +23,7 @@
}
.chat-workbench--dock-bottom.chat-workbench--workspace-collapsed {
grid-template-rows: minmax(0, 1fr) 44px;
grid-template-rows: minmax(0, 1fr);
}
.chat-workbench--dock-bottom .chat-workspace-rail {
@@ -33,10 +33,48 @@
border-top: 1px solid var(--border);
}
.chat-workbench--dock-bottom .chat-workspace-rail--collapsed {
flex-direction: row;
justify-content: flex-start;
padding: 0 12px;
/* Floating opener shown in single-pane mode while the rail is collapsed
(split panes host the same toggle in their header row instead). */
.chat-workspace-open {
position: absolute;
top: 10px;
right: 10px;
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);
}
/* The rail (and with it every toggle) only exists on wide viewports. */
@media (max-width: 1120px) {
.chat-workspace-open,
.chat-pane__actions .chat-workspace-toggle {
display: none;
}
}
/* Positioning context for the badge; the floating variant already gets one
from its own position: absolute, so scope this to the pane-header variant. */
.chat-pane__actions .chat-workspace-toggle {
position: relative;
}
.chat-workspace-toggle__badge {
position: absolute;
top: 0;
right: -2px;
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: var(--radius-full);
background: var(--accent);
color: var(--primary-foreground, #fff);
font-size: 9px;
font-weight: 650;
line-height: 14px;
text-align: center;
}
/* Drop zones shown while dragging the rail's grip; the hovered edge wins. */
@@ -144,12 +182,6 @@
background: color-mix(in srgb, var(--panel) 84%, transparent);
}
.chat-workspace-rail--collapsed {
align-items: center;
gap: 10px;
padding: 12px 6px;
}
.chat-workspace-rail__header {
display: flex;
align-items: center;
@@ -227,17 +259,6 @@
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.chat-workspace-rail--collapsed .chat-workspace-rail__terminal {
border-color: transparent;
background: transparent;
}
.chat-workspace-rail--collapsed .chat-workspace-rail__terminal:hover,
.chat-workspace-rail--collapsed .chat-workspace-rail__terminal:focus-visible {
border-color: transparent;
background: transparent;
}
.chat-workspace-rail__collapse-toggle {
flex: 0 0 auto;
box-shadow: none;
@@ -257,62 +278,6 @@
stroke-linejoin: round;
}
/* Collapsed-strip files button: expands the rail; the badge carries the
session's changed-file count when known. */
.chat-workspace-rail__files {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--muted);
transition:
background var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.chat-workspace-rail__files:hover,
.chat-workspace-rail__files:focus-visible {
background: color-mix(in srgb, var(--bg-hover) 84%, transparent);
color: var(--text);
}
.chat-workspace-rail__files:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.chat-workspace-rail__files svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-workspace-rail__files-badge {
position: absolute;
top: 1px;
right: 0;
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: var(--radius-full);
background: var(--accent);
color: var(--primary-foreground, #fff);
font-size: 9px;
font-weight: 650;
line-height: 14px;
text-align: center;
}
.chat-workspace-rail__path {
padding: 9px 12px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 42%, transparent);
-14
View File
@@ -182,20 +182,6 @@ openclaw-chat-pane {
-webkit-backdrop-filter: blur(10px);
}
/* The drop container spans the workspace rail column, so the default 16px
inset parks the 28px opener flush against the collapsed 44px rail's left
border. Center it in the rail instead ((44 - 28) / 2). Media guard matches
the rail's render breakpoint: the collapsed class stays on the workbench
while the rail is display:none below 1121px. */
@media (min-width: 1121px) {
.chat-split-view__drop-container:has(
.chat-workbench--workspace-collapsed:not(.chat-workbench--dock-bottom)
)
.chat-open-split-view {
right: 8px;
}
}
@media (max-width: 1099px) {
.chat-open-split-view {
display: none;
+132 -302
View File
@@ -7,10 +7,9 @@
--shell-gap: 16px;
--shell-nav-expanded-width: 258px;
--shell-nav-width: var(--shell-nav-expanded-width);
/* The topbar owns brand, navigation, and global actions on desktop; the nav
column below it is the sessions panel. Narrow viewports keep their own
row height (layout.mobile.css). */
--shell-topbar-height: 48px;
/* Desktop has no topbar row the sidebar owns navigation. Narrow viewports
(layout.mobile.css) and the chat split toolbar restore the row height. */
--shell-topbar-height: 0px;
--shell-focus-duration: 200ms;
--shell-focus-ease: var(--ease-out);
height: 100vh;
@@ -18,7 +17,7 @@
grid-template-columns: var(--shell-nav-width) minmax(0, 1fr);
grid-template-rows: var(--shell-topbar-height) 1fr;
grid-template-areas:
"topbar topbar"
"nav topbar"
"nav content";
gap: 0;
animation: dashboard-enter 0.3s var(--ease-out);
@@ -38,9 +37,9 @@
overflow: hidden;
}
/* Collapsing hides the sessions panel entirely navigation lives in the
topbar, so there is no icon rail to fall back to. Keep --shell-nav-width in
sync for fixed-position consumers (e.g. the docked terminal panel). */
/* Collapsing hides the sidebar entirely; a floating expand affordance
(.shell-nav-expand) and Cmd+B bring it back. Keep --shell-nav-width in sync
for fixed-position consumers (e.g. the docked terminal panel). */
.shell--nav-collapsed {
grid-template-columns: 0 minmax(0, 1fr);
--shell-nav-width: 0px;
@@ -54,11 +53,62 @@
}
}
/* Floating expand control shown while the sidebar is hidden. On native macOS
it sits below the AppKit drag regions (x 78-254, y < 46) so clicks reach it. */
.shell-nav-expand {
position: fixed;
top: 10px;
left: 10px;
z-index: 45;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--panel) 92%, transparent);
color: var(--muted);
box-shadow: var(--shadow-sm);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.shell-nav-expand:hover {
background: color-mix(in srgb, var(--bg-hover) 92%, transparent);
color: var(--text);
}
.shell-nav-expand:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.shell-nav-expand svg {
width: 17px;
height: 17px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (max-width: 1100px) {
.shell-nav-expand {
display: none;
}
}
html.openclaw-native-macos .shell-nav-expand {
top: 52px;
}
.shell--onboarding {
grid-template-columns: 0 minmax(0, 1fr);
grid-template-rows: 0 1fr;
--shell-nav-width: 0px;
--shell-topbar-height: 0px;
}
.shell--onboarding .topbar {
@@ -87,6 +137,7 @@
@media (min-width: 1101px) {
.shell--settings {
grid-template-columns: var(--shell-settings-nav-width) minmax(0, 1fr);
grid-template-rows: 0 1fr;
--shell-nav-width: var(--shell-settings-nav-width);
}
@@ -125,10 +176,7 @@
background: color-mix(in srgb, var(--panel) 98%, white 2%);
}
/* Renamed from __header: shipped Mac apps inject 50px titlebar padding onto
.settings-sidebar__header from before the shell had a topbar row; the new
name keeps that stale override from double-padding this surface. */
.settings-sidebar__head {
.settings-sidebar__header {
flex-shrink: 0;
padding: 14px 12px 6px;
}
@@ -436,15 +484,16 @@
Topbar
=========================================== */
/* The topbar is the app's chrome: brand + primary navigation + global
actions. Narrow viewports slim it down to drawer toggle + brand + search
(layout.mobile.css). */
/* Hidden by default: on desktop the sidebar owns navigation, so no topbar row
renders. Narrow viewports show it as the drawer/search header
(layout.mobile.css), and desktop chat split view brings it back as the
backdrop for the fixed split toolbar (see below). */
.topbar {
grid-area: topbar;
position: sticky;
top: 0;
z-index: 40;
display: flex;
display: none;
align-items: center;
padding: 0 12px;
min-height: var(--shell-topbar-height);
@@ -454,32 +503,6 @@
-webkit-backdrop-filter: blur(12px) saturate(1.6);
}
/* Native macOS Mac app: AppKit traffic lights sit over the topbar's left edge
and a native drag region floats over x78-254px (DashboardWindowController).
Inset the bar past the window controls and stretch the non-interactive
brand across the drag strip so every clickable control stays reachable. */
@media (min-width: 1101px) {
html.openclaw-native-macos .topbar {
padding-left: 78px;
}
html.openclaw-native-macos .topbar-brand {
min-width: 176px;
}
}
/* Narrow Mac windows: the slide-over drawer is fixed at top: 0 and covers the
native titlebar area, so its first controls need their own clearance from
the AppKit traffic lights and drag regions (the topbar inset above only
protects the desktop layout). Covers the app drawer and the settings
sidebar that rides inside the drawer below the breakpoint. */
@media (max-width: 1100px) {
html.openclaw-native-macos .shell-nav .sidebar-shell,
html.openclaw-native-macos .shell-nav .settings-sidebar__head {
padding-top: max(18px, var(--openclaw-native-titlebar-height, 50px));
}
}
.topnav-shell {
display: flex;
align-items: center;
@@ -498,21 +521,19 @@
align-items: center;
gap: 6px;
flex-shrink: 0;
margin-left: auto;
}
.topnav-shell__content {
min-width: 0;
flex: 1;
}
.topbar-brand {
display: flex;
display: none;
align-items: center;
gap: 8px;
justify-content: center;
gap: 7px;
min-width: 0;
flex-shrink: 0;
}
.topbar-brand__logo-wrap {
position: relative;
display: inline-flex;
flex: 0 0 auto;
}
.topbar-brand__logo {
@@ -522,26 +543,6 @@
border-radius: var(--radius-md);
}
/* Connection state rides on the brand mark instead of spending a whole
footer row on a status dot. */
.topbar-brand__status {
position: absolute;
right: -2px;
bottom: -2px;
width: 8px;
height: 8px;
border-radius: var(--radius-full);
box-shadow: 0 0 0 2px var(--bg);
}
.topbar-brand__status--online {
background: var(--ok);
}
.topbar-brand__status--offline {
background: var(--danger);
}
.topbar-brand__title {
min-width: 0;
overflow: hidden;
@@ -563,183 +564,22 @@
height: 20px;
}
/* Primary navigation pills. Scrolls instead of clipping so the trailing
"More" button stays reachable however many routes are pinned. */
.topbar-nav {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.topbar-nav::-webkit-scrollbar {
display: none;
}
.topnav-item {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 32px;
padding: 0 11px;
border: 1px solid transparent;
border-radius: var(--radius-full);
background: transparent;
color: var(--muted);
cursor: default;
font: inherit;
font-size: 13px;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
flex-shrink: 0;
transition:
border-color var(--duration-fast) ease,
background var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.topnav-item:hover {
color: var(--text);
background: color-mix(in srgb, var(--bg-hover) 84%, transparent);
text-decoration: none;
}
.topnav-item:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.topnav-item--active {
color: var(--text-strong);
background: color-mix(in srgb, var(--accent-subtle) 88%, var(--bg-elevated) 12%);
border-color: color-mix(in srgb, var(--accent) 16%, transparent);
}
.topnav-item .nav-item__icon {
width: 15px;
height: 15px;
opacity: 0.72;
}
.topnav-item .nav-item__icon svg {
width: 15px;
height: 15px;
}
.topnav-item:hover .nav-item__icon,
.topnav-item--active .nav-item__icon {
opacity: 1;
}
.topnav-item--active .nav-item__icon {
color: var(--accent);
}
.topnav-item__chevron {
display: inline-flex;
opacity: 0.5;
}
.topnav-item__chevron svg {
width: 12px;
height: 12px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Dropdowns anchored to the topbar (More menu). */
.topbar-menu {
position: fixed;
z-index: 120;
min-width: 224px;
max-width: 264px;
max-height: min(480px, calc(100vh - 64px));
overflow-y: auto;
padding: 6px;
border: 1px solid color-mix(in srgb, var(--border-strong) 78%, transparent);
border-radius: var(--radius-lg);
background: var(--bg-elevated);
box-shadow: 0 18px 40px color-mix(in srgb, black 26%, transparent);
}
.topbar-menu__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 30px;
padding: 0 8px;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
text-align: left;
text-decoration: none;
cursor: default;
}
/* External entries (Docs) are real links and keep the pointer hand. */
.topbar-menu__item[target] {
cursor: pointer;
}
.topbar-menu__item:hover,
.topbar-menu__item:focus-visible {
background: color-mix(in srgb, var(--bg-hover) 84%, transparent);
text-decoration: none;
}
.topbar-menu__item:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.topbar-menu__item--active {
color: var(--text-strong);
}
.topbar-menu__item--active .nav-item__icon {
color: var(--accent);
opacity: 1;
}
.topbar-menu__item .nav-item__external-icon {
opacity: 0;
}
.topbar-menu__item:hover .nav-item__external-icon {
opacity: 0.5;
}
.topbar-menu__separator {
margin: 6px 4px;
border-top: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
}
.topbar-search {
display: inline-flex;
display: none;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 0 12px;
gap: 12px;
min-height: 38px;
padding: 0 14px;
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--bg-elevated) 84%, transparent);
color: var(--muted);
font-size: 12.5px;
font-size: 13px;
transition:
border-color var(--duration-fast) ease,
background var(--duration-fast) ease,
color var(--duration-fast) ease;
min-width: 200px;
}
.topbar-search:hover {
@@ -754,31 +594,14 @@
}
.topbar-search svg {
width: 15px;
height: 15px;
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 1.7px;
stroke-linecap: round;
stroke-linejoin: round;
}
.topbar-search__label {
min-width: 72px;
text-align: left;
}
.topbar-search__kbd {
padding: 1px 5px;
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--card) 70%, transparent);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 10px;
font-weight: 550;
letter-spacing: 0.04em;
}
.topbar-icon-btn {
display: inline-flex;
align-items: center;
@@ -791,10 +614,6 @@
border-radius: var(--radius-md);
background: transparent;
color: var(--muted);
text-decoration: none;
/* App-internal control (also rendered as <a> for Settings): default arrow,
the pointer hand is reserved for real links. */
cursor: default;
transition:
background var(--duration-fast) ease,
color var(--duration-fast) ease;
@@ -803,7 +622,6 @@
.topbar-icon-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--bg-hover) 84%, transparent);
color: var(--text);
text-decoration: none;
}
.topbar-icon-btn:focus-visible {
@@ -811,15 +629,6 @@
box-shadow: var(--focus-ring);
}
.topbar-icon-btn:disabled {
cursor: not-allowed;
opacity: 0.42;
}
.topbar-icon-btn--active {
color: var(--accent);
}
/* Without explicit sizing/stroke the Lucide-style icon renders as a filled
blob at the browser's default svg size (e.g. the terminal toggle glyph). */
.topbar-icon-btn svg {
@@ -832,10 +641,6 @@
stroke-linejoin: round;
}
.topbar-mode-switch {
display: inline-flex;
}
.topbar .topbar-nav-toggle {
display: none;
}
@@ -899,29 +704,6 @@
box-shadow: none;
}
/* Desktop sessions panel: sessions list + resident pet, nothing else. Kept
apart from .sidebar-shell so the Mac app's drawer titlebar padding does not
apply here (the topbar already clears the window controls). */
.sidebar-panel {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
padding: 12px 10px 10px;
}
.sidebar-panel .sidebar-sessions {
margin-top: 0;
}
/* Slim ledge for the lobster pet: both its perch modes (ledge above the
divider, bar inside the strip) need a footer bar to anchor to. */
.sidebar-panel__footer {
flex-shrink: 0;
min-height: 26px;
border-top: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
}
.sidebar-brand {
display: flex;
align-items: center;
@@ -957,6 +739,55 @@
white-space: nowrap;
}
.sidebar-brand__actions {
display: flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.sidebar-brand__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--muted);
transition:
background var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.sidebar-brand__icon:hover:not(:disabled),
.sidebar-brand__icon:focus-visible {
background: color-mix(in srgb, var(--bg-hover) 84%, transparent);
color: var(--text);
}
.sidebar-brand__icon:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.sidebar-brand__icon:disabled {
cursor: not-allowed;
opacity: 0.42;
}
.sidebar-brand__icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 1.7px;
stroke-linecap: round;
stroke-linejoin: round;
}
.sidebar-shell__footer {
flex-shrink: 0;
}
@@ -1916,8 +1747,7 @@ openclaw-native-link-menu[popover] {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 14%, transparent);
}
.sidebar-pair-mobile svg *,
.topbar-pair-mobile svg * {
.sidebar-pair-mobile svg * {
stroke: currentColor;
fill: none;
}
+70 -14
View File
@@ -140,36 +140,34 @@
display: none;
}
/* Show the hamburger toggle at the same breakpoint where the drawer takes
over; the drawer owns navigation here, so the desktop nav pills, sessions
panel toggle, and global action buttons leave the bar. */
/* Show the hamburger toggle at the same breakpoint where the drawer takes over;
the desktop rail toggle makes no sense once the sidebar is a drawer. */
.topbar .topbar-nav-toggle {
display: inline-flex;
}
.topbar {
display: flex;
}
.topnav-shell {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) 40px;
gap: 10px;
}
/* openclaw-tooltip wrappers are display:contents (inline style), so hide
the wrapped controls directly the wrappers contribute no boxes. */
.topbar-nav,
.topbar-panel-toggle,
.topnav-shell__actions .topbar-action,
.topnav-shell__actions .topbar-mode-switch {
display: none;
.topnav-shell__content {
display: flex;
justify-content: center;
}
.topbar-brand {
justify-content: center;
display: flex;
}
.topnav-shell__actions {
min-width: 0;
justify-content: flex-end;
margin-left: 0;
}
.topbar-search {
@@ -182,8 +180,7 @@
padding: 0;
}
.topbar-search__label,
.topbar-search__kbd {
.sidebar-brand__actions {
display: none;
}
@@ -236,6 +233,40 @@
flex: 0 0 auto;
width: auto;
}
}
/* Native macOS narrow widths (e.g. the in-app link browser splitting the
window): fold the titlebar clearance into one 58px row instead of stacking
it. body-qualified + !important outranks the Mac-app-injected topbar rules
(DashboardWindowController), so shipped apps get the compact row too. The
brand moves first and stays non-interactive under the AppKit drag region
(x 78-254, y < 46); the drawer toggle and search sit to its right. */
@media (max-width: 1100px) {
html.openclaw-native-macos body .shell {
--shell-topbar-height: 58px;
}
html.openclaw-native-macos body .topbar {
padding: 0 12px 0 78px !important;
}
html.openclaw-native-macos body .topnav-shell {
display: flex;
gap: 8px;
min-height: 58px;
}
html.openclaw-native-macos body .topnav-shell__content {
order: -1;
flex: 0 0 auto;
min-width: 176px;
justify-content: flex-start;
}
html.openclaw-native-macos body .topnav-shell__actions {
margin-left: auto;
}
}
/* Mobile-specific styles */
@@ -247,6 +278,7 @@
/* Topbar */
.topbar {
display: flex;
padding: 10px 12px;
min-height: auto;
}
@@ -255,10 +287,34 @@
gap: 10px;
}
.topnav-shell__actions {
min-width: 0;
justify-content: flex-end;
}
.topnav-shell__content {
display: flex;
justify-content: center;
}
.topbar-brand {
display: flex;
}
.topbar .topbar-nav-toggle {
display: inline-flex;
}
.topbar-search {
display: inline-flex;
justify-content: center;
width: 38px;
min-width: 38px;
height: 38px;
min-height: 38px;
padding: 0;
}
.sidebar-mode-switch {
display: block;
}
+1 -2
View File
@@ -4,8 +4,7 @@
============================================ */
/* The footer bar is the pet's ledge. */
.sidebar-shell__footer,
.sidebar-panel__footer {
.sidebar-shell__footer {
position: relative;
}