From ec83118f2eb7067615cc2b1f3495c0cffaddde5f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 23 Sep 2026 12:02:31 +0800 Subject: [PATCH 1/2] test(perf): measure render work while streaming and idle Adds a streaming-render Electron probe to the frontend performance lane and a browser-panel idle case to the Storybook driver. Both run without reduced motion, so infinite animations and per-frame geometry polling show up, and report style writes, running infinite animations, CDP style recalcs and renderer task time per second. Generated-by: Claude Code --- scripts/perf/CI.md | 4 + scripts/perf/playwright.config.ts | 7 +- scripts/perf/storybook.mjs | 70 +++++++++ scripts/perf/streaming-render.spec.ts | 196 ++++++++++++++++++++++++++ 4 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 scripts/perf/streaming-render.spec.ts diff --git a/scripts/perf/CI.md b/scripts/perf/CI.md index 44da99444f..4e1fd05dec 100644 --- a/scripts/perf/CI.md +++ b/scripts/perf/CI.md @@ -39,6 +39,10 @@ Protocol `warm-projection-codec-ms` retains production wire decoding but exclude - DOM-ready latency includes Playwright polling/IPC. Stream lag starts at renderer subscription delivery and ends at DOM mutation; neither is proof of pixels presented. These are laboratory measurements, not whole-page INP. Long tasks and CPU TaskDuration do not substitute for anchor geometry, energy watts or OS wakeups. - Repeated actions use a warm process. First-action samples are labelled separately, not called cold application startup. Storybook reloads for cold-scroll trials, then repeats expansion and closure in the same mounted story. Heap readings include garbage awaiting collection and do not establish a leak by themselves. Idle retains ten approximately one-second CPU/heap samples and the CPU profile; task duration is normalized by the actual CDP timestamp interval. Browser-context traces retain DOM snapshots, and their overhead is included in the laboratory timings. Long-task/LoAF rows labelled case-total include every operation after observer installation in that case. Actual disk-cold startup, RTT injection, physical presentation, wakeups and long-duration leak acceptance remain outside this first harness. +## Render energy while streaming and idle + +`streaming-render.spec.ts` (`frontend-streaming-render`) runs without reduced motion, because the work it counts is animation and re-rendering that reduced motion hides. In one Electron + Host process it sends 30 short prompts to build a prompt rail, then streams the fixed 679-character prompt five times. Each stream reports CDP style recalcs, layouts, task and script time per second, inline `style` attribute writes per second (whole document and prompt rail), and the most infinite animations running at once. Five idle seconds follow. `storybook.mjs` also reports `frontend-storybook-browser-idle`: renderer task time per second with the Workbar browser strip showing a loaded page and nothing happening. These are counts and renderer-thread time; they are not GPU frames or power, and xvfb paces frames at 60Hz, so per-frame costs double on a 120Hz display. + ## #5184 production layout measurements The frontend lane measures the current production layout. diff --git a/scripts/perf/playwright.config.ts b/scripts/perf/playwright.config.ts index 8342443c14..b2746922e3 100644 --- a/scripts/perf/playwright.config.ts +++ b/scripts/perf/playwright.config.ts @@ -20,7 +20,12 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: '.', - testMatch: ['frontend.spec.ts', 'geometry-navigation.spec.ts', 'scroll-input.spec.ts'], + testMatch: [ + 'frontend.spec.ts', + 'geometry-navigation.spec.ts', + 'scroll-input.spec.ts', + 'streaming-render.spec.ts', + ], workers: 1, retries: 0, timeout: 180000, diff --git a/scripts/perf/storybook.mjs b/scripts/perf/storybook.mjs index 59a2dbfbcf..718b3703f5 100644 --- a/scripts/perf/storybook.mjs +++ b/scripts/perf/storybook.mjs @@ -52,7 +52,77 @@ async function scrollSteps(page) { return result; }); } +async function browserPanelIdle() { + const page = await browser.newPage({ + viewport: { width: 1400, height: 900 }, + colorScheme: 'light', + reducedMotion: 'no-preference', + }); + await page.addInitScript(() => { + window.__storyDone = new Promise((resolve, reject) => { + const hook = () => { + const channel = window.__STORYBOOK_PREVIEW__?.channel; + if (!channel) return setTimeout(hook, 20); + channel.on('storyFinished', (payload) => + payload?.status === 'error' + ? reject(new Error('storyFinished: ' + JSON.stringify(payload))) + : resolve(), + ); + for (const event of ['storyThrewException', 'playFunctionThrewException', 'storyErrored']) + channel.on(event, (error) => reject(new Error(event + ': ' + (error?.message ?? '')))); + }; + hook(); + }); + }); + const cdp = await page.context().newCDPSession(page); + await cdp.send('Performance.enable'); + const metrics = async () => + Object.fromEntries( + (await cdp.send('Performance.getMetrics')).metrics.map((m) => [m.name, m.value]), + ); + const taskMs = []; + for (let trial = 0; trial < 3; trial++) { + await page.goto( + server.baseUrl + '/iframe.html?id=product-session-workbar--browser-loaded&viewMode=story', + ); + await page.evaluate(() => window.__storyDone); + await page.locator('.maka-browser-strip').waitFor(); + await page.waitForTimeout(1000); + let before = await metrics(); + for (let sample = 0; sample < 5; sample++) { + await page.waitForTimeout(1000); + const after = await metrics(); + const seconds = after.Timestamp - before.Timestamp; + taskMs.push(((after.TaskDuration - before.TaskDuration) * 1000) / seconds); + before = after; + } + } + await report( + 'frontend-storybook-browser-idle', + { + fixture: 'SessionWorkbar BrowserLoaded story (mocked browser service, page loaded)', + chromium: browser.version(), + viewport: '1400x900', + theme: 'light', + motion: 'no-preference', + repetitions: 3, + conditions: + 'Three fresh story navigations; after the play function finishes and 1s settles, five one-second samples of an idle Workbar with the browser strip showing.', + limits: + 'Synthetic Workbar with no native view or Host; measures the renderer work the strip does to mirror its rect, not the main-process IPC it causes.', + }, + [ + { + scenario: 'browser-panel-idle', + metric: 'renderer-task-ms-per-second', + ...summarize(taskMs), + }, + ], + ); + await page.close(); +} try { + await browserPanelIdle(); const page = await browser.newPage({ viewport: { width: 1400, height: 900 }, colorScheme: 'light', diff --git a/scripts/perf/streaming-render.spec.ts b/scripts/perf/streaming-render.spec.ts new file mode 100644 index 0000000000..3f6bef557b --- /dev/null +++ b/scripts/perf/streaming-render.spec.ts @@ -0,0 +1,196 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { test, expect, type CDPSession, type Locator, type Page } from '@playwright/test'; +import { withE2eWindow, COMPOSER_INPUT } from '../../apps/desktop/e2e/fixtures'; +import { report, summarize } from './report.mjs'; + +const warmups = 30; +const streams = 5; +const idleSeconds = 5; +const rows: Array> = []; + +async function activate(locator: Locator) { + await expect(locator).toBeVisible(); + await expect(locator).toBeEnabled(); + await locator.evaluate((el) => { + el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); + }); +} +async function input(page: Page, text: string) { + await page.locator(COMPOSER_INPUT).evaluate((el, value) => { + (el as HTMLElement).focus(); + el.textContent = value; + el.dispatchEvent( + new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }), + ); + }, text); + await expect(page.locator(COMPOSER_INPUT)).toHaveText(text); +} +function row(scenario: string, metric: string, values: number[]) { + rows.push({ scenario, metric, ...summarize(values) }); +} + +type Metrics = Record; +async function metrics(cdp: CDPSession): Promise { + const { metrics } = await cdp.send('Performance.getMetrics'); + return Object.fromEntries(metrics.map((m) => [m.name, m.value])); +} +/** Per-second rates between two CDP samples, normalized by their own timestamps. */ +function rates(before: Metrics, after: Metrics) { + const seconds = after.Timestamp - before.Timestamp; + const per = (name: string, scale = 1) => ((after[name] - before[name]) * scale) / seconds; + return { + 'style-recalcs-per-second': per('RecalcStyleCount'), + 'layouts-per-second': per('LayoutCount'), + 'renderer-task-ms-per-second': per('TaskDuration', 1000), + 'script-ms-per-second': per('ScriptDuration', 1000), + }; +} + +test.afterEach(async ({}, info) => { + if (rows.length) + await report( + 'frontend-streaming-render', + { + status: info.status, + fixture: `fresh seeded session with ${warmups} short prompts, normal fake stream (9 characters/45ms)`, + repetitions: streams, + viewport: '1400x900', + theme: 'light', + motion: 'no-preference', + conditions: `One fresh Electron + real Host. Sidebar expanded. ${warmups} short prompts build the prompt rail, then ${streams} sequential streams of the same 679-character prompt, each measured from the first delta until the streaming bubble is gone; then ${idleSeconds} one-second idle samples.`, + limits: + 'Counts and CDP renderer-thread time, not power, GPU frames or wakeups. xvfb drives frames at 60Hz; a 120Hz display doubles per-frame costs. Compositor-only animations do not show in style recalcs, hence the separate running-animation count.', + }, + rows, + ); +}); + +test('streaming in a session with a long prompt rail, then idle', { + annotation: { type: 'perf-report', description: 'frontend-streaming-render' }, +}, async () => { + test.setTimeout(180_000); + await withE2eWindow( + { + seed: true, + readinessSelector: COMPOSER_INPUT, + locale: 'zh-CN', + showWindow: true, + }, + async (page) => { + await page.setViewportSize({ width: 1400, height: 900 }); + await page.emulateMedia({ colorScheme: 'light', reducedMotion: 'no-preference' }); + expect( + await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches), + ).toBe(false); + const expand = page.getByRole('button', { name: '展开侧边栏', exact: true }); + if (await expand.isVisible()) await activate(expand); + const send = async (text: string) => { + await input(page, text); + await activate(page.getByRole('button', { name: '发送', exact: true })); + }; + const streaming = page.locator('.maka-bubble-streaming'); + for (let i = 0; i < warmups; i++) { + await send('performance warmup ' + i); + await expect(page.getByRole('log')).toContainText( + 'Fake backend received: performance warmup ' + i, + { timeout: 20_000 }, + ); + await expect(streaming).toHaveCount(0); + } + await expect(page.locator('.maka-prompt-rail-tick')).toHaveCount(warmups); + const cdp = await page.context().newCDPSession(page); + await cdp.send('Performance.enable'); + await page.evaluate(() => { + const counts = { style: 0, railStyle: 0 }; + (window as any).__perfStyleWrites = counts; + new MutationObserver((records) => { + for (const record of records) { + counts.style++; + if ((record.target as Element).closest('.maka-prompt-rail')) counts.railStyle++; + } + }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['style'] }); + }); + const styleWrites = () => + page.evaluate( + () => ({ ...(window as any).__perfStyleWrites }) as { style: number; railStyle: number }, + ); + const infiniteAnimations = () => + page.evaluate( + () => + document + .getAnimations() + .filter( + (animation) => + animation.playState === 'running' && + animation.effect?.getComputedTiming().endTime === Infinity, + ).length, + ); + + const prompt = ('performance fixture ' + 'abcdefghij '.repeat(60)).trimEnd(); + const perStream: Record = {}; + const push = (metric: string, value: number) => (perStream[metric] ??= []).push(value); + for (let i = 0; i < streams; i++) { + await send(prompt); + await expect(streaming).toContainText('Fake backend received'); + const before = await metrics(cdp); + const writesBefore = await styleWrites(); + let animations = 0; + while (await streaming.count()) { + animations = Math.max(animations, await infiniteAnimations()); + await page.waitForTimeout(250); + } + const after = await metrics(cdp); + const writesAfter = await styleWrites(); + const seconds = after.Timestamp - before.Timestamp; + expect(seconds).toBeGreaterThan(1); + for (const [metric, value] of Object.entries(rates(before, after))) push(metric, value); + push( + 'style-attribute-writes-per-second', + (writesAfter.style - writesBefore.style) / seconds, + ); + push( + 'prompt-rail-style-writes-per-second', + (writesAfter.railStyle - writesBefore.railStyle) / seconds, + ); + push('max-running-infinite-animations', animations); + await expect(page.locator('[data-turn-id]').last()).toContainText( + 'renderer loop are connected.', + ); + } + for (const [metric, values] of Object.entries(perStream)) row('streaming', metric, values); + + await page.waitForTimeout(1000); + const idle: Record = {}; + let before = await metrics(cdp); + for (let sample = 0; sample < idleSeconds; sample++) { + await page.waitForTimeout(1000); + const after = await metrics(cdp); + for (const [metric, value] of Object.entries(rates(before, after))) + (idle[metric] ??= []).push(value); + before = after; + } + idle['running-infinite-animations'] = [await infiniteAnimations()]; + for (const [metric, values] of Object.entries(idle)) + row('idle-after-streaming', metric, values); + await cdp.detach(); + }, + ); +}); From dc0a59854a4c976acfbd2013464477541482dffe Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 23 Sep 2026 11:38:59 +0800 Subject: [PATCH 2/2] perf(desktop): stop idle and streaming render work Normal conversations kept the renderer and GPU busy with work that changed nothing on screen: - The browser panel and WorkHub dock polled their geometry every frame while a page was showing, even when idle (~120 style recalcs/s). They now use watchNativeSurface: ResizeObserver, window resize and an IntersectionObserver armed on the element's own rect for moves, plus toggle capture for overlays. Frames are sampled only while a non-empty overlay is open, since its geometry can change without any event, or while an ancestor clips the element, since the move observer cannot see a clipped element move. - The prompt rail replaced every entry on each streaming delta, and a caller's fresh highlight handler re-rendered every tick (WorkHub). It now reuses unchanged entries and hands ticks a stable handler. - astryx useTouchTrigger returned a new object per render, so every HoverCard re-render detached and re-attached its trigger ref, rewriting anchor-name and rebinding listeners. The patch memoizes it. - The preload mark breathed forever, hidden, after the overlay faded; it now runs four cycles, which outlast the 8s overlay timeout. Generated-by: Claude Code --- .../contracts/native-surface-occlusion.ts | 88 +++++++++++++++++++ .../workbar/tools/browser/browser-panel.tsx | 18 ++-- .../features/workhub/ui/workhub-dock.tsx | 15 ++-- apps/desktop/src/renderer/index.html | 4 +- .../stories/session-workbar.stories.tsx | 60 ++++++++++++- .../src/__tests__/prompt-anchor-rail.test.ts | 18 ++++ .../prompt-rail-reading-position.test.tsx | 43 +++++++++ packages/ui/src/chat-view.tsx | 48 ++++------ packages/ui/src/prompt-anchor-rail.tsx | 35 +++++++- patches/@astryxdesign+core+0.6.2.patch | 63 +++++++++++++ 10 files changed, 337 insertions(+), 55 deletions(-) diff --git a/apps/desktop/src/renderer/application/contracts/native-surface-occlusion.ts b/apps/desktop/src/renderer/application/contracts/native-surface-occlusion.ts index 28be7e20de..d220b5c7d5 100644 --- a/apps/desktop/src/renderer/application/contracts/native-surface-occlusion.ts +++ b/apps/desktop/src/renderer/application/contracts/native-surface-occlusion.ts @@ -25,3 +25,91 @@ export function isNativeSurfaceOccluded(rect: DOMRect, document: Document): bool return bounds.width > 0 && bounds.height > 0 && bounds.left < rect.right && bounds.right > rect.left && bounds.top < rect.bottom && bounds.bottom > rect.top; }); } + +export interface NativeSurfaceWatch { + /** Re-run `sync` next frame for a change the watch cannot observe. */ + refresh(): void; + dispose(): void; +} + +/** + * Runs `sync` in a frame after the element's box or an overlay above it may + * have changed. With no overlay open, everything arrives through events and + * observers, so an idle surface costs nothing. An open overlay can move or + * resize with no event at all (text, attributes, anchor positioning), so + * frames are sampled for as long as one is open. + */ +export function watchNativeSurface(element: HTMLElement, sync: () => void): NativeSurfaceWatch { + const document = element.ownerDocument; + const view = document.defaultView!; + let frame = 0; + let moveKey = ''; + let move: IntersectionObserver | undefined; + let emptyOverlays: Element[] = []; + // The toast viewport stays open while empty, and no toggle fires when a + // toast fills it. + const filled = new view.MutationObserver(() => refresh()); + const overlayOpen = () => { + const open = Array.from(document.querySelectorAll(':popover-open, dialog[open]')); + const empty = open.filter((overlay) => overlay.matches(':empty')); + if (empty.length !== emptyOverlays.length || empty.some((overlay, index) => overlay !== emptyOverlays[index])) { + filled.disconnect(); + for (const overlay of empty) filled.observe(overlay, { childList: true }); + emptyOverlays = empty; + } + return open.length > empty.length; + }; + const refresh = () => { + if (!frame) frame = view.requestAnimationFrame(run); + }; + // A box can move without resizing: a window resize shifts a right-anchored + // strip. An observer whose root is shrunk to the box's own rect fires as soon + // as the box leaves it. It cannot see a move of a box that an ancestor + // already clips, so a partly hidden box is sampled until it is whole again. + let clipped = false; + const observeMove = () => { + const rect = element.getBoundingClientRect(); + const root = document.documentElement; + const insets = [rect.top, root.clientWidth - rect.right, root.clientHeight - rect.bottom, rect.left].map(Math.floor); + const key = `${insets.join()},${rect.width},${rect.height}`; + if (key === moveKey) return; + moveKey = key; + move?.disconnect(); + move = undefined; + clipped = false; + if (rect.width === 0 || rect.height === 0) return; + let armed = false; + move = new view.IntersectionObserver((entries) => { + const whole = entries.at(-1)?.intersectionRatio === 1; + const first = !armed; + armed = true; + clipped = !whole; + if (!first || !whole) refresh(); + }, { rootMargin: insets.map((inset) => `${-inset}px`).join(' '), threshold: 1 }); + move.observe(element); + }; + const run = () => { + frame = 0; + sync(); + observeMove(); + if (overlayOpen() || clipped) refresh(); + }; + const resize = new view.ResizeObserver(refresh); + resize.observe(element); + view.addEventListener('resize', refresh); + // Toggle events do not bubble; capture sees every popover and dialog. + document.addEventListener('toggle', refresh, true); + refresh(); + return { + refresh, + dispose() { + view.cancelAnimationFrame(frame); + frame = 0; + resize.disconnect(); + move?.disconnect(); + filled.disconnect(); + view.removeEventListener('resize', refresh); + document.removeEventListener('toggle', refresh, true); + }, + }; +} diff --git a/apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx index e722f102e5..7f886831ae 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx @@ -32,7 +32,7 @@ * It mounts only for sessions with a live view (see browser:live), so an * ordinary chat reserves no space. */ -import { isNativeSurfaceOccluded } from '../../../../application/contracts/native-surface-occlusion.js'; +import { isNativeSurfaceOccluded, watchNativeSurface } from '../../../../application/contracts/native-surface-occlusion.js'; import { useCallback, useEffect, useRef, useState } from 'react'; import { ICON_SIZE, ChevronLeft, ChevronRight, Globe, RotateCw, X } from '@maka/ui/icons'; import { normalizeBrowserAddressInput, type BrowserState } from '@maka/core/browser'; @@ -123,10 +123,8 @@ export function BrowserPanel(props: { sessionId: string; hidden: boolean }) { }; }, [browser, sessionId, hidden]); - // Mirror the strip's on-screen rect to main every animation frame while it is - // showable. Position shifts on window resize and sidebar drags even when the - // size is unchanged, which a ResizeObserver would miss; a getBoundingClientRect - // per frame is negligible and the IPC only fires when the rect changes. + // Mirror the strip's on-screen rect to main while it is showable. The IPC + // only fires when the rect changes. const showView = !hidden && state.hasPage; useEffect(() => { // Capture the injected capability because this passive cleanup may run @@ -138,12 +136,11 @@ export function BrowserPanel(props: { sessionId: string; hidden: boolean }) { } const el = stripRef.current; if (!el) return; - let raf = 0; let last = ''; let active = true; let covered = false; let revision = 0; - const tick = () => { + const sync = () => { const r = el.getBoundingClientRect(); const rect = { x: Math.round(r.left), @@ -167,18 +164,17 @@ export function BrowserPanel(props: { sessionId: string; hidden: boolean }) { } else setBackdrop(undefined); last = ''; } - if (occluded) { raf = requestAnimationFrame(tick); return; } + if (occluded) return; const key = `${rect.x},${rect.y},${rect.width},${rect.height}`; if (key !== last) { last = key; browser.setViewport({ sessionId, rect }); } - raf = requestAnimationFrame(tick); }; - raf = requestAnimationFrame(tick); + const surface = watchNativeSurface(el, sync); return () => { active = false; - cancelAnimationFrame(raf); + surface.dispose(); browser.setViewport({ sessionId, rect: null }); }; }, [browser, sessionId, showView]); diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-dock.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-dock.tsx index 15c616ce13..09ec5a9f17 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-dock.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-dock.tsx @@ -18,7 +18,7 @@ */ import type { WorkbarTogglePosition } from '@maka/core/settings'; -import { isNativeSurfaceOccluded } from '../../../application/contracts/native-surface-occlusion.js'; +import { isNativeSurfaceOccluded, watchNativeSurface, type NativeSurfaceWatch } from '../../../application/contracts/native-surface-occlusion.js'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Button } from '@astryxdesign/core'; import { useUiLocale } from '@maka/ui'; @@ -51,17 +51,17 @@ export function WorkHubDock({ enabled, visible = true, workbarCollapsed, workbar unsubscribe(); }; }, [presentation]); + const surface = useRef(undefined); useLayoutEffect(() => { const node = element.current; if (!node) return; - let frame = 0; let active = true; let revision = 0; let last = ''; let covered = false; + const docked = snapshot?.placement === 'docked'; const update = () => { const rect = node.getBoundingClientRect(); - const docked = snapshot?.placement === 'docked'; const occluded = visible && docked && isNativeSurfaceOccluded(rect, node.ownerDocument); const host = { visible: enabled && visible && rect.width > 0 && rect.height > 0, @@ -80,19 +80,20 @@ export function WorkHubDock({ enabled, visible = true, workbarCollapsed, workbar if (active && current === revision && image) setBackdrop(image); }).catch(report); } - // Menus animate and the sidebar can move without resizing this node. - // Only changed geometry/occlusion crosses IPC. - if (visible && docked) frame = requestAnimationFrame(update); }; update(); + surface.current = visible && docked ? watchNativeSurface(node, update) : undefined; return () => { active = false; - cancelAnimationFrame(frame); + surface.current?.dispose(); + surface.current = undefined; void presentation .setHost({ visible: false, rect: { x: 0, y: 0, width: 0, height: 0 } }) .catch(() => undefined); }; }, [enabled, presentation, visible, snapshot?.placement]); + // The host also reports Workbar state, which can change without moving this node. + useEffect(() => surface.current?.refresh(), [workbarCollapsed, workbarTogglePosition]); return (