refactor(ui): deduplicate layout, clipboard, and polling scaffolds (#106003)

* refactor(ui): share dock panel layout

* refactor(ui): centralize clipboard writes

* refactor(ui): centralize polling lifecycle

* refactor(ui): reuse shared byte formatter

* chore(ci): refresh UI LOC ratchet

* fix(ui): keep dock layout type internal
This commit is contained in:
Peter Steinberger
2026-07-12 22:05:09 -07:00
committed by GitHub
parent 2a6ccac40c
commit 2ecd485cd5
22 changed files with 386 additions and 320 deletions
+8 -8
View File
@@ -1176,7 +1176,7 @@
"ui/src/app/overlays.ts": 714,
"ui/src/app/settings.ts": 704,
"ui/src/components/app-sidebar.ts": 2953,
"ui/src/components/browser/browser-panel.ts": 1412,
"ui/src/components/browser/browser-panel.ts": 1371,
"ui/src/components/command-palette.ts": 687,
"ui/src/components/config-form.node.ts": 1169,
"ui/src/components/file-preview-modal.ts": 803,
@@ -1184,12 +1184,12 @@
"ui/src/components/icons.ts": 701,
"ui/src/components/lobster-pet.ts": 1790,
"ui/src/components/markdown.ts": 1493,
"ui/src/components/terminal/terminal-panel.ts": 1182,
"ui/src/components/terminal/terminal-panel.ts": 1140,
"ui/src/components/tooltip.ts": 502,
"ui/src/lib/agents/display.ts": 661,
"ui/src/lib/chat/commands.ts": 537,
"ui/src/lib/chat/message-normalizer.ts": 530,
"ui/src/lib/config/index.ts": 951,
"ui/src/lib/config/index.ts": 947,
"ui/src/lib/cron/index.ts": 1319,
"ui/src/lib/nodes/index.ts": 900,
"ui/src/lib/sessions/index.ts": 1457,
@@ -1197,7 +1197,7 @@
"ui/src/lib/workboard/index.ts": 4121,
"ui/src/lib/workspace/index.ts": 779,
"ui/src/pages/agents/agents-page.ts": 964,
"ui/src/pages/agents/memory/dreaming.ts": 1288,
"ui/src/pages/agents/memory/dreaming.ts": 1280,
"ui/src/pages/agents/memory/memory-panel.ts": 503,
"ui/src/pages/agents/memory/view.ts": 1549,
"ui/src/pages/agents/panels-status-files.ts": 704,
@@ -1207,7 +1207,7 @@
"ui/src/pages/chat/chat-command-executor.ts": 818,
"ui/src/pages/chat/chat-history.ts": 1230,
"ui/src/pages/chat/chat-page.ts": 575,
"ui/src/pages/chat/chat-pane.ts": 2195,
"ui/src/pages/chat/chat-pane.ts": 2194,
"ui/src/pages/chat/chat-queue.ts": 714,
"ui/src/pages/chat/chat-send.ts": 2536,
"ui/src/pages/chat/chat-session.ts": 544,
@@ -1220,14 +1220,14 @@
"ui/src/pages/chat/components/chat-model-controls.ts": 761,
"ui/src/pages/chat/components/chat-session-workspace.ts": 1268,
"ui/src/pages/chat/components/chat-sidebar.ts": 1320,
"ui/src/pages/chat/components/chat-thread.ts": 1019,
"ui/src/pages/chat/components/chat-thread.ts": 994,
"ui/src/pages/chat/components/chat-tool-cards.ts": 957,
"ui/src/pages/chat/composer-persistence.ts": 1746,
"ui/src/pages/chat/realtime-talk-shared.ts": 627,
"ui/src/pages/chat/realtime-talk-webrtc.ts": 532,
"ui/src/pages/chat/stream-reconciliation.ts": 644,
"ui/src/pages/chat/tool-stream.ts": 792,
"ui/src/pages/config/config-page.ts": 962,
"ui/src/pages/config/config-page.ts": 958,
"ui/src/pages/config/quick.ts": 1178,
"ui/src/pages/config/view.ts": 1986,
"ui/src/pages/cron/view.ts": 2069,
@@ -1248,7 +1248,7 @@
"ui/src/pages/usage/metrics.ts": 862,
"ui/src/pages/usage/usage-page.ts": 673,
"ui/src/pages/usage/view-details.ts": 1246,
"ui/src/pages/usage/view-overview.ts": 1145,
"ui/src/pages/usage/view-overview.ts": 1142,
"ui/src/pages/usage/view.ts": 926,
"ui/src/pages/workboard/view.ts": 2962
}
+27 -68
View File
@@ -12,6 +12,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { t } from "../../i18n/index.ts";
import { openExternalUrlSafe } from "../../lib/open-external-url.ts";
import { OpenClawLitElement } from "../../lit/openclaw-element.ts";
import { createDockPanelLayout, type DockPanelSide } from "../dock-panel-layout.ts";
import {
buildAnnotationPrompt,
composeAnnotatedImage,
@@ -54,7 +55,7 @@ const EXTERNAL_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill=
const PENCIL_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M11.3 2.7l2 2L5 13H3v-2z" /></svg>`;
const INSPECT_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l5.5 10 1.2-4.3L14 7.5z" /></svg>`;
type BrowserDock = "bottom" | "right";
type BrowserDock = DockPanelSide;
type BrowserPanelMode = "interact" | "annotate" | "inspect";
type BrowserToggleDetail = {
dock?: BrowserDock;
@@ -62,13 +63,6 @@ type BrowserToggleDetail = {
url?: string;
};
type PanelLayout = {
open: boolean;
dock: BrowserDock;
height: number;
width: number;
};
/** One rendered page snapshot plus the geometry needed to map pointer coords. */
type BrowserPanelView = {
targetId: string;
@@ -78,10 +72,14 @@ type BrowserPanelView = {
metrics: BrowserPageMetrics | null;
};
const LAYOUT_KEY = "openclaw.browser.panel.v1";
const DEFAULT_LAYOUT: PanelLayout = { open: false, dock: "right", height: 420, width: 560 };
const MIN_HEIGHT = 240;
const MIN_WIDTH = 380;
const panelLayout = createDockPanelLayout({
storageKey: "openclaw.browser.panel.v1",
minHeight: 240,
minWidth: 380,
defaultDock: "right",
defaultHeight: 420,
defaultWidth: 560,
});
const TOGGLE_EVENT = "openclaw:browser-toggle";
const INSPECT_THROTTLE_MS = 120;
const ACTION_REFRESH_DELAY_MS = 350;
@@ -101,40 +99,6 @@ const FORWARDED_KEYS = new Set([
"PageDown",
]);
function loadLayout(): PanelLayout {
try {
const raw = globalThis.localStorage?.getItem(LAYOUT_KEY);
if (!raw) {
return { ...DEFAULT_LAYOUT };
}
const parsed = JSON.parse(raw) as Partial<PanelLayout>;
return {
open: Boolean(parsed.open),
dock: parsed.dock === "bottom" ? "bottom" : "right",
height: clampSize(parsed.height, MIN_HEIGHT, maxPanelHeight(), DEFAULT_LAYOUT.height),
width: clampSize(parsed.width, MIN_WIDTH, maxPanelWidth(), DEFAULT_LAYOUT.width),
};
} catch {
return { ...DEFAULT_LAYOUT };
}
}
// Cap the dock at 80% of the viewport so a size persisted on a large desktop
// never swallows a smaller window.
function maxPanelHeight(): number {
return Math.max(MIN_HEIGHT, Math.floor((globalThis.innerHeight || 800) * 0.8));
}
function maxPanelWidth(): number {
return Math.max(MIN_WIDTH, Math.floor((globalThis.innerWidth || 1280) * 0.8));
}
function clampSize(value: unknown, min: number, max: number, fallback: number): number {
const size =
typeof value === "number" && Number.isFinite(value) && value >= min ? value : fallback;
return Math.min(size, max);
}
function tabLabel(tab: BrowserPanelTab): string {
if (tab.title.trim()) {
return tab.title.trim();
@@ -187,9 +151,9 @@ export class OpenClawBrowserPanel extends OpenClawLitElement {
@property({ attribute: false }) authToken: string | null = null;
@state() private open = false;
@state() private dock: BrowserDock = DEFAULT_LAYOUT.dock;
@state() private height = DEFAULT_LAYOUT.height;
@state() private width = DEFAULT_LAYOUT.width;
@state() private dock: BrowserDock = panelLayout.defaults.dock;
@state() private height = panelLayout.defaults.height;
@state() private width = panelLayout.defaults.width;
@state() private running: boolean | null = null;
@state() private tabs: BrowserPanelTab[] = [];
/** Stable tab handle (plugin alias when available), not a raw CDP target id. */
@@ -221,8 +185,8 @@ export class OpenClawBrowserPanel extends OpenClawLitElement {
private resizeCleanup: (() => void) | null = null;
private readonly onToggleRequest = (event: Event) => this.handleToggleRequest(event);
private readonly onViewportResize = () => {
const height = Math.min(this.height, maxPanelHeight());
const width = Math.min(this.width, maxPanelWidth());
const height = Math.min(this.height, panelLayout.maxHeight());
const width = Math.min(this.width, panelLayout.maxWidth());
if (height !== this.height || width !== this.width) {
this.height = height;
this.width = width;
@@ -234,7 +198,7 @@ export class OpenClawBrowserPanel extends OpenClawLitElement {
override connectedCallback(): void {
super.connectedCallback();
const layout = loadLayout();
const layout = panelLayout.load();
this.dock = layout.dock;
this.height = layout.height;
this.width = layout.width;
@@ -270,7 +234,7 @@ export class OpenClawBrowserPanel extends OpenClawLitElement {
// so the open preference survives a reconnect.
this.open = false;
this.resetBrowserState();
} else if (this.available && !this.open && loadLayout().open) {
} else if (this.available && !this.open && panelLayout.load().open) {
// Hello arrived after mount (or a reconnect): restore the persisted
// open state now that the surface is actually available.
this.open = true;
@@ -378,17 +342,12 @@ export class OpenClawBrowserPanel extends OpenClawLitElement {
}
private persistLayout(): void {
try {
const layout: PanelLayout = {
open: this.open,
dock: this.dock,
height: this.height,
width: this.width,
};
globalThis.localStorage?.setItem(LAYOUT_KEY, JSON.stringify(layout));
} catch {
// Storage may be unavailable (private mode); layout just won't persist.
}
panelLayout.save({
open: this.open,
dock: this.dock,
height: this.height,
width: this.width,
});
}
private setDock(dock: BrowserDock): void {
@@ -1020,11 +979,11 @@ export class OpenClawBrowserPanel extends OpenClawLitElement {
const startWidth = this.width;
const onMove = (move: PointerEvent) => {
if (this.dock === "bottom") {
const next = Math.max(MIN_HEIGHT, startHeight + (startY - move.clientY));
this.height = Math.min(next, maxPanelHeight());
const next = Math.max(panelLayout.minHeight, startHeight + (startY - move.clientY));
this.height = Math.min(next, panelLayout.maxHeight());
} else {
const next = Math.max(MIN_WIDTH, startWidth + (startX - move.clientX));
this.width = Math.min(next, maxPanelWidth());
const next = Math.max(panelLayout.minWidth, startWidth + (startX - move.clientX));
this.width = Math.min(next, panelLayout.maxWidth());
}
this.syncLayoutReservation();
};
+2 -5
View File
@@ -1,15 +1,12 @@
// Control UI component renders a copyable gateway connection command.
import { html } from "lit";
import { t } from "../i18n/index.ts";
import { copyToClipboard } from "../lib/clipboard.ts";
import { renderCopyButton } from "./copy-button.ts";
import "./tooltip.ts";
async function copyCommand(command: string) {
try {
await navigator.clipboard.writeText(command);
} catch {
// Best effort only; the explicit copy button provides visible feedback.
}
await copyToClipboard(command);
}
export function renderConnectCommand(command: string) {
@@ -0,0 +1,55 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDockPanelLayout } from "./dock-panel-layout.ts";
function createLayout(defaultDock: "bottom" | "right") {
return createDockPanelLayout({
storageKey: `test.dock-panel.${defaultDock}`,
minHeight: 140,
minWidth: 320,
defaultDock,
defaultHeight: 320,
defaultWidth: 520,
});
}
afterEach(() => {
localStorage.clear();
vi.unstubAllGlobals();
});
describe("createDockPanelLayout", () => {
it("uses the caller's default dock for missing or invalid storage", () => {
const bottom = createLayout("bottom");
const right = createLayout("right");
expect(bottom.load()).toEqual(bottom.defaults);
localStorage.setItem("test.dock-panel.right", "{invalid");
expect(right.load()).toEqual(right.defaults);
});
it("restores valid layout fields and rejects invalid sizes", () => {
const layout = createLayout("bottom");
localStorage.setItem(
"test.dock-panel.bottom",
JSON.stringify({ open: true, dock: "right", height: 100, width: Number.NaN }),
);
expect(layout.load()).toEqual({
open: true,
dock: "right",
height: layout.defaults.height,
width: layout.defaults.width,
});
});
it("caps persisted sizes to the current viewport and saves the canonical shape", () => {
const layout = createLayout("right");
vi.stubGlobal("innerHeight", 500);
vi.stubGlobal("innerWidth", 750);
layout.save({ open: true, dock: "bottom", height: 900, width: 900 });
expect(layout.load()).toEqual({ open: true, dock: "bottom", height: 400, width: 600 });
});
});
+69
View File
@@ -0,0 +1,69 @@
export type DockPanelSide = "bottom" | "right";
type DockPanelLayout = {
open: boolean;
dock: DockPanelSide;
height: number;
width: number;
};
type DockPanelLayoutOptions = {
storageKey: string;
minHeight: number;
minWidth: number;
defaultDock: DockPanelSide;
defaultHeight: number;
defaultWidth: number;
};
export function createDockPanelLayout(options: DockPanelLayoutOptions) {
const defaults: DockPanelLayout = {
open: false,
dock: options.defaultDock,
height: options.defaultHeight,
width: options.defaultWidth,
};
// Re-clamp desktop-persisted sizes to 80% of the current viewport so dock
// chrome and the remaining app surface stay reachable on smaller windows.
const maxHeight = () =>
Math.max(options.minHeight, Math.floor((globalThis.innerHeight || 800) * 0.8));
const maxWidth = () =>
Math.max(options.minWidth, Math.floor((globalThis.innerWidth || 1280) * 0.8));
const clampSize = (value: unknown, min: number, max: number, fallback: number) => {
const size =
typeof value === "number" && Number.isFinite(value) && value >= min ? value : fallback;
return Math.min(size, max);
};
return {
defaults,
minHeight: options.minHeight,
minWidth: options.minWidth,
maxHeight,
maxWidth,
load(): DockPanelLayout {
try {
const raw = globalThis.localStorage?.getItem(options.storageKey);
if (!raw) {
return { ...defaults };
}
const parsed = JSON.parse(raw) as Partial<DockPanelLayout>;
return {
open: Boolean(parsed.open),
dock: parsed.dock === "bottom" || parsed.dock === "right" ? parsed.dock : defaults.dock,
height: clampSize(parsed.height, options.minHeight, maxHeight(), defaults.height),
width: clampSize(parsed.width, options.minWidth, maxWidth(), defaults.width),
};
} catch {
return { ...defaults };
}
},
save(layout: DockPanelLayout): void {
try {
globalThis.localStorage?.setItem(options.storageKey, JSON.stringify(layout));
} catch {
// Storage may be unavailable (private mode); layout just won't persist.
}
},
};
}
+6 -17
View File
@@ -3,40 +3,29 @@ import { html, nothing } from "lit";
import { property } from "lit/decorators.js";
import { formatDurationCompact } from "../lib/format.ts";
import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
import { PollController } from "../lit/poll-controller.ts";
class ElapsedTime extends OpenClawLightDomContentsElement {
@property({ type: Number }) startMs: number | null = null;
@property({ type: Number }) endMs: number | null = null;
private timer: number | null = null;
private readonly polling = new PollController(this, 1_000, () => this.requestUpdate(), false);
override connectedCallback() {
super.connectedCallback();
this.syncTimer();
}
override disconnectedCallback() {
this.stopTimer();
super.disconnectedCallback();
}
override updated() {
this.syncTimer();
}
private syncTimer() {
const ticking = this.isConnected && this.startMs != null && this.endMs == null;
if (ticking && this.timer == null) {
this.timer = window.setInterval(() => this.requestUpdate(), 1_000);
} else if (!ticking) {
this.stopTimer();
}
}
private stopTimer() {
if (this.timer != null) {
window.clearInterval(this.timer);
this.timer = null;
if (ticking) {
this.polling.start();
} else {
this.polling.stop();
}
}
+3 -15
View File
@@ -5,6 +5,7 @@ import { CONTROL_UI_BUILD_INFO, type ControlUiBuildInfo } from "../build-info.ts
import { t } from "../i18n/index.ts";
import { formatTimeAgo } from "../lib/format.ts";
import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
import { PollController } from "../lit/poll-controller.ts";
import "./tooltip.ts";
const BRANCH_DISPLAY_LENGTH = 14;
@@ -46,21 +47,8 @@ class SidebarBuildChip extends OpenClawLightDomContentsElement {
@property({ attribute: false }) gatewayVersion: string | null = null;
@property({ attribute: false }) onNavigate?: (routeId: "about") => void;
private ageTimer: ReturnType<typeof setInterval> | undefined;
override connectedCallback() {
super.connectedCallback();
// Relative age must advance without sidebar renders; teardown keeps tests and reconnects clean.
this.ageTimer = setInterval(() => this.requestUpdate(), 60_000);
}
override disconnectedCallback() {
if (this.ageTimer !== undefined) {
clearInterval(this.ageTimer);
this.ageTimer = undefined;
}
super.disconnectedCallback();
}
// Relative age must advance without sidebar renders; controller teardown keeps reconnects clean.
readonly polling = new PollController(this, 60_000, () => this.requestUpdate());
override render() {
const text = formatBuildChipText(CONTROL_UI_BUILD_INFO, Date.now());
+26 -68
View File
@@ -9,6 +9,7 @@ import { css, html, nothing, svg } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "../../i18n/index.ts";
import { OpenClawLitElement } from "../../lit/openclaw-element.ts";
import { createDockPanelLayout, type DockPanelSide } from "../dock-panel-layout.ts";
import { TerminalConnection, type TerminalGatewayClient } from "./terminal-connection.ts";
import { createIsolatedGhosttyTerminal } from "./terminal-runtime.ts";
import { terminalTheme } from "./terminal-theme.ts";
@@ -20,19 +21,12 @@ const PLUS_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="non
const DOCK_BOTTOM_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M2 10h12" /></svg>`;
const DOCK_RIGHT_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M10 2.5v11" /></svg>`;
type TerminalDock = "bottom" | "right";
type TerminalDock = DockPanelSide;
type TerminalToggleDetail = {
dock?: TerminalDock;
open?: boolean;
};
type PanelLayout = {
open: boolean;
dock: TerminalDock;
height: number;
width: number;
};
type TerminalTabState = {
id: string;
sequence: number;
@@ -85,57 +79,26 @@ function terminalTabStatusLabel(tab: TerminalTabState): string | null {
: t("terminal.exited");
}
const LAYOUT_KEY = "openclaw.terminal.panel.v1";
const panelLayout = createDockPanelLayout({
storageKey: "openclaw.terminal.panel.v1",
minHeight: 140,
minWidth: 320,
defaultDock: "bottom",
defaultHeight: 320,
defaultWidth: 520,
});
// Session ids for reattach after a reload/reconnect. Deliberately
// sessionStorage, not localStorage: attach is take-over, and a shared
// per-origin key would make multiple Control UI windows clobber each other's
// ids and steal each other's live shells. Per-tab storage survives exactly the
// cases reattach is for (reload, laptop sleep, transient disconnect).
const SESSIONS_KEY = "openclaw.terminal.sessions.v1";
const DEFAULT_LAYOUT: PanelLayout = { open: false, dock: "bottom", height: 320, width: 520 };
const MIN_HEIGHT = 140;
const MIN_WIDTH = 320;
const TOGGLE_EVENT = "openclaw:terminal-toggle";
const TERMINAL_FONT_FAMILY =
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Symbols Nerd Font Mono", "MesloLGLDZ Nerd Font Mono", "JetBrainsMono Nerd Font Mono", "Liberation Mono", monospace';
const TERMINAL_INPUT_DECODER = new TextDecoder();
const TERMINAL_OUTPUT_ENCODER = new TextEncoder();
function loadLayout(): PanelLayout {
try {
const raw = globalThis.localStorage?.getItem(LAYOUT_KEY);
if (!raw) {
return { ...DEFAULT_LAYOUT };
}
const parsed = JSON.parse(raw) as Partial<PanelLayout>;
return {
open: Boolean(parsed.open),
dock: parsed.dock === "right" ? "right" : "bottom",
height: clampSize(parsed.height, MIN_HEIGHT, maxPanelHeight(), DEFAULT_LAYOUT.height),
width: clampSize(parsed.width, MIN_WIDTH, maxPanelWidth(), DEFAULT_LAYOUT.width),
};
} catch {
return { ...DEFAULT_LAYOUT };
}
}
// A size persisted on a large desktop must not swallow a smaller window: cap
// the dock at 80% of the viewport so the header/resizer stay reachable and the
// shell content keeps a usable slice.
function maxPanelHeight(): number {
return Math.max(MIN_HEIGHT, Math.floor((globalThis.innerHeight || 800) * 0.8));
}
function maxPanelWidth(): number {
return Math.max(MIN_WIDTH, Math.floor((globalThis.innerWidth || 1280) * 0.8));
}
function clampSize(value: unknown, min: number, max: number, fallback: number): number {
const size =
typeof value === "number" && Number.isFinite(value) && value >= min ? value : fallback;
return Math.min(size, max);
}
function loadPersistedSessionIds(): string[] {
try {
const raw = globalThis.sessionStorage?.getItem(SESSIONS_KEY);
@@ -169,8 +132,8 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
@state() private open = false;
@state() private dock: TerminalDock = "bottom";
@state() private height = DEFAULT_LAYOUT.height;
@state() private width = DEFAULT_LAYOUT.width;
@state() private height = panelLayout.defaults.height;
@state() private width = panelLayout.defaults.width;
@state() private tabs: TerminalTabState[] = [];
@state() private activeId: string | null = null;
@state() private booting = false;
@@ -189,8 +152,8 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
// Re-clamp a dock sized on a larger window so the header/resizer never end
// up off-screen after the viewport shrinks (e.g. rotate, window resize).
private readonly onViewportResize = () => {
const height = Math.min(this.height, maxPanelHeight());
const width = Math.min(this.width, maxPanelWidth());
const height = Math.min(this.height, panelLayout.maxHeight());
const width = Math.min(this.width, panelLayout.maxWidth());
if (height === this.height && width === this.width) {
return;
}
@@ -205,7 +168,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
this.activeClient = this.client;
this.activeAvailable = this.available;
if (!this.fullscreen) {
const layout = loadLayout();
const layout = panelLayout.load();
this.dock = layout.dock;
this.height = layout.height;
this.width = layout.width;
@@ -314,7 +277,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
// panel WITHOUT persisting: a disconnect must not overwrite the user's
// open preference, or the reconnect path would never auto-reopen.
this.open = false;
} else if (!this.open && (this.fullscreen || loadLayout().open)) {
} else if (!this.open && (this.fullscreen || panelLayout.load().open)) {
// Hello arrived after mount (or a reconnect); restore the persisted
// open state (fullscreen documents are always open while available)
// and reattach persisted sessions where possible.
@@ -800,17 +763,12 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
}
private persistLayout(): void {
try {
const layout: PanelLayout = {
open: this.open,
dock: this.dock,
height: this.height,
width: this.width,
};
globalThis.localStorage?.setItem(LAYOUT_KEY, JSON.stringify(layout));
} catch {
// Storage may be unavailable (private mode); layout just won't persist.
}
panelLayout.save({
open: this.open,
dock: this.dock,
height: this.height,
width: this.width,
});
}
private startResize(event: PointerEvent): void {
@@ -822,11 +780,11 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
const startWidth = this.width;
const onMove = (move: PointerEvent) => {
if (this.dock === "bottom") {
const next = Math.max(MIN_HEIGHT, startHeight + (startY - move.clientY));
this.height = Math.min(next, maxPanelHeight());
const next = Math.max(panelLayout.minHeight, startHeight + (startY - move.clientY));
this.height = Math.min(next, panelLayout.maxHeight());
} else {
const next = Math.max(MIN_WIDTH, startWidth + (startX - move.clientX));
this.width = Math.min(next, maxPanelWidth());
const next = Math.max(panelLayout.minWidth, startWidth + (startX - move.clientX));
this.width = Math.min(next, panelLayout.maxWidth());
}
// Reflow the content reservation live so the shell tracks the drag.
this.syncLayoutReservation();
+4 -8
View File
@@ -2,6 +2,7 @@
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ConfigSchemaResponse, ConfigSnapshot, ConfigUiHints } from "../../api/types.ts";
import { schemaType, type JsonSchema } from "../../components/config-form.shared.ts";
import { copyToClipboard } from "../clipboard.ts";
import {
cloneConfigObject,
removePathValue,
@@ -790,10 +791,9 @@ export async function openConfigFile(state: ConfigState): Promise<void> {
let errorMessage = res.error || "Failed to open config file";
const path = res.path || state.configSnapshot?.path;
if (path) {
try {
await navigator.clipboard.writeText(path);
if (await copyToClipboard(path)) {
errorMessage += `\n\nFile path copied to clipboard: ${path}`;
} catch {
} else {
errorMessage += `\n\nFile path: ${path}`;
}
}
@@ -808,11 +808,7 @@ export async function openConfigFile(state: ConfigState): Promise<void> {
const errorMessage = String(err);
const path = state.configSnapshot?.path;
if (path) {
try {
await navigator.clipboard.writeText(path);
} catch {
// ignore
}
await copyToClipboard(path);
}
if (isCurrent()) {
state.lastError = errorMessage;
+1
View File
@@ -11,6 +11,7 @@ import {
} from "../../../src/infra/format-time/format-relative.ts";
import { t } from "../i18n/index.ts";
export { formatByteSize } from "@openclaw/normalization-core";
export { formatRelativeTimestamp, formatTimeAgo, formatDurationCompact, formatDurationHuman };
export function formatUnknownText(
+69
View File
@@ -0,0 +1,69 @@
// @vitest-environment node
import type { ReactiveController, ReactiveControllerHost } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import { PollController } from "./poll-controller.ts";
class TestHost implements ReactiveControllerHost {
readonly controllers: ReactiveController[] = [];
readonly requestUpdate = vi.fn();
readonly updateComplete = Promise.resolve(true);
addController(controller: ReactiveController): void {
this.controllers.push(controller);
}
removeController(controller: ReactiveController): void {
const index = this.controllers.indexOf(controller);
if (index !== -1) {
this.controllers.splice(index, 1);
}
}
connect(): void {
for (const controller of this.controllers) {
controller.hostConnected?.();
}
}
disconnect(): void {
for (const controller of this.controllers) {
controller.hostDisconnected?.();
}
}
}
afterEach(() => vi.useRealTimers());
describe("PollController", () => {
it("starts explicitly, ticks idempotently, and stops", () => {
vi.useFakeTimers();
const host = new TestHost();
const tick = vi.fn();
const polling = new PollController(host, 1_000, tick, false);
expect(polling.start()).toBe(true);
expect(polling.start()).toBe(false);
vi.advanceTimersByTime(2_000);
expect(tick).toHaveBeenCalledTimes(2);
polling.stop();
vi.advanceTimersByTime(1_000);
expect(tick).toHaveBeenCalledTimes(2);
});
it("auto-starts on connect and always stops on disconnect", () => {
vi.useFakeTimers();
const host = new TestHost();
const tick = vi.fn();
const polling = new PollController(host, 500, tick);
expect(host.controllers).toContain(polling);
host.connect();
vi.advanceTimersByTime(500);
expect(tick).toHaveBeenCalledOnce();
host.disconnect();
vi.advanceTimersByTime(500);
expect(tick).toHaveBeenCalledOnce();
});
});
+42
View File
@@ -0,0 +1,42 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
export class PollController implements ReactiveController {
private timer: ReturnType<typeof globalThis.setInterval> | null = null;
constructor(
host: ReactiveControllerHost,
private readonly intervalMs: number,
private readonly tick: () => void,
private readonly autoStart = true,
) {
host.addController(this);
}
hostConnected(): void {
if (this.autoStart) {
this.start();
}
}
hostDisconnected(): void {
this.stop();
}
start(): boolean {
if (this.timer !== null) {
return false;
}
this.timer = globalThis.setInterval(() => {
this.tick();
}, this.intervalMs);
return true;
}
stop(): void {
if (this.timer === null) {
return;
}
globalThis.clearInterval(this.timer);
this.timer = null;
}
}
+7 -15
View File
@@ -1,5 +1,6 @@
import type { GatewayBrowserClient, GatewayHelloOk } from "../../../api/gateway.ts";
import type { ConfigSnapshot } from "../../../api/types.ts";
import { copyToClipboard } from "../../../lib/clipboard.ts";
import type { RuntimeConfigCapability } from "../../../lib/config/index.ts";
import { isGatewayMethodAdvertised } from "../../../lib/gateway-methods.ts";
import { isPluginEnabledInConfigSnapshot } from "../../../lib/plugin-activation.ts";
@@ -1151,27 +1152,18 @@ export async function copyDreamingArchivePath(state: DreamingState): Promise<boo
if (!path) {
return false;
}
if (!globalThis.navigator?.clipboard?.writeText) {
state.dreamDiaryActionMessage = {
kind: "error",
text: "Could not copy archive path.",
};
return false;
}
try {
await globalThis.navigator.clipboard.writeText(path);
if (await copyToClipboard(path)) {
state.dreamDiaryActionMessage = {
kind: "success",
text: "Archive path copied.",
};
return true;
} catch {
state.dreamDiaryActionMessage = {
kind: "error",
text: "Could not copy archive path.",
};
return false;
}
state.dreamDiaryActionMessage = {
kind: "error",
text: "Could not copy archive path.",
};
return false;
}
export async function dedupeDreamDiary(state: DreamingState): Promise<boolean> {
+2 -1
View File
@@ -17,6 +17,7 @@ import type {
import { t } from "../../i18n/index.ts";
import { buildAgentContext } from "../../lib/agents/display.ts";
import type { AgentsPanel } from "../../lib/agents/index.ts";
import { copyToClipboard } from "../../lib/clipboard.ts";
import "./memory/memory-panel.ts";
import { renderAgentOverview } from "./panels-overview.ts";
import { renderAgentFiles, renderAgentChannels, renderAgentCron } from "./panels-status-files.ts";
@@ -165,7 +166,7 @@ export function renderAgents(props: AgentsProps) {
<button
type="button"
class="btn btn--sm btn--ghost"
@click=${() => void navigator.clipboard.writeText(selectedAgent.id)}
@click=${() => void copyToClipboard(selectedAgent.id)}
>
${t("agents.copyId")}
</button>
+6 -7
View File
@@ -58,6 +58,7 @@ import {
} from "../../lib/sessions/session-key.ts";
import { SessionUnreadPatchGuard } from "../../lib/sessions/unread.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { PollController } from "../../lit/poll-controller.ts";
import { refreshChatAvatar } from "./chat-avatar.ts";
import {
applyChatAgentsList,
@@ -256,6 +257,11 @@ function keyboardEventPathMatches(event: KeyboardEvent, selector: string): boole
}
class ChatPane extends OpenClawLightDomElement {
// One lifecycle-owned minute tick refreshes both relative labels and external PR state.
readonly minutePoll = new PollController(this, 60_000, () => {
this.requestUpdate();
void this.refreshSessionPullRequests();
});
@consume({ context: applicationContext, subscribe: true })
private context!: ChatPageContext;
@property({ attribute: false }) paneId = "single";
@@ -1474,13 +1480,6 @@ class ChatPane extends OpenClawLightDomElement {
this.applyGatewaySnapshot(snapshot);
}),
);
// PRs open, merge, and finish CI outside any gateway event stream, so the
// chip row refreshes on a coarse timer between session/connect refreshes.
const pullRequestTimer = window.setInterval(
() => void this.refreshSessionPullRequests(),
60_000,
);
chatState.addCleanup(() => window.clearInterval(pullRequestTimer));
chatState.addCleanup(
this.context.gateway.subscribeEvents((event) => {
const state = this.state;
@@ -17,6 +17,7 @@ import { icons } from "../../../components/icons.ts";
import "../../../components/tooltip.ts";
import { t } from "../../../i18n/index.ts";
import { copyToClipboard } from "../../../lib/clipboard.ts";
import { formatByteSize } from "../../../lib/format.ts";
import { isGatewayMethodAdvertised } from "../../../lib/gateway-methods.ts";
import {
scopedAgentParamsForSession,
@@ -740,13 +741,12 @@ function formatWorkspaceFileSize(file: { size?: number }): string {
if (typeof size !== "number" || !Number.isFinite(size) || size < 0) {
return "";
}
if (size >= 1024 * 1024) {
return `${(size / (1024 * 1024)).toFixed(1).replace(/\.0$/, "")} MB`;
}
if (size >= 1024) {
return `${(size / 1024).toFixed(1).replace(/\.0$/, "")} KB`;
}
return `${size} B`;
return formatByteSize(size, {
style: "legacy-binary",
maxUnit: "mega",
separator: " ",
fractionDigits: (value, unit) => (unit === "byte" ? null : Math.round(value * 10) % 10 ? 1 : 0),
});
}
function renderWorkspaceArtifactSize(artifact: { sizeBytes?: number }): string {
+2 -27
View File
@@ -87,9 +87,6 @@ type ChatThreadState = {
scrollTop: number;
} | null;
historyRenderAnchorFrame: number | null;
relativeTimeTimer: ReturnType<typeof setInterval> | null;
relativeTimeRequestUpdate: (() => void) | null;
relativeTimeVersion: number;
};
type ChatThreadProps = {
@@ -168,26 +165,9 @@ function createChatThreadState(): ChatThreadState {
historyRenderExpansionFrame: null,
historyRenderAnchorAdjustment: null,
historyRenderAnchorFrame: null,
relativeTimeTimer: null,
relativeTimeRequestUpdate: null,
relativeTimeVersion: 0,
};
}
const RELATIVE_TIME_REFRESH_MS = 60_000;
// Footer timestamps render relative labels ("5m ago") that go stale on idle
// panes; one per-pane minute tick keeps them fresh without per-message timers.
// The version bump must accompany requestUpdate: the message subtree is
// memoized by guard(), so a tick only re-renders it via this dependency.
function ensureRelativeTimeRefresh(state: ChatThreadState, requestUpdate: () => void) {
state.relativeTimeRequestUpdate = requestUpdate;
state.relativeTimeTimer ??= setInterval(() => {
state.relativeTimeVersion = (state.relativeTimeVersion + 1) % Number.MAX_SAFE_INTEGER;
state.relativeTimeRequestUpdate?.();
}, RELATIVE_TIME_REFRESH_MS);
}
const threadStates = new Map<string, ChatThreadState>();
function getChatThreadState(paneId: string): ChatThreadState {
@@ -235,11 +215,6 @@ export function resetChatThreadPresentationState(paneId?: string) {
if (state.historyRenderAnchorFrame != null) {
cancelAnimationFrame(state.historyRenderAnchorFrame);
}
if (state.relativeTimeTimer != null) {
clearInterval(state.relativeTimeTimer);
state.relativeTimeTimer = null;
state.relativeTimeRequestUpdate = null;
}
}
if (paneId) {
threadStates.delete(paneId);
@@ -738,7 +713,6 @@ function renderLoadingSkeleton() {
export function renderChatThread(props: ChatThreadProps) {
const state = getChatThreadState(props.paneId);
const requestUpdate = props.onRequestUpdate ?? (() => {});
ensureRelativeTimeRefresh(state, requestUpdate);
const displayStream = props.stream ?? null;
const sessionHost = props.sessionHost ?? null;
// Equivalence, not exact match: the default session travels under alias
@@ -878,7 +852,8 @@ export function renderChatThread(props: ChatThreadProps) {
deletedChatItemsSignature(deleted, chatItems),
stableBooleanMapSignature(expandedToolCards),
getAssistantAttachmentAvailabilityRenderVersion(),
state.relativeTimeVersion,
// The host minute poll requests an update; this key crosses guard() memoization.
Math.floor(Date.now() / 60_000),
getToolTitlesVersion(),
props.sessionKey,
props.fullMessageAgentId,
+14 -18
View File
@@ -24,6 +24,7 @@ import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"
import { i18n, isSupportedLocale, t, type Locale } from "../../i18n/index.ts";
import { isMissingOperatorReadScopeError } from "../../lib/gateway-errors.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { PollController } from "../../lit/poll-controller.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import {
AI_AGENTS_SECTION_KEYS,
@@ -282,7 +283,14 @@ export class ConfigPage extends OpenClawLightDomElement {
private systemInfoClient: GatewayBrowserClient | null = null;
private systemInfoLoading = false;
private systemInfoRequestId = 0;
private systemInfoPollInterval: ReturnType<typeof globalThis.setInterval> | null = null;
private readonly systemInfoPolling = new PollController(
this,
SYSTEM_INFO_POLL_INTERVAL_MS,
() => {
void this.loadSystemInfo();
},
false,
);
private pendingRouteTargetId: string | null = null;
private readonly subscriptions = new SubscriptionsController(this)
.watch(
@@ -322,7 +330,7 @@ export class ConfigPage extends OpenClawLightDomElement {
}
override disconnectedCallback() {
this.stopSystemInfoPolling();
this.systemInfoPolling.stop();
this.invalidateSystemInfoRequest();
this.runtimeConfigSource = null;
this.resetConfigViewState();
@@ -409,7 +417,7 @@ export class ConfigPage extends OpenClawLightDomElement {
private synchronizeSystemInfoGateway(gateway: ApplicationContext["gateway"]) {
if (gateway !== this.systemInfoGatewaySource) {
this.stopSystemInfoPolling();
this.systemInfoPolling.stop();
this.invalidateSystemInfoRequest();
this.systemInfoGatewaySource = gateway;
this.resetConfigViewState();
@@ -457,24 +465,12 @@ export class ConfigPage extends OpenClawLightDomElement {
supportsSystemInfo(gateway.hello) &&
gateway.client != null;
if (!shouldPoll) {
this.stopSystemInfoPolling();
this.systemInfoPolling.stop();
return;
}
if (this.systemInfoPollInterval !== null) {
return;
}
void this.loadSystemInfo();
this.systemInfoPollInterval = globalThis.setInterval(() => {
if (this.systemInfoPolling.start()) {
void this.loadSystemInfo();
}, SYSTEM_INFO_POLL_INTERVAL_MS);
}
private stopSystemInfoPolling() {
if (this.systemInfoPollInterval === null) {
return;
}
globalThis.clearInterval(this.systemInfoPollInterval);
this.systemInfoPollInterval = null;
}
private invalidateSystemInfoRequest() {
@@ -531,7 +527,7 @@ export class ConfigPage extends OpenClawLightDomElement {
if (isMissingOperatorReadScopeError(error) || isUnknownSystemInfoMethodError(error)) {
this.systemInfo = null;
this.systemInfoUnavailable = true;
this.stopSystemInfoPolling();
this.systemInfoPolling.stop();
}
} finally {
if (this.isCurrentSystemInfoRequest(requestId, client, gatewaySource)) {
+11 -17
View File
@@ -13,6 +13,7 @@ import {
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { loadGatewayDiagnostics } from "../../lib/gateway-diagnostics.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { PollController } from "../../lit/poll-controller.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { renderDebug } from "./view.ts";
@@ -41,7 +42,14 @@ class DebugPage extends OpenClawLightDomElement {
@state() private debugCallError: string | null = null;
@state() private eventLog: readonly EventLogEntry[] = [];
private debugPollInterval: ReturnType<typeof globalThis.setInterval> | null = null;
private readonly polling = new PollController(
this,
DEBUG_POLL_INTERVAL_MS,
() => {
void this.loadDiagnostics();
},
false,
);
private hasBoundGatewaySource = false;
private gatewaySource: ApplicationContext["gateway"] | null = null;
private requestGeneration = 0;
@@ -71,7 +79,6 @@ class DebugPage extends OpenClawLightDomElement {
);
override disconnectedCallback() {
this.stopPolling();
this.subscriptions.clear();
this.requestGeneration += 1;
this.gatewaySource = null;
@@ -108,23 +115,10 @@ class DebugPage extends OpenClawLightDomElement {
private syncPolling() {
if (!this.connected || !this.client) {
this.stopPolling();
this.polling.stop();
return;
}
if (this.debugPollInterval !== null) {
return;
}
this.debugPollInterval = globalThis.setInterval(() => {
void this.loadDiagnostics();
}, DEBUG_POLL_INTERVAL_MS);
}
private stopPolling() {
if (this.debugPollInterval === null) {
return;
}
globalThis.clearInterval(this.debugPollInterval);
this.debugPollInterval = null;
this.polling.start();
}
private ensureInitialDebug() {
+11 -17
View File
@@ -14,6 +14,7 @@ import {
isMissingOperatorReadScopeError,
} from "../../lib/gateway-errors.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { PollController } from "../../lit/poll-controller.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import {
DEFAULT_LOG_LEVEL_FILTERS,
@@ -51,7 +52,14 @@ class LogsPage extends OpenClawLightDomElement {
private logsCursor: number | null = null;
private readonly logsLimit = 500;
private readonly logsMaxBytes = 250_000;
private logsPollInterval: ReturnType<typeof globalThis.setInterval> | null = null;
private readonly polling = new PollController(
this,
LOGS_POLL_INTERVAL_MS,
() => {
void this.loadLogs({ quiet: true });
},
false,
);
private logsScrollFrame: number | null = null;
private contentScrollFrame: number | null = null;
private hasBoundGatewaySource = false;
@@ -95,7 +103,6 @@ class LogsPage extends OpenClawLightDomElement {
}
override disconnectedCallback() {
this.stopPolling();
this.subscriptions.clear();
this.requestGeneration += 1;
this.activeRequest = null;
@@ -150,23 +157,10 @@ class LogsPage extends OpenClawLightDomElement {
private syncPolling() {
if (!this.connected || !this.client) {
this.stopPolling();
this.polling.stop();
return;
}
if (this.logsPollInterval !== null) {
return;
}
this.logsPollInterval = globalThis.setInterval(() => {
void this.loadLogs({ quiet: true });
}, LOGS_POLL_INTERVAL_MS);
}
private stopPolling() {
if (this.logsPollInterval === null) {
return;
}
globalThis.clearInterval(this.logsPollInterval);
this.logsPollInterval = null;
this.polling.start();
}
private ensureInitialLogs() {
+12 -17
View File
@@ -36,6 +36,7 @@ import {
type NodesPageDataState,
} from "../../lib/nodes/index.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { PollController } from "../../lit/poll-controller.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { renderNodes } from "./view.ts";
@@ -97,7 +98,15 @@ class NodesPage extends OpenClawLightDomElement implements NodesPageDataState {
private hasBoundGateway = false;
private presenceRequestId = 0;
private gatewaySource: ApplicationContext["gateway"] | null = null;
private nodesPollInterval: ReturnType<typeof globalThis.setInterval> | null = null;
private readonly polling = new PollController(
this,
NODES_ACTIVE_POLL_INTERVAL_MS,
() => {
void loadNodes(this, { quiet: true });
void loadDevices(this, { quiet: true });
},
false,
);
private readonly subscriptions = new SubscriptionsController(this)
.watch(
() => this.context?.runtimeConfig,
@@ -164,7 +173,6 @@ class NodesPage extends OpenClawLightDomElement implements NodesPageDataState {
}
override disconnectedCallback() {
this.stopPolling();
this.subscriptions.clear();
this.requestGeneration += 1;
this.presenceRequestId += 1;
@@ -286,23 +294,10 @@ class NodesPage extends OpenClawLightDomElement implements NodesPageDataState {
private syncPolling() {
if (this.connected && this.client) {
if (this.nodesPollInterval == null) {
this.nodesPollInterval = globalThis.setInterval(() => {
void loadNodes(this, { quiet: true });
void loadDevices(this, { quiet: true });
}, NODES_ACTIVE_POLL_INTERVAL_MS);
}
this.polling.start();
return;
}
this.stopPolling();
}
private stopPolling() {
if (this.nodesPollInterval == null) {
return;
}
clearInterval(this.nodesPollInterval);
this.nodesPollInterval = null;
this.polling.stop();
}
private async loadPresence() {
+2 -5
View File
@@ -5,6 +5,7 @@ import { html, nothing } from "lit";
import { formatDurationCompact } from "../../../../src/infra/format-time/format-duration.ts";
import { t } from "../../i18n/index.ts";
import "../../components/tooltip.ts";
import { copyToClipboard } from "../../lib/clipboard.ts";
import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts";
import {
buildUsageCostWindows,
@@ -875,11 +876,7 @@ function renderSessionsCard(
};
const copySessionName = async (s: UsageSessionEntry) => {
const text = formatSessionListLabel(s);
try {
await navigator.clipboard.writeText(text);
} catch {
// Best effort; clipboard can fail on insecure contexts or denied permission.
}
await copyToClipboard(text);
};
const buildSessionMeta = (s: UsageSessionEntry): string[] => {