mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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[] => {
|
||||
|
||||
Reference in New Issue
Block a user