mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(ui): one job per sidebar zone — shell chrome controls, header presence, slim footer (#112936)
* refactor(ui): one job per sidebar zone — move search/collapse to shell chrome, presence to chat header, slim footer * test(ui): point drawer-collapse e2e step at shell chrome nav toggle * refactor(ui): unexport lobsterLookStyleVars after logo stand-in removal * docs(web): describe shell chrome controls, slim footer, and header presence facepile * fix(ui): keep cluster search on native-nav hosts; draft-aware collapsed new-thread
This commit is contained in:
committed by
GitHub
parent
b5232bc4dd
commit
1e5096c519
@@ -227,7 +227,7 @@ The **+** in the sidebar session-list header opens a full-page draft at `/new`:
|
||||
|
||||
Inside **Settings**, the dedicated sidebar includes **Ask OpenClaw** and starts with a **Search settings** field for quickly finding settings sections.
|
||||
|
||||
A **Search** field at the top of the sidebar opens the command palette (⌘K). Clicking the agent identity row at the top of the sidebar opens the agent menu; **Home** opens the main session. When something needs action — failed or overdue cron jobs, expiring or expired model auth — compact attention chips appear above the sidebar footer and click through to the owning page. The identity row shows the agent's avatar (identity image or emoji), name, connection dot, and a live subtitle. Clicking it opens the agent menu: an agent switcher (multi-agent setups), "What can this agent do?", **Agent settings**, **Settings**, mobile pairing, **Docs**, the build chip, and the color-mode toggle. Rosters above ten agents get a filter field and list pinned agents first; pin or unpin agents from the Agents settings page, with the pinned set stored in the browser profile. Choosing an agent scopes Chat plus Usage, Automations, Tasks, Workboard, and Sessions to that agent. Each scoped page exposes an **Agent** control with **All agents** as an escape; this widens the shared page scope without changing the concrete chat agent, while direct session links still open their target. The Agents settings page keeps its own `?agent=` selection and does not follow the shared page scope. The footer bar holds the product logo, the build chip, a gateway connection dot, and a Settings shortcut. When the gateway runs from a source checkout on a branch other than `main`, the footer also shows that branch name in red so a non-release gateway is obvious at a glance (release installs never show it). Shift-Command-Comma opens **Settings** without overriding the browser's Command-Comma shortcut. 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 macOS app hosts that toggle natively in the titlebar instead. 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; on phones, Chat absorbs that navigation row into its title bar, with the menu and search controls beside the session title. In the macOS app the separate 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 a native sidebar toggle next to the window controls plus trackpad swipe gestures, with back/forward buttons at the sidebar's right edge while it is expanded and native search (command palette) and new-session buttons while it is collapsed.
|
||||
On desktop web, a fixed control cluster at the top-left of the content area — the web counterpart of the macOS titlebar strip — holds the sidebar collapse toggle (⌘B) and the command-palette search button (⌘K). Clicking the agent identity row at the top of the sidebar opens the agent menu; **Home** opens the main session. When something needs action — failed or overdue cron jobs, expiring or expired model auth — compact attention chips appear above the sidebar footer and click through to the owning page. The identity row shows the agent's avatar (identity image or emoji), name, connection dot, and a live subtitle. Clicking it opens the agent menu: an agent switcher (multi-agent setups), "What can this agent do?", **Agent settings**, **Settings**, mobile pairing, **Docs**, the build chip, and the color-mode toggle. Rosters above ten agents get a filter field and list pinned agents first; pin or unpin agents from the Agents settings page, with the pinned set stored in the browser profile. Choosing an agent scopes Chat plus Usage, Automations, Tasks, Workboard, and Sessions to that agent. Each scoped page exposes an **Agent** control with **All agents** as an escape; this widens the shared page scope without changing the concrete chat agent, while direct session links still open their target. The Agents settings page keeps its own `?agent=` selection and does not follow the shared page scope. The footer bar holds your identity (avatar and name, opening your profile settings) and a Settings shortcut; a connection status pill appears only while the gateway is offline. Build and version details live in the agent menu's build chip and the About page. When the gateway runs from a source checkout on a branch other than `main`, the footer also shows that branch name in red so a non-release gateway is obvious at a glance (release installs never show it). Shift-Command-Comma opens **Settings** without overriding the browser's Command-Comma shortcut. Collapsing the sidebar (⌘B or the cluster's toggle) hides it entirely for a full-width workspace; while collapsed, the top-left cluster keeps the expand toggle and search and gains a new-thread button — mirroring what the macOS app hosts natively in its titlebar. 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; on phones, Chat absorbs that navigation row into its title bar, with the menu and search controls beside the session title. In the macOS app the separate 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 a native sidebar toggle next to the window controls plus trackpad swipe gestures, with back/forward buttons at the sidebar's right edge while it is expanded and native search (command palette) and new-session buttons while it is collapsed.
|
||||
|
||||
Pending approvals also contribute an attention chip above the sidebar footer;
|
||||
select it to open the owning Approvals page.
|
||||
@@ -419,10 +419,11 @@ The macOS app keeps its native link-browser sidebar for links clicked in the das
|
||||
- Live `chat` events are delivery state, while `chat.history` is rebuilt from the durable session transcript. After tool-final events the Control UI reloads history and merges only a small optimistic tail; the transcript boundary is documented in [WebChat](/web/webchat).
|
||||
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
|
||||
- The sidebar lists every loaded active session by agent section and pinned/channel/work/custom/Chats buckets with a single New Session action that opens the draft dialog. Opening a visible row moves only the highlight. Sessions can be dropped onto Pinned to pin them, or onto a custom group or Chats to move them; custom groups are collapsible and drag-reorderable, group names and order sync through the gateway, and collapsed state stays in the browser. A new dashboard session asynchronously gets a concise generated title from its first non-command message; explicit names and authenticated sender identity remain separate, so account names are never used as generated titles. Set `agents.defaults.utilityModel` (or `agents.entries.*.utilityModel`) to route this separate model call to a lower-cost model; if that distinct model fails, title generation retries once with the primary model. Expanding another agent section browses that agent's sessions without leaving the open chat.
|
||||
- Thread 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 Threads page keeps the exhaustive searchable list with filters.
|
||||
- Thread search lives in the command palette (⌘K, or the search button in the top-left control cluster): 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 Threads 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. Multi-selected rows (Cmd/Ctrl-click, Shift-click for ranges) get a batch menu covering unread state, grouping, archive, and delete; batch archive/delete stays disabled unless every selected session is archivable. 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 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.
|
||||
- The session header shows a small facepile beside the workspace chip when other people are viewing the same session; it lists up to four viewer avatars with an overflow count and disappears when you are alone.
|
||||
- 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.
|
||||
- User-message bubbles carry transcript actions: a hover rewind button (confirm popover with a "Don't ask again" option) plus right-click **Rewind to here** and **Fork from here**. Rewind repoints the session to the state just before that message and returns its text to the composer for edit and resend (`sessions.rewind`, `operator.admin`); fork creates a new session from the active-path prefix before the message, opens it, and seeds its composer with the same text (`sessions.fork`, `operator.write`). Both actions disable with an explanatory tooltip while the agent is working, apply only to persisted user messages, and are rejected for sessions whose conversation is owned by an external agent harness. Rewind moves chat context only — files and other tool side effects are not reverted — and the pre-rewind transcript remains preserved in the append-only session store. When that store contains multiple transcript branches, the chat title bar shows a branch menu with each branch's latest message, message count, and recency; selecting an inactive branch switches the current session back to that preserved path (`sessions.branches.list`, `operator.read`; `sessions.branches.switch`, `operator.admin`). Branch switching is also unavailable while the agent is working, and selecting the already-active branch is a typed no-op error at the RPC boundary. The separate hide action on user bubbles hides a message in the current browser only; the message stays in the transcript and the agent still sees it.
|
||||
- When a session's checkout sits on a non-default branch of a GitHub repository, the chat view pins pull request chips above the composer: PR number, repo, branch, diff counts, a CI pill, and draft/merged/closed state, each linking to the PR. The row shows at most two chips — live (open/draft) PRs first — and a "Show more" button reveals collapsed merged/closed history. The CI pill opens a small CI monitoring popover with passed/failed/running/skipped check counts and a link to the PR's checks page. Detection runs server-side through `controlUi.sessionPullRequests`, which reuses the Gateway's `GH_TOKEN`/`GITHUB_TOKEN` when set. When the GitHub API rate limit is hit, chips keep the last known status and show a warning that the status may be out of date; dismissing a chip hides it for that session in the current browser profile. Before any PR exists, the row shows the branch itself — repo, branch name, and the +/− size of the diff against the default-branch merge base (committed and uncommitted work). Once the pushed branch has commits to compare, the row adds a Create PR button that opens GitHub's new-pull-request page; before that, a session with changed files (committed, uncommitted, or untracked) still gets the row without the button. The row hides itself while an open or draft PR exists. The branch row comes from local git only, so it stays available while GitHub is rate limited and carries the same stale-status warning, since "no PR found" cannot be trusted until the limit resets.
|
||||
|
||||
+62
-21
@@ -54,6 +54,7 @@ import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
|
||||
import { createIdleImport } from "../lib/idle-import.ts";
|
||||
import { isWorkboardEnabledInConfigSnapshot } from "../lib/plugin-activation.ts";
|
||||
import { searchForSession } from "../lib/sessions/index.ts";
|
||||
import { normalizeAgentId } from "../lib/sessions/session-key.ts";
|
||||
import "../lib/toast.ts";
|
||||
import { isTerminalAvailable } from "../lib/terminal-availability.ts";
|
||||
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
|
||||
@@ -125,6 +126,9 @@ type AppSidebarElement = HTMLElement & {
|
||||
const ROUTE_IDS_WITHOUT_WORKBOARD = APP_ROUTE_IDS.filter((routeId) => routeId !== "workboard");
|
||||
const AGENT_ROSTER_REFRESH_DEBOUNCE_MS = 100;
|
||||
const EMPTY_OUTBOX_COUNT_FOR_SESSION = () => 0;
|
||||
const PALETTE_SHORTCUT = /Mac|iP(hone|ad|od)/i.test(globalThis.navigator?.platform ?? "")
|
||||
? "⌘K"
|
||||
: "Ctrl K";
|
||||
|
||||
type StoredOutboxScopeHost = {
|
||||
settings: { gatewayUrl?: string | null };
|
||||
@@ -938,7 +942,7 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
});
|
||||
if (nextNavCollapsed) {
|
||||
void this.updateComplete.then(() => {
|
||||
this.restoreFocusTo(this.querySelector<HTMLElement>(".shell-nav-expand"));
|
||||
this.restoreFocusTo(this.querySelector<HTMLElement>(".shell-chrome-controls__nav-toggle"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -987,6 +991,10 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
context.navigation.update({ navWidth });
|
||||
}
|
||||
|
||||
private openNewSession(agentId: string, target?: NewSessionTarget) {
|
||||
this.navigate("new-session", { search: newSessionSearch(agentId, target) });
|
||||
}
|
||||
|
||||
// Shipped Mac app builds without web chrome still drive these handlers.
|
||||
private readonly handleNativeToggleSidebar = () => {
|
||||
this.toggleNavigationSurface();
|
||||
@@ -1018,9 +1026,7 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
return;
|
||||
}
|
||||
const agentId = context.agentSelection.state.selectedId ?? "";
|
||||
this.navigate("new-session", {
|
||||
search: agentId ? `?agent=${encodeURIComponent(agentId)}` : "",
|
||||
});
|
||||
this.openNewSession(agentId);
|
||||
};
|
||||
|
||||
private readonly handleNativeHistoryState = (event: Event) => {
|
||||
@@ -1585,6 +1591,12 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
mobileNavLayout,
|
||||
});
|
||||
const shellWidth = Math.max(globalThis.innerWidth || 0, NAV_WIDTH_MAX);
|
||||
// Mirror the sidebar brand action: an open new-session draft wins over the
|
||||
// persisted selection so the collapsed cluster "+" targets the same agent.
|
||||
const selectedAgentId = normalizeAgentId(
|
||||
this.draftSessionAgentId() ||
|
||||
(context.agentSelection.state.selectedId ?? gatewaySnapshot.assistantAgentId),
|
||||
);
|
||||
// One storage read per render; theme.refresh() re-renders on pref changes.
|
||||
const uiSettings = loadSettings();
|
||||
// The new-session draft shares the chat layout: full-height pane that owns
|
||||
@@ -1648,19 +1660,52 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
.onOpenPalette=${this.openPalette}
|
||||
.onToggleDrawer=${(trigger: HTMLElement) => this.toggleNavigationSurface(trigger)}
|
||||
></openclaw-app-topbar>
|
||||
${navCollapsed && !onboarding
|
||||
${!onboarding && !settingsTakeover && !mobileNavLayout
|
||||
? 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()}
|
||||
<div class="shell-chrome-controls">
|
||||
<openclaw-tooltip
|
||||
.content=${`${t(navCollapsed ? "nav.expand" : "nav.collapse")} (⌘B)`}
|
||||
>
|
||||
${icons.panelLeftOpen}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
<button
|
||||
type="button"
|
||||
class="shell-chrome-controls__button shell-chrome-controls__nav-toggle"
|
||||
aria-label=${t(navCollapsed ? "nav.expand" : "nav.collapse")}
|
||||
aria-expanded=${navCollapsed ? "false" : "true"}
|
||||
@click=${() => this.toggleNavigationSurface()}
|
||||
>
|
||||
${navCollapsed ? icons.panelLeftOpen : icons.panelLeftClose}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
${navCollapsed
|
||||
? html`<openclaw-tooltip
|
||||
.content=${gatewaySnapshot.connected
|
||||
? t("chat.runControls.newSession")
|
||||
: t("chat.runControls.newSessionDisconnected")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="shell-chrome-controls__button shell-chrome-controls__new-thread"
|
||||
aria-label=${t("chat.runControls.newSession")}
|
||||
?disabled=${!gatewaySnapshot.connected}
|
||||
@click=${() => this.openNewSession(selectedAgentId)}
|
||||
>
|
||||
${icons.plus}
|
||||
</button>
|
||||
</openclaw-tooltip>`
|
||||
: nothing}
|
||||
<openclaw-tooltip
|
||||
.content=${`${t("chat.openCommandPalette")} (${PALETTE_SHORTCUT})`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="shell-chrome-controls__button shell-chrome-controls__search"
|
||||
aria-label=${t("chat.openCommandPalette")}
|
||||
@click=${this.openPalette}
|
||||
>
|
||||
${icons.search}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<div class="shell-nav">
|
||||
@@ -1726,14 +1771,10 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
.updateAvailable=${navigationSurfaceHidden ? null : overlaySnapshot.updateAvailable}
|
||||
.updateRunning=${overlaySnapshot.updateRunning}
|
||||
.onUpdate=${() => void context.overlays.runUpdate()}
|
||||
.onOpenPalette=${this.openPalette}
|
||||
.onOpenApprovals=${this.openApprovals}
|
||||
.onToggleSidebar=${() => this.toggleNavigationSurface()}
|
||||
.onRetryConnect=${() => context.gateway.connect()}
|
||||
.onOpenNewSession=${(agentId: string, target?: NewSessionTarget) => {
|
||||
const search = newSessionSearch(agentId, target);
|
||||
this.navigate("new-session", { search });
|
||||
}}
|
||||
.onOpenNewSession=${(agentId: string, target?: NewSessionTarget) =>
|
||||
this.openNewSession(agentId, target)}
|
||||
.draftSessionAgentId=${this.draftSessionAgentId()}
|
||||
.onUpdateSidebarEntries=${(entries: string[]) =>
|
||||
context.navigation.update({ sidebarEntries: entries })}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { openExternalUrlSafe } from "../lib/open-external-url.ts";
|
||||
import { normalizeAgentId } from "../lib/sessions/session-key.ts";
|
||||
import { renderAgentSelectAvatar, renderAgentSelectCopy } from "./agent-select.ts";
|
||||
import { icons, type IconName } from "./icons.ts";
|
||||
import "./sidebar-build-chip.ts";
|
||||
import {
|
||||
consumeDropdownKeyboardDismissal,
|
||||
syncDropdownItemRadio,
|
||||
|
||||
@@ -45,9 +45,7 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement {
|
||||
@property({ attribute: false }) updateAvailable: UpdateAvailable | null = null;
|
||||
@property({ attribute: false }) updateRunning = false;
|
||||
@property({ attribute: false }) onUpdate: () => void = () => undefined;
|
||||
@property({ attribute: false }) onOpenPalette?: () => void;
|
||||
@property({ attribute: false }) onOpenApprovals?: () => void;
|
||||
@property({ attribute: false }) onToggleSidebar?: () => void;
|
||||
@property({ attribute: false }) onRetryConnect?: () => void;
|
||||
@property({ attribute: false }) onOpenNewSession?: (
|
||||
agentId: string,
|
||||
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
} from "../app-navigation.ts";
|
||||
import { pathForRoute } from "../app-route-paths.ts";
|
||||
import { sessionHasPendingApproval } from "../app/approval-presentation.ts";
|
||||
import { controlUiPublicAssetPath } from "../app/public-assets.ts";
|
||||
import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts";
|
||||
import { CONTROL_UI_BUILD_INFO } from "../build-info.ts";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { normalizeAgentLabel, resolveAgentTextAvatar } from "../lib/agents/display.ts";
|
||||
import { resolveAgentAvatarUrl } from "../lib/avatar.ts";
|
||||
@@ -22,48 +20,22 @@ import type { AppSidebarSessionNavigationElement } from "./app-sidebar-session-n
|
||||
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
|
||||
import type { SidebarWorkboardBoard } from "./app-sidebar-workboard.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
import type { LobsterLogoVisitDetail } from "./lobster-pet-contract.ts";
|
||||
import { redactLoginFailureError } from "./login-gate.ts";
|
||||
import { renderOfflineSidebarStatus, renderSessionRowBadges } from "./session-row-badges.ts";
|
||||
|
||||
const PALETTE_SHORTCUT = /Mac|iP(hone|ad|od)/i.test(globalThis.navigator?.platform ?? "")
|
||||
? "⌘K"
|
||||
: "Ctrl K";
|
||||
|
||||
type AppSidebarRenderHost = AppSidebarSessionNavigationElement & {
|
||||
activePluginTabId: string;
|
||||
activeWorkboardBoardId: string;
|
||||
offline: boolean;
|
||||
queuedOutboxCount: number;
|
||||
lastError: string | null;
|
||||
gatewayVersion: string | null;
|
||||
onOpenPalette?: () => void;
|
||||
onOpenApprovals?: () => void;
|
||||
onToggleSidebar?: () => void;
|
||||
onRetryConnect?: () => void;
|
||||
getRouteSessionKey(): string;
|
||||
renderPinnedSidebarSession(session: SidebarRecentSession): unknown;
|
||||
};
|
||||
|
||||
function renderSearch(host: AppSidebarRenderHost) {
|
||||
const tooltip = `${t("chat.openCommandPalette")} (${PALETTE_SHORTCUT})`;
|
||||
return html`
|
||||
<openclaw-tooltip .content=${tooltip}>
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-brand__icon sidebar-search"
|
||||
?disabled=${!host.onOpenPalette}
|
||||
aria-label=${t("chat.openCommandPalette")}
|
||||
@click=${() => host.onOpenPalette?.()}
|
||||
>
|
||||
${icons.search}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderAppSidebarBrand(host: AppSidebarRenderHost) {
|
||||
const collapseLabel = t("nav.collapse");
|
||||
const { activeId: cardAgentId, agent: cardAgent, agents: cardAgents } = host.activeChipAgent();
|
||||
const menuUnread = cardAgents.some((entry) => {
|
||||
const agentId = normalizeAgentId(entry.id);
|
||||
@@ -105,18 +77,6 @@ export function renderAppSidebarBrand(host: AppSidebarRenderHost) {
|
||||
${icons.plus}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
${renderSearch(host)}
|
||||
<openclaw-tooltip .content=${`${collapseLabel} (⌘B)`}>
|
||||
<button
|
||||
class="sidebar-brand__icon sidebar-brand__collapse"
|
||||
type="button"
|
||||
@click=${() => host.onToggleSidebar?.()}
|
||||
aria-label=${collapseLabel}
|
||||
aria-expanded="true"
|
||||
>
|
||||
${icons.panelLeftClose}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -214,10 +174,7 @@ export function renderAppSidebarPagesHead(host: AppSidebarRenderHost) {
|
||||
}
|
||||
|
||||
/** Zone 5: product chrome recedes to one slim footer bar. */
|
||||
export function renderAppSidebarFooterBar(
|
||||
host: AppSidebarRenderHost,
|
||||
logoVisit: LobsterLogoVisitDetail | null,
|
||||
) {
|
||||
export function renderAppSidebarFooterBar(host: AppSidebarRenderHost) {
|
||||
const reconnecting = t("connection.reconnecting");
|
||||
const selfUser = host.connected
|
||||
? resolveCurrentSelfUser({
|
||||
@@ -229,15 +186,6 @@ export function renderAppSidebarFooterBar(
|
||||
const selfLabel = selfUser?.name ?? selfUser?.email ?? selfUser?.id;
|
||||
return html`
|
||||
<div class="sidebar-footer-bar">
|
||||
<span class="sidebar-brand__logo-slot sidebar-footer-bar__logo">
|
||||
<img
|
||||
class="sidebar-brand__logo ${logoVisit ? "sidebar-brand__logo--vacated" : ""}"
|
||||
src=${controlUiPublicAssetPath("apple-touch-icon.png", host.basePath)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<openclaw-lobster-logo-standin .visit=${logoVisit}></openclaw-lobster-logo-standin>
|
||||
</span>
|
||||
${selfUser && selfLabel
|
||||
? html`<openclaw-tooltip .content=${selfLabel}>
|
||||
<button
|
||||
@@ -254,19 +202,7 @@ export function renderAppSidebarFooterBar(
|
||||
</button>
|
||||
</openclaw-tooltip>`
|
||||
: nothing}
|
||||
<openclaw-viewer-facepile
|
||||
.presencePayload=${host.sessionData.presencePayload}
|
||||
.selfInstanceId=${host.sessionData.presenceInstanceId}
|
||||
.buildInfo=${CONTROL_UI_BUILD_INFO}
|
||||
.gatewayVersion=${host.gatewayVersion}
|
||||
.maxVisible=${5}
|
||||
variant="footer"
|
||||
></openclaw-viewer-facepile>
|
||||
<openclaw-sidebar-build-chip
|
||||
.basePath=${host.basePath}
|
||||
.gatewayVersion=${host.gatewayVersion}
|
||||
.onNavigate=${(routeId: "about") => host.onNavigate?.(routeId)}
|
||||
></openclaw-sidebar-build-chip>
|
||||
<span class="sidebar-footer-bar__spacer" aria-hidden="true"></span>
|
||||
${host.offline
|
||||
? renderOfflineSidebarStatus({
|
||||
queuedOutboxCount: host.queuedOutboxCount,
|
||||
|
||||
@@ -7,7 +7,6 @@ import "./menu-surface.ts";
|
||||
import "./session-menu.ts";
|
||||
import "./sidebar-agent-card.ts";
|
||||
import "./sidebar-attention.ts";
|
||||
import "./sidebar-build-chip.ts";
|
||||
import "./sidebar-update-card.ts";
|
||||
import "./theme-mode-toggle.ts";
|
||||
import "./tooltip.ts";
|
||||
@@ -44,11 +43,9 @@ import {
|
||||
} from "./app-sidebar-session-types.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
import {
|
||||
LOBSTER_LOGO_VISIT_EVENT,
|
||||
lobsterPetSeed,
|
||||
resolveLobsterPetMode,
|
||||
resolveLobsterRunOutcome,
|
||||
type LobsterLogoVisitDetail,
|
||||
} from "./lobster-pet-contract.ts";
|
||||
import { SessionOrganizerController } from "./session-organizer-controller.ts";
|
||||
import { renderSessionCreatorFilter } from "./session-owner-chip.ts";
|
||||
@@ -78,7 +75,6 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
|
||||
private readonly narrationSubscriptions = this.createNarrationSubscriptions();
|
||||
|
||||
@state() protected catalogProjectGrouping = loadStoredSidebarCatalogGrouping();
|
||||
@state() private logoVisit: LobsterLogoVisitDetail | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -114,8 +110,6 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
|
||||
};
|
||||
},
|
||||
);
|
||||
// The footer pet announces logo stand-in phases through this bubbling event.
|
||||
this.addEventListener(LOBSTER_LOGO_VISIT_EVENT, this.handleLogoVisit as EventListener);
|
||||
}
|
||||
|
||||
private createNarrationSubscriptions(): SubscriptionsController {
|
||||
@@ -218,13 +212,6 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => this.classList.add("sidebar-r")));
|
||||
}
|
||||
|
||||
private readonly handleLogoVisit = (event: Event) => {
|
||||
const detail = (event as CustomEvent<LobsterLogoVisitDetail>).detail;
|
||||
// A lookless visit is a logo scare: the brand mark hides (the img gets
|
||||
// the --vacated class) but no stand-in crab renders in its place.
|
||||
this.logoVisit = detail.phase === "out" ? null : detail;
|
||||
};
|
||||
|
||||
startSessionDrag(session: SidebarRecentSession): void {
|
||||
this.sessionOrganizer.startSessionDrag(session);
|
||||
}
|
||||
@@ -431,7 +418,7 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
|
||||
</div>
|
||||
</openclaw-tooltip>`
|
||||
: nothing}
|
||||
${renderAppSidebarFooterBar(this, this.logoVisit)}
|
||||
${renderAppSidebarFooterBar(this)}
|
||||
</div>
|
||||
</div>
|
||||
${this.sidebarMenus.renderCustomizeMenu()} ${this.sidebarMenus.renderMoreMenu()}
|
||||
|
||||
@@ -76,20 +76,6 @@ export type LobsterPetLook = {
|
||||
glint: string | null;
|
||||
};
|
||||
|
||||
export type LobsterLogoVisitPhase = "in" | "leaving" | "out";
|
||||
|
||||
export type LobsterLogoVisitDetail = {
|
||||
phase: LobsterLogoVisitPhase;
|
||||
// A null look on a non-"out" phase means "hide the logo, render no
|
||||
// stand-in": a ledge visit scared the brand mark away.
|
||||
look: LobsterPetLook | null;
|
||||
name: string | null;
|
||||
};
|
||||
|
||||
// Fired on the pet host whenever the logo stand-in phase changes; the
|
||||
// sidebar owns the brand slot, so the swap renders there, not here.
|
||||
export const LOBSTER_LOGO_VISIT_EVENT = "openclaw-lobster-logo-visit";
|
||||
|
||||
function fnv1a(value: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
|
||||
@@ -407,10 +407,10 @@ export function renderLobsterSvg(
|
||||
export const SPOT_ZONES = { left: [12, 38], right: [60, 84] } as const;
|
||||
|
||||
// Shared inline vars for every surface that renders a look (ledge sprite,
|
||||
// twin, stranger passer, logo stand-in). The seeded glint rides
|
||||
// twin, stranger passer). The seeded glint rides
|
||||
// --lob-glint-seed instead of --lob-glint so the class-driven palette and
|
||||
// offline overrides in lobster-pet.css still out-cascade it.
|
||||
export function lobsterLookStyleVars(look: LobsterPetLook): string[] {
|
||||
function lobsterLookStyleVars(look: LobsterPetLook): string[] {
|
||||
const crusher = look.crusherSide;
|
||||
const clawMul = (side: "left" | "right") =>
|
||||
crusher === null
|
||||
@@ -448,7 +448,6 @@ export function renderLobsterPetScene(args: {
|
||||
look: LobsterPetLook;
|
||||
mode: LobsterPetMode;
|
||||
presence: "out" | "in" | "leaving";
|
||||
logoPerched: boolean;
|
||||
shellVisible: boolean;
|
||||
visitsEnabled: boolean;
|
||||
dismissed: boolean;
|
||||
@@ -583,9 +582,7 @@ export function renderLobsterPetScene(args: {
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
// While the pet is upstairs playing logo, the ledge stays empty - one
|
||||
// crab, two homes, never both at once.
|
||||
const showSprites = args.presence !== "out" && !args.logoPerched;
|
||||
const showSprites = args.presence !== "out";
|
||||
// The shell may outlive the visit while it fades, but dismissal and the
|
||||
// visits setting silence it like everything else.
|
||||
const showShell = args.shellVisible && args.visitsEnabled && !args.dismissed;
|
||||
|
||||
@@ -186,12 +186,6 @@ export const VISIT_FIRST_DELAY_MS = [15_000, 180_000] as const;
|
||||
export const VISIT_STAY_MS = [90_000, 300_000] as const;
|
||||
export const VISIT_GAP_MS = [360_000, 1_080_000] as const;
|
||||
|
||||
// Some ledge visits spook the logo: a beat after the crab settles in, the
|
||||
// brand mark ducks away, and it pops back once the visit ends. Rolled from a
|
||||
// dedicated seeded stream so tests can probe arrivals purely.
|
||||
export const LOGO_SCARE_CHANCE = 0.3;
|
||||
export const LOGO_SCARE_DELAY_MS = 900;
|
||||
|
||||
// Rare-event loads, planned per seed so tests can probe them purely: a molt
|
||||
// load sheds its shell during the first idle act and sizes up one tier; a
|
||||
// twin load brings a mini copycat along on every visit.
|
||||
@@ -203,13 +197,6 @@ export function isLobsterTwinLoad(seed: number): boolean {
|
||||
return mulberry32((seed ^ 0x7715) >>> 0)() < 0.04;
|
||||
}
|
||||
|
||||
// On a logo load the pet's first scheduled visit skips the ledge entirely:
|
||||
// it climbs up top and fills in for the brand logo until the stay ends.
|
||||
// Offline summons still report to the ledge - status duty outranks cosplay.
|
||||
export function isLobsterLogoLoad(seed: number): boolean {
|
||||
return mulberry32((seed ^ 0x1063) >>> 0)() < 0.12;
|
||||
}
|
||||
|
||||
export type LobsterPasserPlan = {
|
||||
kind: LobsterPasserKind;
|
||||
atMs: number;
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
import type { LobsterLogoVisitDetail } from "./lobster-pet-contract.ts";
|
||||
import { lobsterLookStyleVars, renderLobsterSvg } from "./lobster-pet-look.ts";
|
||||
|
||||
class LobsterLogoStandIn extends LitElement {
|
||||
override createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@property({ attribute: false }) visit: LobsterLogoVisitDetail | null = null;
|
||||
|
||||
override render() {
|
||||
const visit = this.visit;
|
||||
if (!visit?.look) {
|
||||
return nothing;
|
||||
}
|
||||
const look = visit.look;
|
||||
const classes = [
|
||||
"sidebar-brand__pet",
|
||||
`lobster-pet--palette-${look.palette.id}`,
|
||||
look.shiny ? "lobster-pet--shiny" : "",
|
||||
visit.phase === "leaving" ? "sidebar-brand__pet--leaving" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const style = lobsterLookStyleVars(look).join(";");
|
||||
return html`
|
||||
<span class=${classes} style=${style} title=${`${visit.name} · filling in for the logo`}
|
||||
>${renderLobsterSvg(look)}</span
|
||||
>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-lobster-logo-standin")) {
|
||||
customElements.define("openclaw-lobster-logo-standin", LobsterLogoStandIn);
|
||||
}
|
||||
@@ -12,14 +12,12 @@ import {
|
||||
resolveLobsterLoadIdentity,
|
||||
} from "./lobster-pet-plans.ts";
|
||||
import {
|
||||
LOBSTER_LOGO_VISIT_EVENT,
|
||||
LOBSTER_PET_PALETTES,
|
||||
createLobsterPetLook,
|
||||
lobsterPetSeed,
|
||||
renderLobsterSvg,
|
||||
resolveLobsterPetMode,
|
||||
resolveLobsterRunOutcome,
|
||||
type LobsterLogoVisitDetail,
|
||||
} from "./lobster-pet.ts";
|
||||
|
||||
type LobsterPetPaletteId = ReturnType<typeof createLobsterPetLook>["palette"]["id"];
|
||||
@@ -792,142 +790,6 @@ describe("lobster pet element", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function trackLogoPhases(element: LobsterPetElement): LobsterLogoVisitDetail[] {
|
||||
const phases: LobsterLogoVisitDetail[] = [];
|
||||
element.addEventListener(LOBSTER_LOGO_VISIT_EVENT, (event) => {
|
||||
phases.push((event as CustomEvent<LobsterLogoVisitDetail>).detail);
|
||||
});
|
||||
return phases;
|
||||
}
|
||||
|
||||
describe("lobster pet logo stand-in", () => {
|
||||
// Seed 70 is a planned logo load, not shy, first arrival ~25s.
|
||||
const LOGO_SEED = 70;
|
||||
|
||||
it("spends the first visit in the brand slot, then returns to the ledge", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
|
||||
const element = createPet(LOGO_SEED);
|
||||
const phases = trackLogoPhases(element);
|
||||
await element.updateComplete;
|
||||
|
||||
const arrived = await advanceUntil(element, () => phases.length > 0, 200_000);
|
||||
expect(arrived).toBe(true);
|
||||
const firstPhase = expectDefined(phases[0], "first logo phase");
|
||||
expect(firstPhase.phase).toBe("in");
|
||||
expect(firstPhase.look).not.toBeNull();
|
||||
expect(firstPhase.name).toBeTruthy();
|
||||
// One crab, two homes: the ledge stays empty while it plays logo.
|
||||
expect(spritePresent(element)).toBe(false);
|
||||
|
||||
const left = await advanceUntil(element, () => phases.some((p) => p.phase === "out"), 400_000);
|
||||
expect(left).toBe(true);
|
||||
expect(phases.map((p) => p.phase)).toEqual(["in", "leaving", "out"]);
|
||||
expect(expectDefined(phases[2], "logo exit phase").look).toBeNull();
|
||||
|
||||
// Logo visits are once per load: the next arrival is a normal ledge perch.
|
||||
const returned = await advanceUntil(element, () => spritePresent(element), 1_300_000);
|
||||
expect(returned).toBe(true);
|
||||
expect(phases.length).toBe(3);
|
||||
});
|
||||
|
||||
it("recalls the stand-in to ledge duty when the gateway drops", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
|
||||
const element = createPet(LOGO_SEED);
|
||||
const phases = trackLogoPhases(element);
|
||||
await advanceUntil(element, () => phases.length > 0, 200_000);
|
||||
expect(phases.at(-1)?.phase).toBe("in");
|
||||
expect(spritePresent(element)).toBe(false);
|
||||
|
||||
element.mode = "offline";
|
||||
await element.updateComplete;
|
||||
expect(phases.at(-1)?.phase).toBe("out");
|
||||
expect(spritePresent(element)).toBe(true);
|
||||
});
|
||||
|
||||
it("sends offline summons to the ledge even on planned logo loads", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
|
||||
const element = createPet(LOGO_SEED, "offline");
|
||||
const phases = trackLogoPhases(element);
|
||||
await element.updateComplete;
|
||||
expect(spritePresent(element)).toBe(true);
|
||||
expect(phases).toEqual([]);
|
||||
});
|
||||
|
||||
it("disabling visits mid-stand-in clears the brand slot immediately", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
|
||||
const element = createPet(LOGO_SEED);
|
||||
const phases = trackLogoPhases(element);
|
||||
await advanceUntil(element, () => phases.length > 0, 200_000);
|
||||
expect(phases.at(-1)?.phase).toBe("in");
|
||||
|
||||
element.visitsEnabled = false;
|
||||
await element.updateComplete;
|
||||
expect(phases.at(-1)?.phase).toBe("out");
|
||||
expect(spritePresent(element)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps unplanned loads on the ledge without logo events", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
|
||||
// Seed 42 also rolls no logo scare on its first arrival, so a full visit
|
||||
// passes with the brand mark untouched.
|
||||
const element = createPet(42);
|
||||
const phases = trackLogoPhases(element);
|
||||
|
||||
await arrive(element);
|
||||
|
||||
expect(spritePresent(element)).toBe(true);
|
||||
expect(phases).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lobster pet logo scare", () => {
|
||||
// Seed 91: a normal ledge load (not a logo load), not shy, first arrival
|
||||
// ~19s, and the first arrival's scare roll hits (~0.07 < 0.3).
|
||||
const SCARE_SEED = 91;
|
||||
|
||||
it("hides the logo without a stand-in mid-visit, restoring after the exit", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
|
||||
const element = createPet(SCARE_SEED);
|
||||
const phases = trackLogoPhases(element);
|
||||
|
||||
const scared = await advanceUntil(element, () => phases.length > 0, 200_000);
|
||||
expect(scared).toBe(true);
|
||||
// Unlike a perch, the crab stays on the ledge while the logo hides.
|
||||
expect(spritePresent(element)).toBe(true);
|
||||
const first = expectDefined(phases[0], "first scare phase");
|
||||
expect(first.phase).toBe("in");
|
||||
expect(first.look).toBeNull();
|
||||
expect(first.name).toBeNull();
|
||||
|
||||
const left = await advanceUntil(element, () => phases.some((p) => p.phase === "out"), 400_000);
|
||||
expect(left).toBe(true);
|
||||
expect(phases.map((p) => p.phase)).toEqual(["in", "leaving", "out"]);
|
||||
});
|
||||
|
||||
it("never scares the logo under reduced motion", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn(() => ({ matches: true }) as MediaQueryList),
|
||||
);
|
||||
const element = createPet(SCARE_SEED);
|
||||
const phases = trackLogoPhases(element);
|
||||
|
||||
const arrived = await advanceUntil(element, () => spritePresent(element), 200_000);
|
||||
expect(arrived).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await element.updateComplete;
|
||||
expect(phases).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lobster plans", () => {
|
||||
it("keeps the passer gate near 9.5% while widening the traffic", () => {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
@@ -13,17 +13,13 @@ import * as dex from "./lobster-dex.ts";
|
||||
import { playLobsterPetChirp, type LobsterPetChirpKind } from "./lobster-pet-audio.ts";
|
||||
import * as contract from "./lobster-pet-contract.ts";
|
||||
import * as lobsterLook from "./lobster-pet-look.ts";
|
||||
import "./lobster-pet-standin.ts";
|
||||
import * as plans from "./lobster-pet-plans.ts";
|
||||
import { LobsterLedgeTraffic } from "./lobster-pet-traffic.ts";
|
||||
|
||||
export {
|
||||
LOBSTER_LOGO_VISIT_EVENT,
|
||||
lobsterPetSeed,
|
||||
resolveLobsterPetMode,
|
||||
resolveLobsterRunOutcome,
|
||||
type LobsterLogoVisitDetail,
|
||||
type LobsterLogoVisitPhase,
|
||||
type LobsterPetLook,
|
||||
type LobsterPetMode,
|
||||
type LobsterRunOutcome,
|
||||
@@ -56,14 +52,6 @@ class LobsterPet extends LitElement {
|
||||
@state() private presence: "out" | "in" | "leaving" = "out";
|
||||
@state() private anchor: plans.LobsterPetAnchor = "ledge";
|
||||
@state() private scheduledVisiting = false;
|
||||
@state() private logoPerched = false;
|
||||
@state() private logoScared = false;
|
||||
private logoScarePending = false;
|
||||
private logoScareTimer: number | null = null;
|
||||
private scareRng: () => number = lobsterLook.mulberry32(0);
|
||||
private logoPlanned = false;
|
||||
private logoDone = false;
|
||||
private lastLogoPhase: contract.LobsterLogoVisitPhase = "out";
|
||||
@state() private dismissed = false;
|
||||
@state() private grumpy = false;
|
||||
@state() private vigil = false;
|
||||
@@ -133,14 +121,13 @@ class LobsterPet extends LitElement {
|
||||
window.clearTimeout(this.shellTimer);
|
||||
this.shellTimer = null;
|
||||
}
|
||||
for (const timer of [this.vigilTimer, this.holdTimer, this.logoScareTimer]) {
|
||||
for (const timer of [this.vigilTimer, this.holdTimer]) {
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
this.vigilTimer = null;
|
||||
this.holdTimer = null;
|
||||
this.logoScareTimer = null;
|
||||
if (this.audioCtx) {
|
||||
this.audioCtx.close().catch(() => {});
|
||||
this.audioCtx = null;
|
||||
@@ -166,7 +153,6 @@ class LobsterPet extends LitElement {
|
||||
this.look = lobsterLook.createLobsterPetLook(this.seed);
|
||||
this.rng = lobsterLook.mulberry32(this.seed ^ 0x9e3779b9);
|
||||
this.visitRng = lobsterLook.mulberry32(this.seed ^ 0x5eaf00d);
|
||||
this.scareRng = lobsterLook.mulberry32((this.seed ^ 0x5ca2e) >>> 0);
|
||||
this.entranceRng = lobsterLook.mulberry32((this.seed ^ 0xe27a) >>> 0);
|
||||
this.identity = plans.resolveLobsterLoadIdentity(this.seed, this.look);
|
||||
this.look = this.identity.look;
|
||||
@@ -188,15 +174,6 @@ class LobsterPet extends LitElement {
|
||||
// The Elder never molts: it is already every size it will ever need.
|
||||
this.moltPlanned = plans.isLobsterMoltLoad(this.seed) && !this.identity.elder;
|
||||
this.twinPlanned = plans.isLobsterTwinLoad(this.seed);
|
||||
this.logoPlanned = plans.isLobsterLogoLoad(this.seed);
|
||||
this.logoDone = false;
|
||||
this.logoPerched = false;
|
||||
this.logoScared = false;
|
||||
this.logoScarePending = false;
|
||||
if (this.logoScareTimer !== null) {
|
||||
window.clearTimeout(this.logoScareTimer);
|
||||
this.logoScareTimer = null;
|
||||
}
|
||||
this.familiarity = dex.getLobsterFamiliarity();
|
||||
this.sailorDay = isLobsterDay(new Date());
|
||||
this.greetedThisLoad = false;
|
||||
@@ -208,12 +185,6 @@ class LobsterPet extends LitElement {
|
||||
this.outcomePresenceOwner = null;
|
||||
this.trackVigil();
|
||||
} else if (changed.has("mode")) {
|
||||
// Status duty outranks the impersonation: any non-idle mode sends the
|
||||
// logo stand-in scurrying back to the ledge, where the status acts
|
||||
// below play out as usual.
|
||||
if (this.logoPerched && this.mode !== "idle") {
|
||||
this.logoPerched = false;
|
||||
}
|
||||
const previousMode = changed.get("mode") as contract.LobsterPetMode | undefined;
|
||||
const finished = previousMode === "busy" && this.mode === "idle";
|
||||
const presenceOwner = finished && this.vigil ? "vigil" : null;
|
||||
@@ -256,25 +227,8 @@ class LobsterPet extends LitElement {
|
||||
}
|
||||
if (this.presence === "out") {
|
||||
this.rollPerch();
|
||||
// A planned logo load spends its first scheduled visit up top as the
|
||||
// brand mark (once per load); offline summons always take the ledge.
|
||||
this.logoPerched =
|
||||
this.logoPlanned && !this.logoDone && this.scheduledVisiting && this.mode === "idle";
|
||||
if (this.logoPerched) {
|
||||
this.logoDone = true;
|
||||
}
|
||||
// One scare roll per arrival keeps the stream aligned across visit
|
||||
// kinds; only an idle ledge visit may spook the logo (a perch owns
|
||||
// the slot outright, offline summons are on status duty).
|
||||
const scareRolled = this.scareRng() < plans.LOGO_SCARE_CHANCE;
|
||||
this.logoScarePending =
|
||||
scareRolled &&
|
||||
!this.logoPerched &&
|
||||
this.scheduledVisiting &&
|
||||
this.mode === "idle" &&
|
||||
!plans.prefersReducedMotion();
|
||||
// Entrance rolls burn once per arrival on their own stream, aligned
|
||||
// across visit kinds like the scare roll above.
|
||||
// across scheduled visits and offline summons.
|
||||
this.entrance = plans.pickLobsterEntrance(this.entranceRng());
|
||||
if (this.look) {
|
||||
// Anniversary check reads the dex before this arrival records into
|
||||
@@ -305,25 +259,15 @@ class LobsterPet extends LitElement {
|
||||
this.clearActTimers();
|
||||
this.act = null;
|
||||
this.entering = false;
|
||||
this.logoScarePending = false;
|
||||
if (this.logoScareTimer !== null) {
|
||||
window.clearTimeout(this.logoScareTimer);
|
||||
this.logoScareTimer = null;
|
||||
}
|
||||
this.presence = "leaving";
|
||||
this.leaveTimer = window.setTimeout(() => {
|
||||
this.leaveTimer = null;
|
||||
this.presence = "out";
|
||||
this.logoPerched = false;
|
||||
// The "out" edge is the single restore point: the logo fades back
|
||||
// in the same update that ends the visit, scare or perch alike.
|
||||
this.logoScared = false;
|
||||
}, plans.LEAVE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
override updated() {
|
||||
this.dispatchLogoPhase();
|
||||
if (!this.restartPending) {
|
||||
return;
|
||||
}
|
||||
@@ -331,69 +275,21 @@ class LobsterPet extends LitElement {
|
||||
this.enterTimer = window.setTimeout(() => {
|
||||
this.enterTimer = null;
|
||||
this.entering = false;
|
||||
// Familiar humans and returning old friends get a hello: the first
|
||||
// arrival of the load waves at you. A logo stand-in skips the greeting
|
||||
// (and keeps it for a ledge visit).
|
||||
// Familiar humans and returning old friends get a hello on the first
|
||||
// arrival of the load.
|
||||
if (
|
||||
!this.greetedThisLoad &&
|
||||
(this.familiarity.tier === "friend" || this.identity?.oldFriend === true) &&
|
||||
this.presence === "in" &&
|
||||
!this.logoPerched &&
|
||||
!plans.prefersReducedMotion()
|
||||
) {
|
||||
this.greetedThisLoad = true;
|
||||
this.performAct("wave");
|
||||
}
|
||||
}, plans.LOBSTER_PET_ENTRANCE_MS[this.entrance]);
|
||||
if (this.logoScarePending) {
|
||||
this.logoScarePending = false;
|
||||
// The beat between arrival and the duck is what sells "the crab scared
|
||||
// the logo" instead of reading as a render glitch.
|
||||
this.logoScareTimer = window.setTimeout(() => {
|
||||
this.logoScareTimer = null;
|
||||
if (this.presence === "in" && !this.logoPerched) {
|
||||
this.logoScared = true;
|
||||
}
|
||||
}, plans.LOGO_SCARE_DELAY_MS);
|
||||
}
|
||||
this.scheduleNextAct();
|
||||
}
|
||||
|
||||
private logoVisitPhase(): contract.LobsterLogoVisitPhase {
|
||||
const occupied = this.logoPerched || this.logoScared;
|
||||
if (!occupied || !this.visitsEnabled || this.dismissed) {
|
||||
return "out";
|
||||
}
|
||||
return this.presence === "in" ? "in" : this.presence === "leaving" ? "leaving" : "out";
|
||||
}
|
||||
|
||||
// The brand slot lives in the sidebar's DOM; phase edges cross over as
|
||||
// events so exactly one home renders the crab at any moment.
|
||||
private dispatchLogoPhase() {
|
||||
const phase = this.logoVisitPhase();
|
||||
if (phase === this.lastLogoPhase) {
|
||||
return;
|
||||
}
|
||||
this.lastLogoPhase = phase;
|
||||
// Scare phases send no look: the logo just hides, nobody fills in.
|
||||
const look = phase === "out" || !this.logoPerched || !this.look ? null : this.look;
|
||||
// The ledge sprite dresses up for palette anniversaries; the stand-in
|
||||
// celebrates the same way.
|
||||
const dressed =
|
||||
look && this.anniversary && look.accessory !== "party"
|
||||
? { ...look, accessory: "party" as const }
|
||||
: look;
|
||||
const name =
|
||||
dressed && this.identity ? plans.lobsterLoadDisplayName(this.identity, this.seed) : null;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<contract.LobsterLogoVisitDetail>(contract.LOBSTER_LOGO_VISIT_EVENT, {
|
||||
detail: { phase, look: dressed, name },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private readonly handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
this.outcomePresenceOwner = null;
|
||||
@@ -617,7 +513,6 @@ class LobsterPet extends LitElement {
|
||||
if (
|
||||
reaction === null ||
|
||||
this.presence !== "in" ||
|
||||
this.logoPerched ||
|
||||
this.act !== null ||
|
||||
this.vigil ||
|
||||
this.mode !== "idle" ||
|
||||
@@ -652,7 +547,6 @@ class LobsterPet extends LitElement {
|
||||
if (
|
||||
!this.look ||
|
||||
this.presence !== "in" ||
|
||||
this.logoPerched ||
|
||||
this.vigil ||
|
||||
this.traffic.passer !== null ||
|
||||
this.idleTimer !== null ||
|
||||
@@ -779,7 +673,6 @@ class LobsterPet extends LitElement {
|
||||
look,
|
||||
mode: this.mode,
|
||||
presence: this.presence,
|
||||
logoPerched: this.logoPerched,
|
||||
shellVisible: this.shellVisible,
|
||||
visitsEnabled: this.visitsEnabled,
|
||||
dismissed: this.dismissed,
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import type { ControlUiBuildInfo } from "../build-info.ts";
|
||||
import { setAvatarGatewayOrigin } from "../lib/identity-avatar.ts";
|
||||
import type { PresenceViewer } from "./viewer-facepile.ts";
|
||||
import "./viewer-facepile.ts";
|
||||
import { hasSessionPresenceViewers, type PresenceViewer } from "./viewer-facepile.ts";
|
||||
|
||||
type ViewerAvatarElement = HTMLElement & {
|
||||
user: PresenceViewer | null;
|
||||
@@ -166,3 +165,22 @@ it("keeps session facepiles as plain non-interactive avatar clusters", async ()
|
||||
expect(facepile.querySelector("button.viewer-facepile-trigger")).toBeNull();
|
||||
expect(facepile.querySelectorAll("openclaw-tooltip")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("detects only other viewers watching the requested session", () => {
|
||||
const payload = {
|
||||
presence: [
|
||||
{
|
||||
instanceId: "self-instance",
|
||||
user: { id: "self", name: "Self" },
|
||||
watchedSessions: ["agent:main:active"],
|
||||
},
|
||||
{
|
||||
instanceId: "alice-instance",
|
||||
user: { id: "alice", name: "Alice" },
|
||||
watchedSessions: ["agent:main:other"],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(hasSessionPresenceViewers(payload, "self-instance", "agent:main:active")).toBe(false);
|
||||
expect(hasSessionPresenceViewers(payload, "self-instance", "agent:main:other")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -91,6 +91,17 @@ function projectPresencePayload(value: unknown, selfInstanceId?: string) {
|
||||
return cachedPresenceProjection;
|
||||
}
|
||||
|
||||
export function hasSessionPresenceViewers(
|
||||
value: unknown,
|
||||
selfInstanceId: string | undefined,
|
||||
sessionKey: string,
|
||||
): boolean {
|
||||
const projection = projectPresencePayload(value, selfInstanceId);
|
||||
return projection.users.some(
|
||||
(user) => user.id !== projection.selfUserId && user.watchedSessions.includes(sessionKey),
|
||||
);
|
||||
}
|
||||
|
||||
export function presenceViewerLabel(user: PresenceViewer): string {
|
||||
return user.name ?? user.email ?? user.id;
|
||||
}
|
||||
|
||||
@@ -111,17 +111,16 @@ describeControlUiE2e("Control UI native-nav sidebar toggle E2E", () => {
|
||||
it("keeps the web expand/collapse controls in plain browsers", async () => {
|
||||
const page = await openPage({ nativeNav: false });
|
||||
|
||||
const collapse = page.locator(".sidebar-brand__collapse");
|
||||
await expect.poll(() => collapse.isVisible()).toBe(true);
|
||||
await collapse.click();
|
||||
|
||||
const expand = page.locator(".shell-nav-expand");
|
||||
await expect.poll(() => expand.isVisible()).toBe(true);
|
||||
await expand.click();
|
||||
await expect.poll(() => collapse.isVisible()).toBe(true);
|
||||
const toggle = page.locator(".shell-chrome-controls__nav-toggle");
|
||||
await expect.poll(() => toggle.isVisible()).toBe(true);
|
||||
await expect.poll(() => toggle.getAttribute("aria-label")).toBe("Collapse sidebar");
|
||||
await toggle.click();
|
||||
await expect.poll(() => toggle.getAttribute("aria-label")).toBe("Expand sidebar");
|
||||
await toggle.click();
|
||||
await expect.poll(() => toggle.getAttribute("aria-label")).toBe("Collapse sidebar");
|
||||
});
|
||||
|
||||
it("hides both web toggles when the native titlebar toggle is present", async () => {
|
||||
it("hides the web chrome cluster when the native titlebar toggle is present", async () => {
|
||||
const page = await openPage({ nativeNav: true });
|
||||
|
||||
await expect
|
||||
@@ -150,10 +149,15 @@ describeControlUiE2e("Control UI native-nav sidebar toggle E2E", () => {
|
||||
});
|
||||
expect(initialWidth).toBeGreaterThan(0);
|
||||
|
||||
await expect.poll(() => page.locator(".sidebar-brand__collapse").isVisible()).toBe(false);
|
||||
// Expanded native-nav hosts keep the cluster's search (no native search
|
||||
// control exists while the rail is open) but hide the duplicate nav toggle.
|
||||
await expect.poll(() => page.locator(".shell-chrome-controls__search").isVisible()).toBe(true);
|
||||
await expect
|
||||
.poll(() => page.locator(".shell-chrome-controls__nav-toggle").isVisible())
|
||||
.toBe(false);
|
||||
|
||||
// Collapse through the native titlebar path; the floating expand control
|
||||
// must stay hidden (the titlebar button is the only expand affordance).
|
||||
// Collapse through the native titlebar path; the whole web chrome cluster
|
||||
// hides (native titlebar provides search and new-thread while collapsed).
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new CustomEvent("openclaw:native-toggle-sidebar"));
|
||||
});
|
||||
@@ -169,7 +173,7 @@ describeControlUiE2e("Control UI native-nav sidebar toggle E2E", () => {
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect.poll(() => page.locator(".shell-nav-expand").isVisible()).toBe(false);
|
||||
await expect.poll(() => page.locator(".shell-chrome-controls").isVisible()).toBe(false);
|
||||
// With the in-page expand control hidden, collapse anchors keyboard focus
|
||||
// on the content column instead of stranding it on the body.
|
||||
await expect
|
||||
@@ -191,8 +195,7 @@ describeControlUiE2e("Control UI native-nav sidebar toggle E2E", () => {
|
||||
const page = await openPage({ webChrome: true });
|
||||
const toolbar = page.locator(".macos-titlebar-controls");
|
||||
await expect.poll(() => toolbar.isVisible()).toBe(true);
|
||||
await expect.poll(() => page.locator(".sidebar-brand__collapse").isVisible()).toBe(false);
|
||||
await expect.poll(() => page.locator(".shell-nav-expand").isVisible()).toBe(false);
|
||||
await expect.poll(() => page.locator(".shell-chrome-controls").isVisible()).toBe(false);
|
||||
|
||||
const back = toolbar.getByRole("button", { name: "Back" });
|
||||
const forward = toolbar.getByRole("button", { name: "Forward" });
|
||||
|
||||
@@ -583,10 +583,10 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
||||
);
|
||||
const shell = page.locator(".shell");
|
||||
const shellNav = page.locator(".shell-nav");
|
||||
const collapseButton = sidebar
|
||||
.locator(".sidebar-brand")
|
||||
const collapseButton = page
|
||||
.locator(".shell-chrome-controls")
|
||||
.getByRole("button", { name: "Collapse sidebar" });
|
||||
const expandButton = page.locator(".shell-nav-expand");
|
||||
const expandButton = page.locator(".shell-chrome-controls__nav-toggle");
|
||||
const drawerToggle = page
|
||||
.locator(".topbar-nav-toggle:visible, .chat-pane__nav-toggle:visible")
|
||||
.first();
|
||||
|
||||
@@ -529,21 +529,20 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
|
||||
.poll(() => trimmedTextContents(pinnedItems))
|
||||
.toEqual(["Usage", "Automations", "Plugins"]);
|
||||
|
||||
// The sidebar search field is the command palette entry point.
|
||||
const searchButton = sidebar.locator(".sidebar-search");
|
||||
// The shell chrome search button is the command palette entry point.
|
||||
const searchButton = page.locator(".shell-chrome-controls__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);
|
||||
|
||||
// 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).
|
||||
// The shell chrome toggle stays visible while the desktop sidebar
|
||||
// collapses and expands (there is no icon rail).
|
||||
const collapseButton = page.getByRole("button", { name: "Collapse sidebar" });
|
||||
await expect
|
||||
.poll(() =>
|
||||
collapseButton.evaluate((element) => Boolean(element.closest(".sidebar-brand"))),
|
||||
collapseButton.evaluate((element) => Boolean(element.closest(".shell-chrome-controls"))),
|
||||
)
|
||||
.toBe(true);
|
||||
await collapseButton.click();
|
||||
@@ -559,12 +558,14 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
|
||||
.toBe("0px");
|
||||
await expect.poll(() => sidebarResizer.count()).toBe(0);
|
||||
await expect.poll(() => sidebar.isVisible()).toBe(false);
|
||||
const navExpand = page.locator(".shell-nav-expand");
|
||||
const navExpand = page.locator(".shell-chrome-controls__nav-toggle");
|
||||
await expect.poll(() => navExpand.isVisible()).toBe(true);
|
||||
await page.reload();
|
||||
await expect.poll(() => page.locator(".shell-nav-expand").isVisible()).toBe(true);
|
||||
await expect
|
||||
.poll(() => page.locator(".shell-chrome-controls__nav-toggle").isVisible())
|
||||
.toBe(true);
|
||||
await captureUiProof(page, "04-persisted-collapsed.png");
|
||||
await page.locator(".shell-nav-expand").click();
|
||||
await page.locator(".shell-chrome-controls__nav-toggle").click();
|
||||
await expect
|
||||
.poll(() => page.locator(".shell").getAttribute("class"))
|
||||
.not.toContain("shell--nav-collapsed");
|
||||
@@ -602,7 +603,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 sidebar.getByRole("button", { name: "Collapse sidebar" }).click();
|
||||
await page.locator(".shell-chrome-controls__nav-toggle").click();
|
||||
await expect
|
||||
.poll(() => page.locator(".shell").getAttribute("class"))
|
||||
.toContain("shell--nav-collapsed");
|
||||
|
||||
@@ -52,7 +52,11 @@ import {
|
||||
type QuestionPrompt,
|
||||
} from "../../app/question-prompt.ts";
|
||||
import { loadSettings, patchSettings } from "../../app/settings.ts";
|
||||
import { readPresenceEntries, resolveCurrentSelfUser } from "../../app/user-profile.ts";
|
||||
import {
|
||||
readPresenceEntries,
|
||||
resolveCurrentSelfUser,
|
||||
type PresencePayload,
|
||||
} from "../../app/user-profile.ts";
|
||||
import {
|
||||
BROWSER_ANNOTATION_EVENT,
|
||||
type BrowserAnnotationDraft,
|
||||
@@ -66,6 +70,7 @@ import { createDockPanelLayout } from "../../components/dock-panel-layout.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import { listSessionCreators } from "../../components/session-owner-chip.ts";
|
||||
import { isCloudWorkerPlacementState } from "../../components/session-row-badges.ts";
|
||||
import { hasSessionPresenceViewers } from "../../components/viewer-facepile.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { resolveBoardChatLayoutWidth } from "../../lib/board/chat-layout.ts";
|
||||
import {
|
||||
@@ -441,6 +446,7 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
@litState() private headerRenameValue = "";
|
||||
@litState() private headerPlatform: string | null = null;
|
||||
@litState() private headerCopiedAction: ChatPaneHeaderAction | null = null;
|
||||
@litState() private presencePayload: PresencePayload | undefined;
|
||||
@litState() private boardCommandDock: {
|
||||
sessionKey: string;
|
||||
tabId: string;
|
||||
@@ -2356,6 +2362,10 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
chatState.addCleanup(
|
||||
this.context.gateway.subscribeEvents((event) => {
|
||||
const state = this.state;
|
||||
if (event.event === "presence") {
|
||||
const presence = readPresenceEntries(event.payload);
|
||||
this.presencePayload = presence ? { presence } : undefined;
|
||||
}
|
||||
if (state) {
|
||||
handleQuestionPromptEvent(this.questionPromptState, event);
|
||||
}
|
||||
@@ -2488,6 +2498,7 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
}
|
||||
this.headerWorktreePaths.clear();
|
||||
this.headerBranches.clear();
|
||||
this.presencePayload = undefined;
|
||||
this.announceCommandPaletteTarget(null);
|
||||
dismissConfirmedActionPopovers(this);
|
||||
resetChatViewState(this.paneId);
|
||||
@@ -2609,6 +2620,12 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
const wasConnected = state.connected;
|
||||
const sourceChanged = state.client !== snapshot.client || wasConnected !== snapshot.connected;
|
||||
const clientChanged = this.connectedClient !== snapshot.client;
|
||||
if (!snapshot.connected) {
|
||||
this.presencePayload = undefined;
|
||||
} else if (clientChanged || !wasConnected) {
|
||||
const presence = readPresenceEntries(snapshot.hello?.snapshot);
|
||||
this.presencePayload = presence ? { presence } : undefined;
|
||||
}
|
||||
if (sourceChanged) {
|
||||
// A reconnect can retain the browser client. Keep async ownership tied
|
||||
// to the logical connection, not only the transport object identity.
|
||||
@@ -3222,6 +3239,22 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
diffAction: renderSessionDiffToggle(sessionWorkspace),
|
||||
backgroundTasksAction: renderBackgroundTasksToggle(backgroundTasks),
|
||||
workspaceAction: renderSessionWorkspaceToggle(sessionWorkspace),
|
||||
presence:
|
||||
!catalog &&
|
||||
hasSessionPresenceViewers(
|
||||
this.presencePayload,
|
||||
this.context.gateway.snapshot.client?.instanceId,
|
||||
this.state?.sessionKey ?? "",
|
||||
)
|
||||
? html`<openclaw-viewer-facepile
|
||||
class="chat-pane__presence"
|
||||
.presencePayload=${this.presencePayload}
|
||||
.selfInstanceId=${this.context.gateway.snapshot.client?.instanceId}
|
||||
.sessionKey=${this.state?.sessionKey}
|
||||
.maxVisible=${4}
|
||||
variant="session"
|
||||
></openclaw-viewer-facepile>`
|
||||
: nothing,
|
||||
faceControl: renderBoardFaceToggle(board.hasBoard, board.face, (face) => {
|
||||
this.persistBoardSessionView({ face });
|
||||
}),
|
||||
|
||||
@@ -109,6 +109,18 @@ describe("chat pane header", () => {
|
||||
expect(props.onBeginRename).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("places pane presence between the workspace chip and face control", () => {
|
||||
const { container } = mount({
|
||||
presence: html`<span data-slot="presence"></span>`,
|
||||
faceControl: html`<span data-slot="face"></span>`,
|
||||
});
|
||||
const workspace = container.querySelector(".chat-pane__workspace-menu");
|
||||
expect(workspace?.nextElementSibling?.getAttribute("data-slot")).toBe("presence");
|
||||
expect(workspace?.nextElementSibling?.nextElementSibling?.getAttribute("data-slot")).toBe(
|
||||
"face",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the permanent owner chip only when attribution chrome is enabled", () => {
|
||||
const shown = mount({
|
||||
showOwnerChip: true,
|
||||
|
||||
@@ -40,6 +40,7 @@ type ChatPaneHeaderProps = {
|
||||
diffAction: TemplateResult | typeof nothing;
|
||||
backgroundTasksAction: TemplateResult | typeof nothing;
|
||||
workspaceAction: TemplateResult | typeof nothing;
|
||||
presence?: TemplateResult | typeof nothing;
|
||||
faceControl?: TemplateResult | typeof nothing;
|
||||
boardDockAction?: TemplateResult | typeof nothing;
|
||||
onBeginRename: () => void;
|
||||
@@ -239,7 +240,7 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) {
|
||||
</wa-dropdown>
|
||||
`
|
||||
: nothing}
|
||||
${props.faceControl ?? nothing}
|
||||
${props.presence ?? nothing} ${props.faceControl ?? nothing}
|
||||
${!props.catalog && props.branches.length > 1
|
||||
? html`
|
||||
<wa-dropdown
|
||||
|
||||
@@ -281,6 +281,12 @@ openclaw-chat-pane {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-pane__presence {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-pane__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -302,18 +308,28 @@ openclaw-chat-pane {
|
||||
.chat-pane__workspace-chip span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-pane__presence {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Plain web shell chrome overlays the first pane header. Expanded navigation
|
||||
shows toggle + search; collapsed navigation adds new-thread between them. */
|
||||
html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-web-chrome)
|
||||
.shell:not(.shell--nav-collapsed):not(.shell--mobile-nav)
|
||||
.chat-split-view__column:first-child
|
||||
> .chat-split-view__cell:first-child
|
||||
.chat-pane__header {
|
||||
padding-left: 88px;
|
||||
}
|
||||
|
||||
/* Plain web with the nav collapsed: the floating sidebar-expand pill
|
||||
(.shell-nav-expand, fixed 10px/10px, 32px wide) overlays the first pane
|
||||
header's left edge; 52px clears it so the session title stays readable.
|
||||
Native shells below reserve larger insets for their own window chrome. */
|
||||
html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-web-chrome)
|
||||
.shell--nav-collapsed:not(.shell--mobile-nav)
|
||||
.chat-split-view__column:first-child
|
||||
> .chat-split-view__cell:first-child
|
||||
.chat-pane__header {
|
||||
padding-left: 52px;
|
||||
padding-left: 124px;
|
||||
}
|
||||
|
||||
/* Native macOS desktop shells: the pane header is the window's top surface,
|
||||
|
||||
+49
-71
@@ -56,9 +56,9 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 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). */
|
||||
/* Collapsing hides the sidebar entirely; shell chrome 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;
|
||||
@@ -69,13 +69,19 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
/* Web shell controls mirror the native Mac titlebar strip. On native macOS
|
||||
hosts without the full strip, they sit below the AppKit drag region. */
|
||||
.shell-chrome-controls {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
left: calc(var(--shell-nav-width) + 10px);
|
||||
z-index: 45;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.shell-chrome-controls__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -91,17 +97,21 @@
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.shell-nav-expand:hover {
|
||||
.shell-chrome-controls__button:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--bg-hover) 92%, transparent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.shell-nav-expand:focus-visible {
|
||||
.shell-chrome-controls__button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.shell-nav-expand svg {
|
||||
.shell-chrome-controls__button:disabled {
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.shell-chrome-controls__button svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
stroke: currentColor;
|
||||
@@ -111,37 +121,29 @@
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.shell--mobile-nav .shell-nav-expand {
|
||||
.shell--mobile-nav .shell-chrome-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html.openclaw-native-macos .shell-nav-expand {
|
||||
html.openclaw-native-macos .shell-chrome-controls {
|
||||
top: 52px;
|
||||
}
|
||||
|
||||
/* Newer Mac apps host a native sidebar toggle in the titlebar next to
|
||||
back/forward and add openclaw-native-nav; the in-page toggles (desktop
|
||||
collapse/expand plus the drawer hamburger in compact layouts) would duplicate
|
||||
it, so they disappear entirely (⌘B and the accessible titlebar button
|
||||
cover keyboard/screen-reader use). Older apps only add
|
||||
openclaw-native-macos and keep the web controls visible. */
|
||||
html.openclaw-native-nav .shell-nav-expand,
|
||||
html.openclaw-native-nav .sidebar-brand__collapse,
|
||||
back/forward and add openclaw-native-nav; the in-page nav toggles would
|
||||
duplicate it, so they disappear (⌘B and the accessible titlebar button
|
||||
cover keyboard/screen-reader use). Search stays: native-nav-only hosts
|
||||
provide native search/new-thread controls only while the rail is
|
||||
collapsed, so the expanded cluster keeps the pointer path to the palette.
|
||||
Older apps only add openclaw-native-macos and keep all web controls. */
|
||||
html.openclaw-native-nav .shell-chrome-controls__nav-toggle,
|
||||
html.openclaw-native-nav .shell--nav-collapsed .shell-chrome-controls,
|
||||
html.openclaw-native-nav .topbar .topbar-nav-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Shipped Mac builds without web chrome only replace the sidebar toggle via
|
||||
openclaw-native-nav. Web-chrome hosts replace the toggle and brand search;
|
||||
new-thread stays in the brand row and moves to the titlebar only when the
|
||||
rail is collapsed. */
|
||||
html.openclaw-native-web-chrome .shell-nav-expand,
|
||||
html.openclaw-native-web-chrome .sidebar-brand__collapse {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The titlebar strip replaces brand search only while it is visible. */
|
||||
html.openclaw-native-web-chrome .shell:not(.shell--mobile-nav) .sidebar-brand .sidebar-search {
|
||||
/* Web-chrome hosts provide this entire cluster in the native titlebar. */
|
||||
html.openclaw-native-web-chrome .shell-chrome-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -844,31 +846,6 @@ html.openclaw-native-web-chrome .shell:not(.shell--mobile-nav) .sidebar-brand .s
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Positioning box for the product mark (now in the footer bar); the lobster
|
||||
pet's logo stand-in (lobster-pet.css) overlays the img during a visit. */
|
||||
.sidebar-brand__logo-slot {
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sidebar-brand__logo {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-md);
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* The crab took over: the static mark steps aside until the visit ends. */
|
||||
.sidebar-brand__logo--vacated {
|
||||
opacity: 0;
|
||||
transform: scale(0.55);
|
||||
}
|
||||
|
||||
.sidebar-brand__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3028,20 +3005,6 @@ wa-dropdown.sidebar-session-sort-menu::part(menu) {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.sidebar-footer-bar__logo {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.sidebar-footer-bar__logo .sidebar-brand__logo {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.sidebar-footer-bar .viewer-facepile {
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
openclaw-tooltip.sidebar-hover-tooltip {
|
||||
--openclaw-tooltip-max-width: min(340px, calc(100vw - 24px));
|
||||
}
|
||||
@@ -3197,9 +3160,8 @@ openclaw-tooltip.sidebar-hover-tooltip {
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.sidebar-footer-bar .sidebar-footer-build {
|
||||
.sidebar-footer-bar__spacer {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-footer-bar__status {
|
||||
@@ -3586,6 +3548,22 @@ openclaw-tooltip.sidebar-hover-tooltip {
|
||||
max-height: 80px;
|
||||
}
|
||||
|
||||
/* The fixed web chrome sits over the top-left of ordinary page headers too.
|
||||
Settings, mobile drawers, and native hosts own different chrome. */
|
||||
html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-web-chrome)
|
||||
.shell:not(.shell--nav-collapsed):not(.shell--mobile-nav):not(.shell--settings)
|
||||
.content:not(.content--chat)
|
||||
.content-header {
|
||||
padding-left: 88px;
|
||||
}
|
||||
|
||||
html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-web-chrome)
|
||||
.shell--nav-collapsed:not(.shell--mobile-nav):not(.shell--settings)
|
||||
.content:not(.content--chat)
|
||||
.content-header {
|
||||
padding-left: 124px;
|
||||
}
|
||||
|
||||
.content-header--page + * {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@@ -235,13 +235,6 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Drawer brand row keeps only the new-thread action: search lives in the
|
||||
topbar and the drawer dismisses via backdrop, so their buttons stay hidden. */
|
||||
.shell--mobile-nav .sidebar-brand .sidebar-search,
|
||||
.shell--mobile-nav .sidebar-brand .sidebar-brand__collapse {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shell--mobile-nav .sidebar {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
@@ -52,8 +52,7 @@ openclaw-lobster-pet {
|
||||
/* Seeded glint tint rides its own variable, mapped here (before the palette
|
||||
rules) so palette and offline class rules still out-cascade it on both
|
||||
look surfaces. */
|
||||
.lobster-pet,
|
||||
.sidebar-brand__pet {
|
||||
.lobster-pet {
|
||||
--lob-glint: var(--lob-glint-seed, #00e5cc);
|
||||
}
|
||||
|
||||
@@ -1093,73 +1092,3 @@ openclaw-lobster-pet[data-dex-complete]::after {
|
||||
color: #f4b840;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- Logo stand-in ----
|
||||
On planned loads (isLobsterLogoLoad) the pet's first scheduled visit
|
||||
perches in the sidebar brand slot instead of the ledge. The sidebar
|
||||
renders this sprite into .sidebar-brand__logo-slot (layout.css); the
|
||||
ledge stays empty meanwhile - one crab, two homes. */
|
||||
|
||||
.sidebar-brand__pet {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
cursor: default;
|
||||
animation: lobster-pet-logo-settle 0.45s ease-out;
|
||||
}
|
||||
|
||||
.sidebar-brand__pet .lobster-pet__svg {
|
||||
width: calc(22px * var(--lob-w, 1));
|
||||
height: calc(19px * var(--lob-h, 1));
|
||||
overflow: visible;
|
||||
animation: lobster-pet-breathe 3.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Blink like the ledge sprite. The closed-lid curves need an explicit
|
||||
resting opacity here: the base rule is scoped to .lobster-pet, which the
|
||||
brand slot intentionally does not use (it would drag perch layout along). */
|
||||
.sidebar-brand__pet .lob-eye-closed {
|
||||
opacity: 0;
|
||||
animation: lobster-pet-blink-closed 5.2s linear infinite;
|
||||
animation-delay: var(--lob-blink-delay, 0s);
|
||||
}
|
||||
|
||||
.sidebar-brand__pet .lob-eye-open {
|
||||
animation: lobster-pet-blink-open 5.2s linear infinite;
|
||||
animation-delay: var(--lob-blink-delay, 0s);
|
||||
}
|
||||
|
||||
/* Departure mirrors LEAVE_MS in lobster-pet.ts: the stand-in sinks toward
|
||||
the ledge while the real logo scales back in underneath. */
|
||||
.sidebar-brand__pet--leaving {
|
||||
opacity: 0;
|
||||
transform: translateY(7px);
|
||||
transition:
|
||||
transform 0.3s ease-in,
|
||||
opacity 0.25s ease 0.05s;
|
||||
}
|
||||
|
||||
@keyframes lobster-pet-logo-settle {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-7px);
|
||||
}
|
||||
55% {
|
||||
opacity: 1;
|
||||
transform: translateY(1.5px) scaleY(0.92);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sidebar-brand__pet,
|
||||
.sidebar-brand__pet *,
|
||||
.sidebar-brand__logo {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ describe("AppSidebar viewer presence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("groups identified viewers for session rows and the footer", async () => {
|
||||
it("groups identified viewers for session rows and keeps the footer identity-only", async () => {
|
||||
const client = { instanceId: "self-instance" } as GatewayBrowserClient;
|
||||
const gatewayHarness = createGatewayHarness(client);
|
||||
const { sidebar } = await mountSidebar(
|
||||
@@ -152,13 +152,7 @@ describe("AppSidebar viewer presence", () => {
|
||||
const sessionFacepile = sidebar.querySelector<HTMLElement>(
|
||||
'[data-session-key="agent:main:work"] openclaw-viewer-facepile',
|
||||
);
|
||||
const footerFacepile = sidebar.querySelector<HTMLElement>(
|
||||
".sidebar-footer-bar openclaw-viewer-facepile",
|
||||
);
|
||||
await Promise.all([
|
||||
(sessionFacepile as { updateComplete?: Promise<unknown> } | null)?.updateComplete,
|
||||
(footerFacepile as { updateComplete?: Promise<unknown> } | null)?.updateComplete,
|
||||
]);
|
||||
await (sessionFacepile as { updateComplete?: Promise<unknown> } | null)?.updateComplete;
|
||||
expect(
|
||||
sessionFacepile?.querySelector(".viewer-facepile")?.getAttribute("data-viewer-count"),
|
||||
).toBe("6");
|
||||
@@ -175,23 +169,6 @@ describe("AppSidebar viewer presence", () => {
|
||||
),
|
||||
).toEqual(["Alice", "bob@example.test", "Carol", "Dave\nErin\nFrank"]);
|
||||
|
||||
expect(
|
||||
footerFacepile?.querySelector(".viewer-facepile")?.getAttribute("data-viewer-count"),
|
||||
).toBe("6");
|
||||
expect(footerFacepile?.querySelector('.viewer-facepile [data-viewer-id="00-self"]')).toBeNull();
|
||||
expect(footerFacepile?.querySelector(".viewer-avatar--overflow")?.textContent).toContain("+1");
|
||||
expect(footerFacepile?.querySelector(".viewer-facepile openclaw-tooltip")).toBeNull();
|
||||
expect(
|
||||
[
|
||||
...(footerFacepile?.querySelectorAll(
|
||||
".sidebar-presence-hover-card .sidebar-hover-card__person",
|
||||
) ?? []),
|
||||
].map((row) => row.getAttribute("data-viewer-id")),
|
||||
).toEqual(["00-self", "alice", "bob", "carol", "dave", "erin", "frank"]);
|
||||
expect(footerFacepile?.querySelector(".sidebar-presence-hover-card")?.textContent).toContain(
|
||||
"Server",
|
||||
);
|
||||
|
||||
const identityChip = sidebar.querySelector<HTMLButtonElement>(".sidebar-footer-bar__identity");
|
||||
expect(identityChip?.querySelector(".sidebar-footer-bar__identity-name")?.textContent).toBe(
|
||||
"Self User",
|
||||
@@ -204,6 +181,13 @@ describe("AppSidebar viewer presence", () => {
|
||||
|
||||
const avatar = identityChip?.querySelector<HTMLImageElement>("openclaw-viewer-avatar img");
|
||||
expect(avatar?.getAttribute("src")).toBe("/api/users/00-self/avatar?v=1");
|
||||
const footer = sidebar.querySelector(".sidebar-footer-bar");
|
||||
expect(footer?.querySelector("openclaw-viewer-facepile")).toBeNull();
|
||||
expect(footer?.querySelector("openclaw-sidebar-build-chip")).toBeNull();
|
||||
expect(footer?.querySelector(".sidebar-brand__logo-slot")).toBeNull();
|
||||
expect(
|
||||
[...(footer?.children ?? [])].map((element) => element.className || element.localName),
|
||||
).toEqual(["openclaw-tooltip", "sidebar-footer-bar__spacer", "openclaw-tooltip"]);
|
||||
gatewayHarness.gateway.updateSelfUser?.({
|
||||
name: "Augusta Ada",
|
||||
avatarUrl: "/api/users/00-self/avatar?v=4",
|
||||
@@ -269,6 +253,9 @@ describe("AppSidebar brand actions", () => {
|
||||
);
|
||||
expect(brandButton?.getAttribute("aria-label")).toBe("New thread");
|
||||
expect(brandButton?.disabled).toBe(true);
|
||||
expect(actions?.querySelectorAll("button")).toHaveLength(1);
|
||||
expect(sidebar.querySelector(".sidebar-search")).toBeNull();
|
||||
expect(sidebar.querySelector(".sidebar-brand__collapse")).toBeNull();
|
||||
|
||||
sidebar.connected = true;
|
||||
await sidebar.updateComplete;
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { ApplicationGatewaySnapshot } from "../../app/context.ts";
|
||||
import {
|
||||
createLobsterPetLook,
|
||||
LOBSTER_LOGO_VISIT_EVENT,
|
||||
type LobsterLogoVisitDetail,
|
||||
} from "../../components/lobster-pet.ts";
|
||||
import {
|
||||
createContext,
|
||||
createGateway,
|
||||
@@ -206,65 +201,6 @@ describe("AppSidebar lobster outcome wiring", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("AppSidebar logo stand-in wiring", () => {
|
||||
it("swaps the brand mark while the pet's logo visit is in, leaving, then out", async () => {
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
|
||||
const pet = sidebar.querySelector("openclaw-lobster-pet");
|
||||
if (!pet) {
|
||||
throw new Error("Expected sidebar lobster pet");
|
||||
}
|
||||
const dispatch = (detail: LobsterLogoVisitDetail) =>
|
||||
pet.dispatchEvent(
|
||||
new CustomEvent(LOBSTER_LOGO_VISIT_EVENT, { detail, bubbles: true, composed: true }),
|
||||
);
|
||||
const logo = () => sidebar.querySelector(".sidebar-brand__logo");
|
||||
const standIn = () => sidebar.querySelector(".sidebar-brand__pet");
|
||||
const standInHost = sidebar.querySelector<HTMLElement & { updateComplete: Promise<boolean> }>(
|
||||
"openclaw-lobster-logo-standin",
|
||||
);
|
||||
const settleStandIn = async () => {
|
||||
await sidebar.updateComplete;
|
||||
await standInHost?.updateComplete;
|
||||
};
|
||||
|
||||
expect(standInHost).not.toBeNull();
|
||||
await standInHost?.updateComplete;
|
||||
expect(logo()?.classList.contains("sidebar-brand__logo--vacated")).toBe(false);
|
||||
expect(standIn()).toBeNull();
|
||||
|
||||
const look = createLobsterPetLook(70);
|
||||
dispatch({ phase: "in", look, name: "Pinchy" });
|
||||
await settleStandIn();
|
||||
expect(logo()?.classList.contains("sidebar-brand__logo--vacated")).toBe(true);
|
||||
const sprite = standIn();
|
||||
expect(sprite).not.toBeNull();
|
||||
expect(sprite?.classList.contains(`lobster-pet--palette-${look.palette.id}`)).toBe(true);
|
||||
expect(sprite?.getAttribute("title")).toContain("Pinchy");
|
||||
expect(sprite?.querySelector(".lobster-pet__svg")).not.toBeNull();
|
||||
|
||||
dispatch({ phase: "leaving", look, name: "Pinchy" });
|
||||
await settleStandIn();
|
||||
expect(standIn()?.classList.contains("sidebar-brand__pet--leaving")).toBe(true);
|
||||
|
||||
dispatch({ phase: "out", look: null, name: null });
|
||||
await settleStandIn();
|
||||
expect(standIn()).toBeNull();
|
||||
expect(logo()?.classList.contains("sidebar-brand__logo--vacated")).toBe(false);
|
||||
|
||||
// A lookless scare phase hides the logo with no stand-in crab, and the
|
||||
// "out" edge restores it.
|
||||
dispatch({ phase: "in", look: null, name: null });
|
||||
await settleStandIn();
|
||||
expect(logo()?.classList.contains("sidebar-brand__logo--vacated")).toBe(true);
|
||||
expect(standIn()).toBeNull();
|
||||
|
||||
dispatch({ phase: "out", look: null, name: null });
|
||||
await settleStandIn();
|
||||
expect(logo()?.classList.contains("sidebar-brand__logo--vacated")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppSidebar session source lifecycle", () => {
|
||||
it("disables Fork session for model-selection-locked rows", async () => {
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
|
||||
Reference in New Issue
Block a user