From 659c2a20e23c5d70715ed6dd37a7194cf8fc287d Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 1 Sep 2026 00:30:01 +0800 Subject: [PATCH 1/8] fix: bottom-strip drag no longer leaks the panel width into the host layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bottom drag applied the panel's persisted width preference to --dsh-sidebar-width regardless of panelOpen (only the idle push effect gated it through layoutPushSize). With the right panel closed, the first drag frame jumped the variable 0 → ~400px: #root lost 400px instantly, the host frame's viewport dropped across its 1024px auto-collapse breakpoint, and the NATIVE left sidebar snapped to its 56px rail — then snapped back on release. The bottom strip is the only drag reachable with the panel closed, which is why only it showed. applyDrag now rides the same gate as the push effect for the PUSHED width; the panel-side DOM writes (panelRef/bottomRef/lastDragSize) keep the raw width — the bottom panel's right edge derives from it. The e2e regression pins the pose: panel closed + bottom open, drag the strip, and every sampled frame must keep the pushed width at 0, the host grid template, the left column width and #root's right edge unchanged. Verified red on the old code (leaked 504px). --- src/client/Sidebar.tsx | 9 ++- tests/e2e/drag-layout.e2e.ts | 137 +++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 8897571..891fa32 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -993,7 +993,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // drag write-only (no React re-render mid-drag). bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRectRef.current.right) + (width - (state?.width ?? 0))}px`) const bottomPush = !narrow && state?.bottomOpen === true ? height + keyboardInset : 0 - writeGeometry(width, bottomPush) + // The pushed width must ride the same gate as the committed push effect + // (layoutPushSize): a collapsed right panel pushes 0. The bottom strip is + // the only drag reachable with the panel closed — writing the panel's + // persisted width preference here squeezed #root mid-drag, dropped the + // host viewport across its 1024px auto-collapse breakpoint, and the + // native left sidebar snapped to its 56px rail (and back on release). + const pushWidth = !narrow && state?.panelOpen === true ? Math.min(width, window.innerWidth) : 0 + writeGeometry(pushWidth, bottomPush) } // Drags write at most once per frame: pointer events fire several times diff --git a/tests/e2e/drag-layout.e2e.ts b/tests/e2e/drag-layout.e2e.ts index a3ce7fb..dbc1ed2 100644 --- a/tests/e2e/drag-layout.e2e.ts +++ b/tests/e2e/drag-layout.e2e.ts @@ -642,3 +642,140 @@ test('the bottom-push anchor resolves through the composite selectors (at least expect(anchors.same, 'both selectors must hit the SAME center-column element').toBe(true) } }) + +/* ── bottom-strip drag with the right panel CLOSED must not touch the host + layout — the regression test for the width-gate fix ───────────────── */ + +test('dragging the bottom strip while the right panel is closed keeps the host layout still', async ({ page }) => { + // The bottom drag used to write the panel's persisted width preference into + // `--dsh-sidebar-width` regardless of `panelOpen` (only the idle push + // effect gated it): with the panel closed the variable jumped 0 → ~400px on the + // first drag frame, #root lost 400px instantly, the host frame's viewport + // dropped across its 1024px auto-collapse breakpoint, and the NATIVE left + // sidebar snapped to its 56px rail — then snapped back on release. The + // panel-side DOM writes keep the raw width (the bottom panel's `right` + // derives from it); only the PUSHED width must ride the panelOpen gate. + await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) + await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) + const sidebar = page.locator('[data-dsh-better-sidebar]') + await expect(sidebar).toBeAttached({ timeout: 90_000 }) + await dismissOnboarding(page) + + // The right panel stays CLOSED (openByDefault off): the width variable + // must read as the collapsed push before anything happens. + const readPushWidth = (): Promise => page.evaluate(() => { + const value = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) + return Number.isNaN(value) ? 0 : value + }) + await expect + .poll(async () => { + const height = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0) + return height + }, { timeout: 90_000 }) + .toBe(0) + + const bottomExpand = sidebar.getByRole('button', { name: 'Expand bottom panel' }) + await expect(bottomExpand, 'the toggle cluster must offer the bottom-panel expand button').toHaveCount(1) + await bottomExpand.click() + await expect + .poll(async () => { + const value = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0) + return value + }, { timeout: 90_000 }) + .toBeGreaterThan(0) + expect(await readPushWidth(), 'the closed right panel must push 0 width').toBe(0) + + // Per-frame sampler on the HOST side: the pushed width variable, the host + // frame's inline grid template (its first track is the native left + // sidebar's width), the rendered left-column width (the grid transition + // animates mid-states, so this catches even a transient snap), and #root's + // right edge (where the width push lands). + await page.evaluate(() => { + const samples: Array<{ t: number; varW: number; gridCols: string; leftColW: number; rootRight: number; dragging: boolean }> = [] + const center = document.querySelector('#root [data-slot="conversation"]')?.parentElement ?? null + const frame = center?.parentElement ?? null + const leftCol = frame?.firstElementChild ?? null + const root = document.querySelector('#root') as HTMLElement + const loop = (): void => { + const varW = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) + samples.push({ + t: performance.now(), + varW: Number.isNaN(varW) ? 0 : varW, + gridCols: frame instanceof HTMLElement ? frame.style.gridTemplateColumns : '', + leftColW: leftCol?.getBoundingClientRect().width ?? -1, + rootRight: root.getBoundingClientRect().right, + dragging: document.body.hasAttribute('data-dsh-sidebar-dragging'), + }) + requestAnimationFrame(loop) + } + requestAnimationFrame(loop) + ;(window as unknown as { __bottomDragSamples: typeof samples }).__bottomDragSamples = samples + }) + + // The bottom strip is the panel's top-edge row-resize hit strip. The panel + // SLIDES DOWN into place on expand: a box read mid-animation sits above the + // settled geometry and the pointerdown lands off-strip (the drag never + // starts). Wait until the strip's center sits at innerHeight - the pushed + // height (the panel's final top edge). + await expect + .poll(async () => { + const probe = await page.evaluate(() => { + const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]') + if (bottom === null) return null + const strip = [...bottom.querySelectorAll('*')].find(el => getComputedStyle(el).cursor === 'row-resize') + if (strip === undefined) return null + const r = strip.getBoundingClientRect() + const heightVar = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0 + return Math.abs((r.y + r.height / 2) - (window.innerHeight - heightVar)) + }) + return probe === null ? Number.NaN : probe + }, { timeout: 30_000 }) + .toBeLessThanOrEqual(8) + const stripBox = await page.evaluate(() => { + const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]') + if (bottom === null) return null + const strip = [...bottom.querySelectorAll('*')].find(el => getComputedStyle(el).cursor === 'row-resize') + if (strip === undefined) return null + const r = strip.getBoundingClientRect() + return { x: r.x + r.width / 2, y: r.y + r.height / 2 } + }) + expect(stripBox, 'the bottom drag strip must be present (cursor: row-resize)').not.toBeNull() + + // Drag the strip UP (grow the bottom panel) in steps, like a real resize. + await page.mouse.move(stripBox!.x, stripBox!.y) + await page.mouse.down() + for (let i = 1; i <= 12; i++) { + await page.mouse.move(stripBox!.x, stripBox!.y - i * 8, { steps: 2 }) + await page.waitForTimeout(40) + } + await page.mouse.up() + await page.waitForTimeout(500) + + const samples = await page.evaluate( + () => (window as unknown as { __bottomDragSamples: Array<{ t: number; varW: number; gridCols: string; leftColW: number; rootRight: number; dragging: boolean }> }).__bottomDragSamples, + ) + expect(samples.length, 'the frame sampler must have collected frames').toBeGreaterThan(20) + const dragging = samples.filter(s => s.dragging) + expect(dragging.length, 'the dragging attribute must appear during the drag').toBeGreaterThan(5) + + // The drag must actually have resized the bottom panel (sanity: the height + // push committed to a bigger value than where it started). + const heightAfter = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0) + expect(heightAfter).toBeGreaterThan(0) + + // Contract: the pushed width stays 0 for EVERY frame — mid-drag AND after + // release. The old code let the panel's persisted preference (~400px) + // through on the first drag frame and the release commit. + for (const s of samples) { + expect(s.varW, `pushed width leaked during the bottom drag at t=${Math.round(s.t)} (got ${s.varW}px)`).toBe(0) + } + // Contract: the host layout never moves — same inline grid template, same + // rendered left-column width (the native sidebar would snap to its 56px + // rail when #root loses 400px on a 1280px viewport), same #root right edge. + const first = samples[0]! + for (const s of samples) { + expect(s.gridCols, `host grid template changed at t=${Math.round(s.t)}: ${first.gridCols} -> ${s.gridCols}`).toBe(first.gridCols) + expect(Math.abs(s.leftColW - first.leftColW), `native left column width changed at t=${Math.round(s.t)}: ${first.leftColW} -> ${s.leftColW}`).toBeLessThanOrEqual(1) + expect(Math.abs(s.rootRight - first.rootRight), `#root right edge changed at t=${Math.round(s.t)}: ${first.rootRight} -> ${s.rootRight}`).toBeLessThanOrEqual(1) + } +}) From e2699a099ef4f9d90d2dde2fd65b2746f8046515 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 1 Sep 2026 00:30:07 +0800 Subject: [PATCH 2/8] test: add the perf measurement lane (mount latency, longtasks, bundle cost, drag frames) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A measurement harness, not a pass/fail gate (beyond the mount sanity it shares with the other lanes): an init-script probe samples the navigationStart → host-attach latency, a longtask collector runs through the full built-in tab sweep, sidebar-shaped resource entries report transfer/decoded/duration, and a frame sampler records the bottom-strip drag's pacing (p50/p95/max) plus the width-leak guard. Results print as single-line PERF_JSON records so a shell run can tee and grep them; before/after comparisons land in docs/plans/2026-08-31-perf-optimization.md. Baseline (main @ d70db8f, 3 runs): mount ≈ 377ms, 2 longtasks ≈ 161ms, drag p95 = 10ms, width leak = 504px. --- tests/e2e/perf.e2e.ts | 275 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 tests/e2e/perf.e2e.ts diff --git a/tests/e2e/perf.e2e.ts b/tests/e2e/perf.e2e.ts new file mode 100644 index 0000000..d8bf184 --- /dev/null +++ b/tests/e2e/perf.e2e.ts @@ -0,0 +1,275 @@ +/** + * Perf lane: a MEASUREMENT harness, not a pass/fail gate (beyond the mount + * sanity it shares with the other lanes). + * + * It exists to put NUMBERS on the sidebar's client-side cost in a real + * mounted `dsh web` (the same scratch-profile boot as scripts/e2e-mount.sh): + * + * 1. mount latency — navigationStart → `[data-dsh-better-sidebar]` + * attached, sampled by an init-script probe (rAF loop, so it catches + * the attach even before Playwright's first poll); + * 2. longtasks — every >50ms main-thread task from first paint + * through the full built-in tab sweep (the heaviest mount surface); + * 3. bundle cost — per-resource transferSize/decodedBodySize/duration + * for everything sidebar-shaped the page fetched; + * 4. bottom-drag frames — rAF frame-interval p95/max while dragging the + * bottom strip (the layout-push hot path), plus the width-leak guard + * from the drag lane (a closed right panel must push 0). + * + * Results print as single-line `PERF_JSON` records (plus a `PERF_SUMMARY` + * aggregate) so a shell run can `tee` the log and grep them out; the + * before/after comparison lives in docs/plans/2026-08-31-perf-optimization.md. + * Run it 3× and take the median of each metric — single-run numbers on a + * developer machine are noise, not baselines. + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test, expect, type APIRequestContext, type Page } from '@playwright/test' +import { PAGE_URL, createHostApi, hostRpc } from './host' + +/** This lane's own workspace (lanes run serially against one server — never + * share seed paths with mount/drag). */ +const WORKSPACE_PATH = process.env.DSH_E2E_PERF_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-perf-workspace') + +/** Built-in tab titles the sweep drives (en-US copy; follows DSH locale). */ +const BUILTIN_TABS = ['Files', 'Changes', 'Tasks', 'Side Chat (beta)', 'Terminal', 'Browser'] + +let api: APIRequestContext + +async function seedSession(): Promise { + mkdirSync(WORKSPACE_PATH, { recursive: true }) + writeFileSync(join(WORKSPACE_PATH, 'seed.txt'), 'perf lane\n') + const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH }) + await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId }) +} + +test.beforeAll(async () => { + api = await createHostApi() + await seedSession() +}) + +test.afterAll(async () => { + await api?.dispose() +}) + +/** The longtask collector + mount-attach probe, injected before ANY page + * script runs (longtasks observed late are gone forever). */ +const PERF_PROBE = `(() => { + const perf = { mountAt: null, longtasks: [] } + window.__perfProbe = perf + new PerformanceObserver(list => { + for (const entry of list.getEntries()) perf.longtasks.push({ start: entry.startTime, duration: entry.duration }) + }).observe({ entryTypes: ['longtask'] }) + const check = () => { + if (document.querySelector('[data-dsh-better-sidebar]') !== null) { perf.mountAt = performance.now(); return } + requestAnimationFrame(check) + } + requestAnimationFrame(check) +})()` + +function percentile(values: number[], p: number): number { + if (values.length === 0) return 0 + const sorted = [...values].sort((a, b) => a - b) + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)) + return sorted[index]! +} + +interface ResourceStat { + name: string + transferSize: number + decodedBodySize: number + duration: number +} + +/** Dismiss whatever onboarding takeover is present (same dance as the other + * lanes) so the pointer can reach the sidebar's controls. */ +async function dismissOnboarding(page: Page): Promise { + try { + await expect + .poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 }) + .toBeGreaterThan(0) + } catch { + console.warn('[e2e-perf] no onboarding takeover appeared; proceeding') + } + for (let round = 0; round < 8; round++) { + let dismissed = false + for (const name of ['Continue', 'Configure later']) { + const button = page.getByRole('button', { name, exact: true }).first() + if ((await button.count()) === 0) continue + try { + await button.click({ timeout: 4_000 }) + dismissed = true + await page.waitForTimeout(1_000) + } catch { + // Masked by a takeover stacked above; retry in the next round. + } + } + if (!dismissed) break + } +} + +test('measure: mount latency, longtasks and bundle cost through a full tab sweep', async ({ page }) => { + await page.addInitScript(PERF_PROBE) + await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) + await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) + const sidebar = page.locator('[data-dsh-better-sidebar]') + await expect(sidebar).toBeAttached({ timeout: 90_000 }) + + const mountLatency = await page.evaluate(() => + (window as unknown as { __perfProbe: { mountAt: number | null } }).__perfProbe.mountAt) + expect(mountLatency, 'the mount probe must have caught the attach').not.toBeNull() + + await dismissOnboarding(page) + + // Open the panel, then sweep every built-in tab through the "+" menu — the + // same crash-sweep surface as mount.e2e.ts, reused here as the workload. + const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' }) + await expect(expandButton).toHaveCount(1) + await expandButton.click() + await expect + .poll(async () => { + const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) + return value !== '' && value !== '0px' + }, { timeout: 90_000 }) + .toBe(true) + + const newTabButton = sidebar.getByRole('button', { name: 'New tab' }).first() + for (const title of BUILTIN_TABS) { + await newTabButton.click() + const item = page.getByRole('menuitem', { name: title }).first() + await expect(item, `built-in tab "${title}" must be offered by the + menu`).toHaveCount(1) + await item.click() + await page.waitForTimeout(1_500) + } + + // Let the last tab settle (lazy chunks + first render) before reading. + await page.waitForTimeout(2_000) + const probe = await page.evaluate(() => (window as unknown as { __perfProbe: { mountAt: number | null; longtasks: Array<{ start: number; duration: number }> } }).__perfProbe) + const resources = await page.evaluate(() => + performance.getEntriesByType('resource') + .filter(entry => /sidebar/.test(entry.name)) + .map(entry => ({ + name: entry.name.replace(/^https?:\/\/[^/]+/, ''), + transferSize: entry.transferSize, + decodedBodySize: entry.decodedBodySize, + duration: entry.duration, + })) as ResourceStat[], + ) + + const longtasks = probe.longtasks ?? [] + const record = { + metric: 'mount-sweep', + mountLatencyMs: mountLatency, + longtaskCount: longtasks.length, + longtaskTotalMs: Math.round(longtasks.reduce((sum, task) => sum + task.duration, 0)), + longtaskMaxMs: longtasks.reduce((max, task) => Math.max(max, task.duration), 0), + resourceCount: resources.length, + resourceTransferTotal: resources.reduce((sum, r) => sum + r.transferSize, 0), + resourceDecodedTotal: resources.reduce((sum, r) => sum + r.decodedBodySize, 0), + resources, + } + console.log(`PERF_JSON ${JSON.stringify(record)}`) + console.log( + `PERF_SUMMARY mount=${Math.round(mountLatency!)}ms longtasks=${record.longtaskCount}/${record.longtaskTotalMs}ms ` + + `resources=${record.resourceCount}/${(record.resourceTransferTotal / 1024).toFixed(0)}KiB`, + ) +}) + +test('measure: bottom-strip drag frame pacing', async ({ page }) => { + await page.addInitScript(PERF_PROBE) + await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) + await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) + const sidebar = page.locator('[data-dsh-better-sidebar]') + await expect(sidebar).toBeAttached({ timeout: 90_000 }) + await dismissOnboarding(page) + + // The right panel stays CLOSED on purpose: the bottom drag must not push + // the host layout in that pose (the width-leak regression the drag lane + // locks; recorded here as a number-adjacent guard for the perf story). + const bottomExpand = sidebar.getByRole('button', { name: 'Expand bottom panel' }) + await expect(bottomExpand).toHaveCount(1) + await bottomExpand.click() + await expect + .poll(async () => { + const value = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0) + return value + }, { timeout: 90_000 }) + .toBeGreaterThan(0) + + // Frame-pacing sampler: rAF timestamps during the drag only. + await page.evaluate(() => { + const frames: number[] = [] + const pushWidths: number[] = [] + const loop = (): void => { + frames.push(performance.now()) + const width = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) + pushWidths.push(Number.isNaN(width) ? 0 : width) + requestAnimationFrame(loop) + } + requestAnimationFrame(loop) + ;(window as unknown as { __dragFrames: number[]; __dragPushWidths: number[] }).__dragFrames = frames + ;(window as unknown as { __dragPushWidths: number[] }).__dragPushWidths = pushWidths + }) + + // The panel slides down into place on expand: read the strip only after it + // settles at innerHeight - the pushed height (a mid-animation box makes the + // pointerdown land off-strip and the drag never starts). + await expect + .poll(async () => { + const probe = await page.evaluate(() => { + const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]') + if (bottom === null) return null + const strip = [...bottom.querySelectorAll('*')].find(el => getComputedStyle(el).cursor === 'row-resize') + if (strip === undefined) return null + const r = strip.getBoundingClientRect() + const heightVar = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0 + return Math.abs((r.y + r.height / 2) - (window.innerHeight - heightVar)) + }) + return probe === null ? Number.NaN : probe + }, { timeout: 30_000 }) + .toBeLessThanOrEqual(8) + const stripPoint = await page.evaluate(() => { + const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]') + if (bottom === null) return null + const strip = [...bottom.querySelectorAll('*')].find(el => getComputedStyle(el).cursor === 'row-resize') + if (strip === undefined) return null + const r = strip.getBoundingClientRect() + return { x: r.x + r.width / 2, y: r.y + r.height / 2 } + }) + expect(stripPoint, 'the bottom drag strip must be present').not.toBeNull() + + const t0 = await page.evaluate(() => performance.now()) + await page.mouse.move(stripPoint!.x, stripPoint!.y) + await page.mouse.down() + for (let i = 1; i <= 12; i++) { + await page.mouse.move(stripPoint!.x, stripPoint!.y - i * 8, { steps: 2 }) + await page.waitForTimeout(40) + } + await page.mouse.up() + const t1 = await page.evaluate(() => performance.now()) + await page.waitForTimeout(300) + + const { frames, widths } = await page.evaluate(() => ({ + frames: (window as unknown as { __dragFrames: number[] }).__dragFrames, + widths: (window as unknown as { __dragPushWidths: number[] }).__dragPushWidths, + })) + const dragFrames = frames.filter(t => t >= t0 && t <= t1) + const intervals: number[] = [] + for (let i = 1; i < dragFrames.length; i++) intervals.push(dragFrames[i]! - dragFrames[i - 1]!) + const dragWidths = widths.slice(Math.max(0, frames.indexOf(dragFrames[0] ?? frames[0]!))) + const record = { + metric: 'bottom-drag', + frames: dragFrames.length, + intervalMedianMs: Math.round(percentile(intervals, 50)), + intervalP95Ms: Math.round(percentile(intervals, 95)), + intervalMaxMs: Math.round(Math.max(0, ...intervals)), + pushWidthLeakMax: Math.max(0, ...dragWidths), + } + console.log(`PERF_JSON ${JSON.stringify(record)}`) + console.log( + `PERF_SUMMARY drag-frames=${record.frames} p50=${record.intervalMedianMs}ms p95=${record.intervalP95Ms}ms ` + + `max=${record.intervalMaxMs}ms widthLeak=${record.pushWidthLeakMax}px`, + ) + expect(record.pushWidthLeakMax, 'the closed right panel must push 0 width during the bottom drag').toBe(0) +}) From 427552e4f910597e8a2b2ba414887d54c25e705b Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 1 Sep 2026 00:30:13 +0800 Subject: [PATCH 3/8] perf: cut the boot and poll overhead (one settings fetch, one git process per tick, one fold per poll) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boot: loadPrefs and loadExternalDisable each fetched the settings document, serially, before the first paint — and the second had no timeout, so a stalled wire kept the sidebar unmounted forever. Both decisions now ride ONE fetch (loadBootDecision) under the existing 2s race. GitLens: the 2s silent poll re-listed worktrees on every tick (a second git process per tick for a list that almost never changes, and a fresh worktrees array re-render each time). Silent ticks now fetch only the selected checkout's status; every 15th tick (~30s) re-lists and re-runs auto-selection, so an agent-created linked checkout is still picked up mid-session. ChangesTab: extractFileOps parses every accumulated tool/call (up to 4000 events); the poll ran it once for the badge and the render ran it AGAIN per tick, and the fresh array defeated the downstream memo. The fold now runs once per poll and its result rides a ref shared by badge and render (reset on session switch). --- src/client/changes/ChangesTab.tsx | 13 ++++++++++-- src/client/changes/GitLens.tsx | 19 +++++++++++++++++ src/client/index.tsx | 34 ++++++++++++++++--------------- src/client/prefs.ts | 19 +++++++++++++++++ tests/prefs.spec.ts | 27 +++++++++++++++++++++++- 5 files changed, 93 insertions(+), 19 deletions(-) diff --git a/src/client/changes/ChangesTab.tsx b/src/client/changes/ChangesTab.tsx index ae7aa22..7726065 100644 --- a/src/client/changes/ChangesTab.tsx +++ b/src/client/changes/ChangesTab.tsx @@ -63,6 +63,12 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen // up). The cursor is the last delivered seq, so each poll ships only // what the accumulator lacks. ──────────────────────────────────────── const eventsRef = useRef([]) + // The fold of eventsRef as of the last poll. extractFileOps parses every + // accumulated tool/call (up to EVENTS_CAP events); running it once per + // poll and REUSING the result across renders (the render used to re-fold + // the whole window, twice per tick, and the fresh array defeated the + // downstream memo on every poll) keeps the 2.5s tick at one fold. + const opsRef = useRef([]) const seqRef = useRef(0) const pollGen = useRef(0) const [opsError, setOpsError] = useState(false) @@ -77,7 +83,9 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen eventsRef.current = merged.length > EVENTS_CAP ? merged.slice(merged.length - EVENTS_CAP) : merged } if (lastSeq > seqRef.current) seqRef.current = lastSeq - opCounts.set(scope.sessionId, extractFileOps(eventsRef.current).length) + const folded = extractFileOps(eventsRef.current) + opsRef.current = folded + opCounts.set(scope.sessionId, folded.length) setOpsError(false) setTick(value => value + 1) } catch { @@ -89,6 +97,7 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen useEffect(() => { pollGen.current += 1 eventsRef.current = [] + opsRef.current = [] seqRef.current = 0 setOpsError(false) }, [scope.sessionId]) @@ -100,7 +109,7 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen }, [visible, pull]) // tick only forces the re-render; the fold reads the ref directly. void tick - const ops = extractFileOps(eventsRef.current) + const ops = opsRef.current /** Persist a meta patch onto the tab (lens choice, pane height). */ const patchMeta = (patch: ChangesMeta): void => { diff --git a/src/client/changes/GitLens.tsx b/src/client/changes/GitLens.tsx index afec62b..71b7324 100644 --- a/src/client/changes/GitLens.tsx +++ b/src/client/changes/GitLens.tsx @@ -76,6 +76,13 @@ interface ConfirmState { * floods the panel at once (the end of the log is reached by paging). */ const LOG_BATCH = 20 +/** Every Nth silent poll re-lists worktrees (and re-runs auto-selection): the + * 2s tick only needs the selected checkout's STATUS, and re-listing spawned + * a second git process per tick for a list that almost never changes — a + * linked checkout the agent creates mid-session is picked up within ~30s + * instead of 2s. */ +const WORKTREE_RECHECK_TICKS = 15 + export interface GitLensProps { scope: SessionScope /** The sidebar store: reads the `workspaceFence` pref (see the open guard below). */ @@ -123,6 +130,8 @@ export function GitLens(props: GitLensProps) { * callback and re-trigger the mount effect — an N→N+1 fetch loop). */ const chosenPathRef = useRef(undefined) useEffect(() => { chosenPathRef.current = selectedWorktree }, [selectedWorktree]) + /** Silent polls since the last worktree re-list (see WORKTREE_RECHECK_TICKS). */ + const silentTickCount = useRef(0) const gitScope: SessionScope = repoRoot === undefined ? scope : { ...scope, repoRoot } @@ -160,6 +169,15 @@ export function GitLens(props: GitLensProps) { refreshInFlight.current = true let generation = refreshGeneration.current try { + // Fast path for the ordinary silent tick: the selected checkout's + // STATUS is all that changes between worktree re-lists (see + // WORKTREE_RECHECK_TICKS) — one git process instead of two. + if (silent && chosenPathRef.current !== undefined && (silentTickCount.current += 1) % WORKTREE_RECHECK_TICKS !== 0) { + const statusResult = await api.gitStatus(gitScope, chosenPathRef.current) + if (generation === refreshGeneration.current) setStatus(statusResult) + return + } + silentTickCount.current = 0 const listed = await api.gitWorktrees(scope) if (generation !== refreshGeneration.current) return setWorktrees(listed) @@ -214,6 +232,7 @@ export function GitLens(props: GitLensProps) { refreshInFlight.current = false worktreeChosenByUser.current = false chosenPathRef.current = undefined + silentTickCount.current = 0 setSelectedWorktree(undefined) }, [scope.sessionId, scope.cwd]) useEffect(() => { void refresh() }, [refresh]) diff --git a/src/client/index.tsx b/src/client/index.tsx index 30033f9..7ba5b31 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -21,7 +21,7 @@ import { registerOpenPathInterception, registerTurnTailInterception } from './in import { registerLinkInterception } from './link-intercept.ts' import { registerImeGuard } from './ime-guard.ts' import { registerSettingsNavIcon } from './settings-nav-icon.ts' -import { loadExternalDisable, loadPrefs } from './prefs.ts' +import { loadBootDecision } from './prefs.ts' import { SideCardSection } from './SideCardSection.tsx' import { api } from './api.ts' import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en, @@ -275,24 +275,26 @@ export function apply(ctx: Context): void { } const sync = async (): Promise => { if (disposed) return - // Resolve the user's side card prefs BEFORE the first session seeds, - // so a brand-new conversation opens (or stays closed) at the chosen - // width from first paint. A settings route failure falls back to the - // schema defaults; the sidebar still mounts (a stalled wire gives up - // after the timeout and mounts on the defaults). - const prefs = await Promise.race([ - loadPrefs(api), + // Resolve the user's side card prefs and the external-disable flag + // from ONE settings fetch BEFORE the first session seeds, so a + // brand-new conversation opens (or stays closed) at the chosen width + // from first paint. A settings route failure falls back to the schema + // defaults; the sidebar still mounts (a stalled wire gives up after + // the timeout and mounts on the defaults — the external-disable check + // rides the same fetch, so one round trip covers both decisions). + const decision = await Promise.race([ + loadBootDecision(api), new Promise(resolve => { const timer = window.setTimeout(() => resolve(null), 2000) }), ]) - if (prefs !== null) sidebarStore.setPrefs(prefs) if (disposed) return - // Mutual exclusion with the dsh-web-ui family right panel: while the - // aionui-panel provider is selected, the sidebar must not mount at - // all. Re-evaluated on every settings-document update (live switch). - const suspended = await loadExternalDisable(api) - if (disposed) return - sidebarStore.setSuspended(suspended) - if (suspended) unmount() + if (decision !== null) { + sidebarStore.setPrefs(decision.prefs) + // Mutual exclusion with the dsh-web-ui family right panel: while the + // aionui-panel provider is selected, the sidebar must not mount at + // all. Re-evaluated on every settings-document update (live switch). + sidebarStore.setSuspended(decision.suspended) + } + if (decision?.suspended) unmount() else mount() } void sync() diff --git a/src/client/prefs.ts b/src/client/prefs.ts index be4d482..082523a 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -217,3 +217,22 @@ export async function loadExternalDisable(settings: SidebarSettingsClient): Prom return false } } + +/** The boot decision both the prefs and the external-disable flag need: + * ONE settings fetch answers both (the boot path used to await + * {@link loadPrefs} and {@link loadExternalDisable} serially — two round + * trips of the same document before the first paint, and the second had no + * timeout, so a stalled wire could keep the sidebar unmounted forever). */ +export interface BootDecision { + prefs: SidebarPrefs + suspended: boolean +} + +export async function loadBootDecision(settings: SidebarSettingsClient): Promise { + try { + const view = await settings.settingsGet() + return { prefs: parsePrefs(view.value), suspended: view.externalDisable === true } + } catch { + return { prefs: { ...SIDEBAR_PREFS_DEFAULTS }, suspended: false } + } +} diff --git a/tests/prefs.spec.ts b/tests/prefs.spec.ts index 449f55b..fe48adb 100644 --- a/tests/prefs.spec.ts +++ b/tests/prefs.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { loadExternalDisable, loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts' +import { loadBootDecision, loadExternalDisable, loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts' import { allLeaves, createSidebarStore, defaultWidthFor, makeDefaultState, setWidth } from '../src/client/state.ts' import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts' @@ -416,3 +416,28 @@ describe('external disable (aionui-panel provider choice)', () => { expect(await loadExternalDisable(rejecting())).toBe(false) }) }) + +describe('boot decision (one fetch for prefs + external disable)', () => { + it('answers both decisions from a single settingsGet call', async () => { + let calls = 0 + const counting = (): SidebarSettingsClient => ({ + settingsGet: async () => { calls += 1; return { value: { openByDefault: true, defaultWidthPercent: 40 }, revision: 1, externalDisable: true } }, + settingsUpdate: async () => ({ value: {}, revision: 2 }), + }) + const decision = await loadBootDecision(counting()) + expect(calls, 'the boot path must fetch the settings document exactly once').toBe(1) + expect(decision.suspended).toBe(true) + expect(decision.prefs.openByDefault).toBe(true) + }) + + it('falls back to the defaults + not suspended on any failure', async () => { + const decision = await loadBootDecision(rejecting()) + expect(decision).toEqual({ prefs: SIDEBAR_PREFS_DEFAULTS, suspended: false }) + }) + + it('reads suspended false when the flag is absent', async () => { + const decision = await loadBootDecision(wire({ defaultWidthPercent: 50 })) + expect(decision.suspended).toBe(false) + expect(decision.prefs.defaultWidthPercent).toBe(50) + }) +}) From b7af498427307407b84b3b8b46fc13345075d99f Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 1 Sep 2026 00:30:58 +0800 Subject: [PATCH 4/8] perf: stabilize renders (transcript row reuse, tree Sets, shared + options, batched drags) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SideChat transcript: every 2s poll rebuilt ALL row objects, so every row (markdown parse + DOMPurify included) re-rendered even when the log had not changed. transcriptRows now takes the previous poll's rows and re-adopts the position-aligned rows whose content is identical (reuseRows) — only the changed tail rebuilds. The view feeds the memoized array back through a ref; unit tests pin the identity contract (unchanged rows keep object identity, changed rows and everything past the change rebuild, tool-result landings never re-adopt). FileTree: expanded/revealed membership was an includes() scan per rendered row (O(rows × expanded) per render on deep trees); one Set per render keeps it O(1) per row. Shell: buildNewTabOptions was inlined in the two Workbench JSX sites — two fresh arrays per render (drag state, resizes) re-rendered every LeafView's + affordance. One shared memo now. DiffPane resize / TerminalView ResizeObserver: pointer and resize streams fired a setState / glyph-measuring fit() per event; both ride per-frame batching now (frame-batcher in the core, a local rAF gate in the lazy terminal chunk). SessionLens sizes each row's content with a Blob (a UTF-8 encode of the whole edit) — that moved from per-row-per-render into one memoized pass per changed fold. --- src/client/FileTree.tsx | 18 +++++--- src/client/SideChatView.tsx | 13 +++++- src/client/Sidebar.tsx | 18 +++++++- src/client/TerminalView.tsx | 23 +++++++--- src/client/changes/DiffPane.tsx | 9 +++- src/client/changes/SessionLens.tsx | 18 +++++++- src/client/sidechat-transcript.ts | 56 ++++++++++++++++++++++- tests/sidechat-transcript.spec.ts | 72 ++++++++++++++++++++++++++++++ 8 files changed, 207 insertions(+), 20 deletions(-) diff --git a/src/client/FileTree.tsx b/src/client/FileTree.tsx index 942a06f..684ceb4 100644 --- a/src/client/FileTree.tsx +++ b/src/client/FileTree.tsx @@ -20,7 +20,7 @@ * (VSCode semantics — a drop on a file row targets its parent directory), * and `busy` gates new drags while one upload is in flight. */ -import { useCallback, useEffect, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { @@ -441,6 +441,12 @@ export function FileTree(props: { const root = cwd + // Membership is tested per rendered row (deep trees run this thousands of + // times per render): includes() made every row O(expanded), the whole tree + // O(rows × expanded). One Set per render keeps it O(1) per row. + const expandedSet = useMemo(() => new Set(expanded), [expanded]) + const revealedSet = useMemo(() => new Set(revealed), [revealed]) + const renderLevel = (dir: string, depth: number): ReactNode => { const level = data[dir] if (level === undefined) { @@ -466,7 +472,7 @@ export function FileTree(props: { const entries = level.entries ?? [] return entries.map(entry => { if (entry.isDir) { - const isOpen = expanded.includes(entry.path) + const isOpen = expandedSet.has(entry.path) return (
{ onToggle(entry.path) }} onKeyDown={(event) => { @@ -507,9 +513,9 @@ export function FileTree(props: { className={clsx( css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken, dropTarget === parentOf(entry.path) && css.explorerRowDropTarget, - revealed.includes(entry.path) && css.explorerRowRevealed, + revealedSet.has(entry.path) && css.explorerRowRevealed, )} - data-dsh-revealed={revealed.includes(entry.path) ? 'true' : undefined} + data-dsh-revealed={revealedSet.has(entry.path) ? 'true' : undefined} style={{ paddingLeft: depth * 22 + 6 }} title={entry.broken ? `${entry.path} — ${t('brokenSymlink')}` : entry.path} onClick={() => { onOpenFile(entry.path) }} diff --git a/src/client/SideChatView.tsx b/src/client/SideChatView.tsx index e409c1a..f6ed825 100644 --- a/src/client/SideChatView.tsx +++ b/src/client/SideChatView.tsx @@ -383,6 +383,8 @@ export function SideChatView(props: { const [menuOpen, setMenuOpen] = useState(false) const cacheRef = useRef({ entries: [] }) + // The previous poll's rows (see the mapping's reuse pass below). + const prevRowsRef = useRef([]) const controllerRef = useRef(null) const scrollRef = useRef(null) const composerRef = useRef(null) @@ -480,6 +482,7 @@ export function SideChatView(props: { // the composer — it owns the first message of a fresh thread. useEffect(() => { cacheRef.current = { entries: [] } + prevRowsRef.current = [] controllerRef.current?.abort() setError(null) setSaved(false) @@ -517,8 +520,14 @@ export function SideChatView(props: { } }, [connectionState, threadId, fetchThread, fetchInfo]) - const rows = useMemo( - () => (threadId === undefined ? [] : transcriptRows(cacheRef.current.entries)), + // The previous poll's rows ride into the mapping so unchanged rows keep + // their object identity (see reuseRows): the 2s poll re-renders only the + // changed tail instead of re-parsing markdown for the whole transcript. + const rows = useMemo(() => { + const next = threadId === undefined ? [] : transcriptRows(cacheRef.current.entries, prevRowsRef.current) + prevRowsRef.current = next + return next + }, // The cache is a ref; revision bumps on every successful pull. // eslint-disable-next-line react-hooks/exhaustive-deps [threadId, revision], diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 891fa32..4f71da1 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -407,6 +407,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { }, [sessionId, summaryCwd]) const cwd = summaryCwd ?? fetchedCwd + // The + menu options ride a memo so the two Workbenches share ONE array + // identity across renders that did not change the store (drag state, + // viewport resize): fresh arrays per render re-rendered every LeafView's + // + affordance whether or not anything tab-related moved. + const newTabOptions = useMemo( + () => (state === undefined || sessionId === undefined ? [] : buildNewTabOptions(state, ctx, { sessionId, cwd })), + // state is the whole session state — every field it wraps is fair game + // for the descriptors' available() callbacks. (The render's own guard + // sits below every hook; this memo must handle the no-session case + // itself.) + // eslint-disable-next-line react-hooks/exhaustive-deps + [state, ctx, sessionId, cwd], + ) + /** * Agent terminals push: subscribe to the host's live list of agent-owned * terminals for this session (created by the model through the @@ -1581,7 +1595,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { renderTab(tab, active, paneId, 'bottom')} diff --git a/src/client/TerminalView.tsx b/src/client/TerminalView.tsx index eba3d93..ffaee8a 100644 --- a/src/client/TerminalView.tsx +++ b/src/client/TerminalView.tsx @@ -268,13 +268,23 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: const inputSub = term.onData((data) => { if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(data) }) + // Resize streams fire per layout frame during panel open/close + // animations; fit() measures glyphs, so coalesce to one fit per + // animation frame (same pattern as the core's frame-batcher, kept + // local — this view lives in the lazy terminal chunk and does not + // import core-bundle modules). + let resizeFrame: number | null = null const observer = new ResizeObserver(() => { - try { - fit.fit() - sendResize() - } catch { - // The terminal may be mid-dispose; ignore. - } + if (resizeFrame !== null) return + resizeFrame = requestAnimationFrame(() => { + resizeFrame = null + try { + fit.fit() + sendResize() + } catch { + // The terminal may be mid-dispose; ignore. + } + }) }) observer.observe(host) @@ -323,6 +333,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: cancelOpen() window.clearTimeout(retry) observer.disconnect() + if (resizeFrame !== null) cancelAnimationFrame(resizeFrame) fontSub() schemeSub() inputSub.dispose() diff --git a/src/client/changes/DiffPane.tsx b/src/client/changes/DiffPane.tsx index c8ff841..436cb53 100644 --- a/src/client/changes/DiffPane.tsx +++ b/src/client/changes/DiffPane.tsx @@ -21,6 +21,7 @@ import { DiffFiles } from '../diff/DiffFiles.tsx' import { langOfPath } from '../diff/highlight.ts' import { buildDiffSegments, diffLines, diffStats, parseUnifiedDiff, unifiedSegments, type DiffRow } from '../diff/rows.ts' import { parseReadLines, type FileOp } from './ops.ts' +import { createFrameBatcher } from '../frame-batcher.ts' import css from './changes.module.css' import diffCss from '../diff/diff.module.css' @@ -176,17 +177,23 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp const paneHeight = dragHeight ?? height const clamp = (value: number): number => Math.min(Math.max(value, HEIGHT_MIN), Math.round(window.innerHeight * 0.7)) const dragOrigin = useRef<{ y: number; h: number } | null>(null) + // Pointer streams fire several times per frame; one setState per event + // re-rendered the whole pane at event cadence (see frame-batcher). + const dragBatcher = useRef(createFrameBatcher()).current + useEffect(() => () => dragBatcher.dispose(), [dragBatcher]) const onHandleDown = (event: ReactPointerEvent): void => { event.preventDefault() dragOrigin.current = { y: event.clientY, h: paneHeight } const onMove = (ev: PointerEvent): void => { if (dragOrigin.current === null) return - setDragHeight(clamp(dragOrigin.current.h + (dragOrigin.current.y - ev.clientY))) + const next = clamp(dragOrigin.current.h + (dragOrigin.current.y - ev.clientY)) + dragBatcher.schedule(() => { setDragHeight(next) }) } const onUp = (): void => { window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onUp) dragOrigin.current = null + dragBatcher.flushNow() setDragHeight(current => { if (current !== null) onHeightCommit(current) return null diff --git a/src/client/changes/SessionLens.tsx b/src/client/changes/SessionLens.tsx index 8b8e6a1..8e33319 100644 --- a/src/client/changes/SessionLens.tsx +++ b/src/client/changes/SessionLens.tsx @@ -54,6 +54,20 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi ) + // Sizing a row's content constructs a Blob (a UTF-8 encode of the whole + // edit) — that was per row PER RENDER, re-encoding every field on every + // poll tick. ops keeps its identity between unchanged polls (see the + // tab's opsRef), so one pass per changed fold covers every render. + const opSizes = useMemo(() => { + const sizes = new Map() + for (const op of ops) { + if (op.kind !== 'read' && !op.isError) { + sizes.set(op.callId, formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)) + } + } + return sizes + }, [ops]) + return (
@@ -85,9 +99,9 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi {op.running && {t('changesRunning')}} {op.isError && {t('changesError')}} - {op.kind !== 'read' && !op.isError && ( + {opSizes.get(op.callId) !== undefined && ( - {formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)} + {opSizes.get(op.callId)} )} {relativeTime(new Date(op.time).toISOString())} diff --git a/src/client/sidechat-transcript.ts b/src/client/sidechat-transcript.ts index 61dde7a..0c9e400 100644 --- a/src/client/sidechat-transcript.ts +++ b/src/client/sidechat-transcript.ts @@ -307,7 +307,7 @@ function resultCard( * @param entries - history rows (event + host-computed view) in seq order. * @returns display rows in log order. */ -export function transcriptRows(entries: readonly SidebarHistoryEntry[]): SidechatTranscriptRow[] { +export function transcriptRows(entries: readonly SidebarHistoryEntry[], prev?: readonly SidechatTranscriptRow[]): SidechatTranscriptRow[] { const events = entries.map(entry => entry.event) const seedEnd = lastSeedEnd(events) const rows: SidechatTranscriptRow[] = [] @@ -484,5 +484,59 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[]): Sidecha } } } + return reuseRows(rows, prev) +} + +/** Whether two rows carry identical display content (identity fields plus + * every rendered field of their kind). */ +function rowsEqual(a: SidechatTranscriptRow, b: SidechatTranscriptRow): boolean { + if (a.kind !== b.kind || a.seq !== b.seq) return false + switch (a.kind) { + case 'user': + case 'injection': { + const other = b as typeof a + return a.text === other.text + } + case 'assistant': + case 'reasoning': { + const other = b as typeof a + return a.text === other.text && a.settled === other.settled + } + case 'tool': { + // The card is NOT compared by value: its content is a pure function of + // the call-time args and the tool/result landing, and every transition + // (output/exit arriving) flips `executing`/`failed`/`resultText` — + // compared here — so a card whose content changed never slips through + // an equal compare, and an equal compare re-adopting the OLD card + // object (identical content, stale reference) only helps React skip. + const other = b as typeof a + return a.name === other.name && a.failed === other.failed && a.args === other.args + && a.resultText === other.resultText && a.executing === other.executing + } + case 'turnSummary': { + const other = b as typeof a + return a.inputTokens === other.inputTokens && a.outputTokens === other.outputTokens + && a.durationMs === other.durationMs + } + } +} + +/** Re-adopt the PREVIOUS poll's row objects wherever the content is + * unchanged (position-aligned, append-mostly): settled rows keep their + * object identity across the 2s polls, so React's reconciler and the + * markdown/DOMPurify caches downstream skip them instead of re-rendering + * the whole transcript every poll. Comparing the strings is far cheaper + * than what a fresh reference costs the row below. Rows past the first + * mismatch (an inserted/superseded streaming row) rebuild as usual — that + * is exactly the changed tail. */ +function reuseRows(rows: SidechatTranscriptRow[], prev: readonly SidechatTranscriptRow[] | undefined): SidechatTranscriptRow[] { + if (prev === undefined) return rows + const shared = Math.min(rows.length, prev.length) + for (let index = 0; index < shared; index++) { + const before = prev[index] + const after = rows[index] + if (before === undefined || after === undefined) continue + if (rowsEqual(before, after)) rows[index] = before + } return rows } diff --git a/tests/sidechat-transcript.spec.ts b/tests/sidechat-transcript.spec.ts index fbe5a6a..6c8e532 100644 --- a/tests/sidechat-transcript.spec.ts +++ b/tests/sidechat-transcript.spec.ts @@ -371,3 +371,75 @@ describe('toolArgsSummary', () => { expect(toolArgsSummary('{"command":" "}')).toBe('{"command":" "}') }) }) + +describe('transcriptRows row reuse (the poll-to-poll identity pass)', () => { + it('re-adopts the previous poll\'s row objects wherever content is unchanged', () => { + const entries = [ + entry(ev('session/end-seed', 0)), + entry(ev('user/message', 1, { content: textBlocks('the side question'), source: { kind: 'user' } })), + entry(ev('assistant/message', 2, { message: { content: textBlocks('the answer') } })), + ] + const first = transcriptRows(entries) + const second = transcriptRows(entries, first) + expect(second).toEqual(first) + // Settled rows keep their object identity (fresh references defeat the + // downstream memo and re-render the whole transcript every 2s poll). + expect(second[1]).toBe(first[1]) + expect(second[2]).toBe(first[2]) + }) + + it('rebuilds changed rows and everything past the change', () => { + const before = transcriptRows([ + entry(ev('session/end-seed', 0)), + entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })), + ]) + const after = transcriptRows([ + entry(ev('session/end-seed', 0)), + entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })), + entry(ev('user/message', 2, { content: textBlocks('q2'), source: { kind: 'user' } })), + ], before) + expect(after).toHaveLength(2) + expect(after[0]).toBe(before[0]) + expect(after[1]).not.toBe(before[1]) + expect(after[1]).toMatchObject({ kind: 'user', seq: 2, text: 'q2' }) + }) + + it('never re-adopts a row whose rendered fields changed (tool result lands)', () => { + const call = entry(ev('tool/call', 1, { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' })) + const before = transcriptRows([entry(ev('session/end-seed', 0)), call]) + expect(before[0]).toMatchObject({ kind: 'tool', executing: true }) + const result = entry(ev('tool/result', 2, { + message: { source: { callId: 'c1' }, content: [{ type: 'tool-result', content: [{ type: 'text', text: 'out' }] }] }, + })) + const after = transcriptRows([entry(ev('session/end-seed', 0)), call, result], before) + expect(after[0]).not.toBe(before[0]) + expect(after[0]).toMatchObject({ kind: 'tool', executing: false, resultText: 'out' }) + }) +}) + +describe('transcriptRows row reuse (newer row kinds)', () => { + it('re-adopts settled turnSummary rows (token usage tail)', () => { + const entries = [ + entry(ev('session/end-seed', 0)), + entry(ev('turn/start', 1, { turn: 1 })), + entry(ev('turn/end', 2, { turn: 1, reason: 'completed' })), + ] + const first = transcriptRows(entries) + const summary = first.find(row => row.kind === 'turnSummary') + expect(summary).toBeDefined() + const second = transcriptRows(entries, first) + expect(second[second.length - 1]).toBe(summary) + }) + + it('re-adopts a tool row whose card landed (executing flip forces the rebuild)', () => { + const call = entry(ev('tool/call', 1, { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' })) + const before = transcriptRows([entry(ev('session/end-seed', 0)), call]) + expect(before[0]).toMatchObject({ kind: 'tool', executing: true }) + const result = entry(ev('tool/result', 2, { + message: { source: { callId: 'c1' }, content: [{ type: 'tool-result', content: [{ type: 'text', text: 'out\n[exit 0]' }] }] }, + })) + const after = transcriptRows([entry(ev('session/end-seed', 0)), call, result], before) + expect(after[0]).not.toBe(before[0]) + expect(after[0]).toMatchObject({ kind: 'tool', executing: false }) + }) +}) From c7d58c9b2531a5445cf55d4bea2f48458f8abc3a Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 1 Sep 2026 00:34:57 +0800 Subject: [PATCH 5/8] perf: move the 19 non-zh/en dictionaries into a lazy locale chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core client bundle statically imported every third-language dictionary (ja/de/fr/…/zh-HK/MO/TW, ~640KB of source — roughly half the bundle's weight) for a code path most sessions never enter: the sidebar's own t() only consults zh/en plus the better-locale override store, and the dicts' ONLY consumer is that store's register() call. They now ride the existing lazy-chunk machinery as lib/client-locale.js (fetched from /sidebar/bundle only when better-locale is actually installed; a generation counter keeps the async registration moot when the integration effect re-runs or disposes mid-flight). Until the chunk lands, t() keeps the zh/en chain; the store's own revision bump on register re-renders the chrome once the dicts arrive. Core client.js: 1.46MB → 822KB (-44%). The chunk-boundary key-set check moves to the existing runtime test (every shipped dict stays key-set-equal to zh). --- src/bundle-route.ts | 2 +- src/client/chunk-loader.ts | 2 +- src/client/chunks/locale.tsx | 60 ++++++++++++++++++++++++++++++++++++ src/client/index.tsx | 27 +++++++++++----- src/client/locales.ts | 50 +++++------------------------- tests/bundle-route.spec.ts | 2 +- tests/locales.spec.ts | 24 ++++++--------- tsdown.config.ts | 2 +- 8 files changed, 100 insertions(+), 69 deletions(-) create mode 100644 src/client/chunks/locale.tsx diff --git a/src/bundle-route.ts b/src/bundle-route.ts index 3c404f3..4f098ec 100644 --- a/src/bundle-route.ts +++ b/src/bundle-route.ts @@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url' import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts' /** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */ -export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid'] as const +export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid', 'locale'] as const export type ChunkName = (typeof CHUNK_NAMES)[number] /** Directory of this host-half module (lib/ — the chunk scripts live next to it). */ diff --git a/src/client/chunk-loader.ts b/src/client/chunk-loader.ts index 76c90e1..169da7c 100644 --- a/src/client/chunk-loader.ts +++ b/src/client/chunk-loader.ts @@ -49,7 +49,7 @@ * client.js); an edit that does land while a core HMR happens is caught by * the ETag comparison on the next activation. */ -export type ChunkName = 'terminal' | 'editor' | 'mermaid' +export type ChunkName = 'terminal' | 'editor' | 'mermaid' | 'locale' /** The module exports a chunk factory provides (namespace-ish record). */ export type ChunkExports = Record diff --git a/src/client/chunks/locale.tsx b/src/client/chunks/locale.tsx new file mode 100644 index 0000000..b2a439e --- /dev/null +++ b/src/client/chunks/locale.tsx @@ -0,0 +1,60 @@ +/** + * Lazy chunk entry: the 19 non-zh/en dictionaries (ja/de/fr/…/zh-HK/MO/TW — + * ~640KB of source). The sidebar's own `t()` only ever consults zh/en plus + * the better-locale override store, so these dicts are needed ONLY when + * @huanlin/dsh-plugin-better-locale is installed: they register into its + * override store from there (see the client apply's better-locale + * integration). Built as `lib/client-locale.js` and fetched from the + * plugin's /sidebar/bundle route on first need — never import this module + * from the core bundle: it would drag every language back into the startup + * path (see docs/plans/2026-08-31-perf-optimization.md). + */ +import { ja } from '../locales-ja.ts' +import { de } from '../locales-de.ts' +import { fr } from '../locales-fr.ts' +import { pt } from '../locales-pt.ts' +import { ko } from '../locales-ko.ts' +import { ar } from '../locales-ar.ts' +import { hi } from '../locales-hi.ts' +import { id } from '../locales-id.ts' +import { tr } from '../locales-tr.ts' +import { vi } from '../locales-vi.ts' +import { th } from '../locales-th.ts' +import { ru } from '../locales-ru.ts' +import { it } from '../locales-it.ts' +import { nl } from '../locales-nl.ts' +import { sv } from '../locales-sv.ts' +import { pl } from '../locales-pl.ts' +import { zhHK } from '../locales-zh-HK.ts' +import { zhTW } from '../locales-zh-TW.ts' +import { zhMO } from '../locales-zh-MO.ts' +import type { CopyKey } from '../locales.ts' + +/** Key-set check against zh (type-only import — erased before bundling, so + * this adds no runtime tie to the core bundle): a dictionary missing or + * adding a key fails the build instead of silently falling back to en. */ +const checked = (dict: Record): Record => dict + +/** Every non-zh/en dictionary keyed by its override id, ready for the + * better-locale store's `register(ns, dicts)`. */ +export const localeDicts: Record> = { + ja: checked(ja), + de: checked(de), + fr: checked(fr), + pt: checked(pt), + ko: checked(ko), + ar: checked(ar), + hi: checked(hi), + id: checked(id), + tr: checked(tr), + vi: checked(vi), + th: checked(th), + ru: checked(ru), + it: checked(it), + nl: checked(nl), + sv: checked(sv), + pl: checked(pl), + 'zh-HK': checked(zhHK), + 'zh-TW': checked(zhTW), + 'zh-MO': checked(zhMO), +} diff --git a/src/client/index.tsx b/src/client/index.tsx index 7ba5b31..ff25c8a 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -24,10 +24,8 @@ import { registerSettingsNavIcon } from './settings-nav-icon.ts' import { loadBootDecision } from './prefs.ts' import { SideCardSection } from './SideCardSection.tsx' import { api } from './api.ts' -import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en, - ja, de, fr, pt, ko, ar, hi, id, tr, vi, th, ru, it, nl, sv, pl, - zhHK, zhTW, zhMO, -} from './locales.ts' +import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en } from './locales.ts' +import { loadChunk } from './chunk-loader.ts' import css from './sidebar.module.css' import './layout.css' @@ -82,7 +80,12 @@ export function apply(ctx: Context): void { // the ja dict once the store becomes available. ctx.effect(() => { let dispose: (() => void) | undefined + // Guards the async chunk registration below: a sync() re-run (or fiber + // disposal) that lands while the chunk is still in flight must render + // that registration moot. + let generation = 0 const sync = (): void => { + generation += 1 dispose?.() dispose = undefined const store = ctx.get('betterLocale') as @@ -96,10 +99,17 @@ export function apply(ctx: Context): void { | undefined attachBetterLocale(store) if (store !== undefined) { - dispose = store.register(LOCALE_NS, { - ja, de, fr, pt, ko, ar, hi, id, tr, vi, th, ru, it, nl, sv, pl, - 'zh-HK': zhHK, 'zh-TW': zhTW, 'zh-MO': zhMO, - }) + // The 19 override dictionaries ride the lazy `locale` chunk: until + // it lands, the store has no betterSidebar entries and t() keeps + // the zh/en chain; the store's own revision bump on register + // re-renders the chrome once the dicts arrive. + const myGeneration = generation + void loadChunk('locale') + .then(mod => { + if (myGeneration !== generation) return + dispose = store.register(LOCALE_NS, mod.localeDicts as Record>) + }) + .catch(() => { /* the dicts stay unregistered; the zh/en chain runs */ }) } } // Initial check (picks up the store if better-locale activated first). @@ -108,6 +118,7 @@ export function apply(ctx: Context): void { // activates with a persisted override, and when the user switches). const unsubscribe = ctx.locale.subscribe(sync) return () => { + generation += 1 unsubscribe() dispose?.() attachBetterLocale(undefined) diff --git a/src/client/locales.ts b/src/client/locales.ts index 584a7b7..4177b6b 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -845,49 +845,13 @@ export const en: Record = { */ export const LOCALE_NS = 'betterSidebar' -// The ja dictionary lives in a sibling file (326 keys) so this module -// stays readable. Type-checked against the zh key set: a missing or extra -// ja key is a compile error. -import { ja as jaDict } from './locales-ja.ts' -import { de as deDict } from './locales-de.ts' -import { fr as frDict } from './locales-fr.ts' -import { pt as ptDict } from './locales-pt.ts' -import { ko as koDict } from './locales-ko.ts' -import { ar as arDict } from './locales-ar.ts' -import { hi as hiDict } from './locales-hi.ts' -import { id as idDict } from './locales-id.ts' -import { tr as trDict } from './locales-tr.ts' -import { vi as viDict } from './locales-vi.ts' -import { th as thDict } from './locales-th.ts' -import { ru as ruDict } from './locales-ru.ts' -import { it as itDict } from './locales-it.ts' -import { nl as nlDict } from './locales-nl.ts' -import { sv as svDict } from './locales-sv.ts' -import { pl as plDict } from './locales-pl.ts' -import { zhHK as zhHKDict } from './locales-zh-HK.ts' -import { zhTW as zhTWDict } from './locales-zh-TW.ts' -import { zhMO as zhMODict } from './locales-zh-MO.ts' - -/** The ja dictionary (key-set-equal to zh, enforced by the type annotation). */ -export const ja: Record = jaDict as Record -export const de: Record = deDict as Record -export const fr: Record = frDict as Record -export const pt: Record = ptDict as Record -export const ko: Record = koDict as Record -export const ar: Record = arDict as Record -export const hi: Record = hiDict as Record -export const id: Record = idDict as Record -export const tr: Record = trDict as Record -export const vi: Record = viDict as Record -export const th: Record = thDict as Record -export const ru: Record = ruDict as Record -export const it: Record = itDict as Record -export const nl: Record = nlDict as Record -export const sv: Record = svDict as Record -export const pl: Record = plDict as Record -export const zhHK: Record = zhHKDict as Record -export const zhTW: Record = zhTWDict as Record -export const zhMO: Record = zhMODict as Record +// The 19 non-zh/en dictionaries live in the LAZY `locale` chunk +// (src/client/chunks/locale.tsx → lib/client-locale.js): `t()` only ever +// consults zh/en plus the better-locale override store, so those dicts are +// needed only when better-locale is installed — the client apply fetches the +// chunk there and registers its `localeDicts` into the override store. They +// used to sit statically in the core bundle (≈ half its weight) for a code +// path most sessions never enter. /** The DSH locale service attached by the client apply (absent → browser detection). */ let localeService: { getSnapshot(): { active: string } } | undefined diff --git a/tests/bundle-route.spec.ts b/tests/bundle-route.spec.ts index faf4400..1585cd5 100644 --- a/tests/bundle-route.spec.ts +++ b/tests/bundle-route.spec.ts @@ -145,6 +145,6 @@ describe('/sidebar/bundle route', () => { }) it('exports the chunk allowlist (mirror of src/client/chunk-loader.ts)', () => { - expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor', 'mermaid']) + expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor', 'mermaid', 'locale']) }) }) diff --git a/tests/locales.spec.ts b/tests/locales.spec.ts index 4e12ffe..e2e6cab 100644 --- a/tests/locales.spec.ts +++ b/tests/locales.spec.ts @@ -6,7 +6,8 @@ * interpolation. */ import { afterEach, describe, expect, it } from 'vitest' -import { LOCALE_NS, attachBetterLocale, attachLocale, en, isZh, ja, relativeTime, t, zh, de, fr, pt, ko, ar, hi, id as idDict, tr, vi, th, ru, it as itDict, nl, sv, pl, zhHK, zhTW, zhMO } from '../src/client/locales.ts' +import { LOCALE_NS, attachBetterLocale, attachLocale, en, isZh, relativeTime, t, zh } from '../src/client/locales.ts' +import { localeDicts } from '../src/client/chunks/locale.tsx' /** Minimal structural fake of the DSH LocaleService face the sidebar uses. */ class FakeLocale { @@ -108,20 +109,15 @@ describe('locales (DSH i18n following)', () => { expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort()) }) - it('keeps the ja dictionary key-set-equal to zh', () => { - expect(Object.keys(ja).sort()).toEqual(Object.keys(zh).sort()) - }) - it('keeps every shipped third-language dictionary key-set-equal to zh', () => { - // The third-language dicts are typed `Record` via - // an `as` cast (unchecked by tsc). This test catches missing/extra keys - // introduced when a zh key is added without updating every language. - const thirdLangs = { - de, fr, pt, ko, ar, hi, idDict, tr, vi, th, ru, itDict, nl, sv, pl, zhHK, zhTW, zhMO, - } - for (const [lang, dict] of Object.entries(thirdLangs)) { - expect(Object.keys(dict as Record).sort(), lang).toEqual(Object.keys(zh).sort()) + // The third-language dicts live in the lazy locale chunk typed as plain + // records (the chunk boundary has no key-set type tie to zh). This test + // catches missing/extra keys introduced when a zh key is added without + // updating every language — a missing key silently falls back to en. + for (const [lang, dict] of Object.entries(localeDicts)) { + expect(Object.keys(dict).sort(), lang).toEqual(Object.keys(zh).sort()) } + expect(Object.keys(localeDicts), 'every shipped third language rides the locale chunk').toContain('ja') }) }) @@ -247,7 +243,7 @@ describe('locales (better-locale override)', () => { locale.switchTo('en') attachLocale(locale) - const betterLocale = new FakeBetterLocale({ [LOCALE_NS]: { ja } }) + const betterLocale = new FakeBetterLocale({ [LOCALE_NS]: { ja: localeDicts.ja! } }) betterLocale.active = 'ja' attachBetterLocale(betterLocale) diff --git a/tsdown.config.ts b/tsdown.config.ts index 2183fd9..881a45d 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -324,7 +324,7 @@ function makeCssPlugin(pluginId: string): BuildPlugin { } /** The lazy chunk names (keep in sync with src/bundle-route.ts CHUNK_NAMES). */ -const CHUNKS = ['terminal', 'editor', 'mermaid'] +const CHUNKS = ['terminal', 'editor', 'mermaid', 'locale'] export default [ { From cf542d6318125723558dc563ba25dd665742445b Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 1 Sep 2026 00:38:54 +0800 Subject: [PATCH 6/8] perf: tag the center column instead of a :has() selector; stop re-stringifying the editor doc per keystroke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bottom-push rule anchored on #root :has(> [data-slot=conversation]): a :has() match cache invalidates on every #root subtree mutation, and the streaming chat mutates that subtree constantly — every layout-push frame (the bottom drag writes --dsh-sidebar-height per frame) paid a whole-document selector re-evaluation. The shell's locator (which already holds the measured column node for the ResizeObserver) now tags it with [data-dsh-center-col]; the rule evaluates once per node. The tag retargets with the ref on boot swap / HMR node replacement and is removed on unmount; the drag lane pins exactly-one-tagged-node and its identity as the conversation slot wrapper's parent. The dead [data-pane] branch (renamed away in alpha hosts) is gone. TextEditor: the CodeMirror updateListener re-stringified the WHOLE document into React state on every keystroke (O(docLength) per key, re-rendering the shell per key). The listener now only flips the dirty flag; the preview's draft is snapshotted from the live view when entering preview (or on a content swap), and save() already reads the live document. --- docs/external-plugin-guide.md | 2 +- src/client/Sidebar.tsx | 9 +++++++- src/client/TextEditor.tsx | 24 +++++++++++++++------ src/client/layout.css | 24 ++++++++++----------- tests/e2e/drag-layout.e2e.ts | 39 ++++++++++++++--------------------- tests/layout-css.spec.ts | 12 +++++++---- 6 files changed, 63 insertions(+), 47 deletions(-) diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index ca4c5ee..7a9903d 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -752,7 +752,7 @@ ctx.effect(() => - **面板表面**:右/底面板背景 = `var(--dsw-alias-bg-layer-1)`。**绝不消费 `--dsw-specific-sidebar-fill`**(宿主左导航专属,皮肤按左导航语义覆盖它,面板消费会失去填充)。换面板表面 = 覆写 `--dsw-alias-bg-layer-1`。 - **终端/编辑器表面**:`effectiveTokenValue` 读 `--dsw-alias-bg-base`——`transparent` 与 alpha < 0.9 的半透明值回退不透明底色(文字不叠背景画,issue #90);≥ 0.9 放行。 - **根锚点**:宿主 div 带 `data-dsh-better-sidebar`(append 到 body);其内**面板宿主层** `[data-dsh-panel-host]`(`fixed; inset:0; z-25; pointer-events:none`,v0.13.1+),面板/开关簇 absolute 定位,免疫中间层 transform 劫持;页面级 transform 触发 `data-dsh-panel-host-degraded` 降级。皮肤作用域覆盖限定在 `[data-dsh-better-sidebar]` 内。 -- **布局变量**(`` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。宿主推挤 = `#root` margin/width calc + centerCol `margin-bottom`;锚点用**复合选择器双保险**(禁止 `nth-child`):`#root [data-dsh-frame] > [data-pane="conversation"]` 与 `#root :has(> [data-slot="conversation"])`(两代命名同元素,`drag-layout.e2e.ts` 断言)。 +- **布局变量**(`` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。宿主推挤 = `#root` margin/width calc + centerCol `margin-bottom`;锚点 = **JS 标注**(禁止 `nth-child`):侧栏 shell 的定位器给测得的 centerCol 节点打 `[data-dsh-center-col]` 标签(`Sidebar.tsx` locate,节点更换/HMR 时随 ref 迁移),`layout.css` 用 `#root [data-dsh-center-col]` 选中(`drag-layout.e2e.ts` 断言恰一节点且为 `[data-slot="conversation"]` 的父级)。 - **桌面信号与标题栏**(v0.14.1+ 四方案模型 `SidebarPrefs.titleBarScheme`,唯一决策点 `src/client/titlebar-strip.ts` 纯函数): - 壳信号(只读,不自动触发修改):URL `dsh-desktop-mode` / `dsh-desktop-platform` / 可选 `dsh-desktop-titlebar-inset`(0–120 clamp)。 - **strip 取值链**:⓪ `web` 方案强制 0;① `navigator.windowControlsOverlay` 真实几何(`wco.ts` 订阅 `geometrychange`,**为 0 也权威**,`visible=false` 幽灵 API 视为缺失);② URL inset;③ 壳预设 `stripFor`(仅 `preset`);④ 手动 `titleBarStripPx`(仅 `custom`);⑤ 0。驱动 `body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip`。 diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 4f71da1..4b6c538 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -756,6 +756,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { ?.parentElement as HTMLElement | undefined if (col === undefined || !col.isConnected) { if (centerColRef.current !== null) { + centerColRef.current.removeAttribute('data-dsh-center-col') centerColRef.current = null observer?.disconnect() observer = undefined @@ -768,8 +769,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // measure it once. Same-node size changes are the ResizeObserver's // job — no forced measurement here, because a forced // getBoundingClientRect per mutation would reflow the shell at - // mutation cadence. + // mutation cadence. The tag retargets with the ref: layout.css's + // bottom-push rule anchors on [data-dsh-center-col], so exactly the + // measured node carries it (a stale tag on a swapped-out node would + // leave the push rule anchorless or doubled). + centerColRef.current?.removeAttribute('data-dsh-center-col') centerColRef.current = col + col.setAttribute('data-dsh-center-col', '') observer?.disconnect() observer = new ResizeObserver(measureCenter) observer.observe(col) @@ -822,6 +828,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { observer?.disconnect() watcher.disconnect() htmlStyleWatcher.disconnect() + centerColRef.current?.removeAttribute('data-dsh-center-col') centerColRef.current = null } // Opening the bottom panel re-runs the whole locate/measure chain: a diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx index 25114c9..66b0fd7 100644 --- a/src/client/TextEditor.tsx +++ b/src/client/TextEditor.tsx @@ -114,11 +114,13 @@ export function TextEditor(props: FileViewerProps) { }, [content]) // Create the CodeMirror editor once the content is loaded. The view owns - // the document; React only tracks dirty/draft state through the update - // listener. For markdown the view stays mounted while previewing (hidden), - // so unsaved edits survive the preview/edit toggle. The theme + syntax - // colors live in a compartment so a scheme flip reconfigures only that - // part — the document, undo history and scroll position survive. + // the document; React only tracks dirty state through the update listener + // (the draft — the preview's text — is snapshotted from the live view on + // entering preview, not re-stringified per keystroke). For markdown the + // view stays mounted while previewing (hidden), so unsaved edits survive + // the preview/edit toggle. The theme + syntax colors live in a compartment + // so a scheme flip reconfigures only that part — the document, undo + // history and scroll position survive. useEffect(() => { if (content === undefined) return const host = hostRef.current @@ -139,7 +141,6 @@ export function TextEditor(props: FileViewerProps) { ...(language !== null ? [language] : []), CodeMirrorView.updateListener.of((update) => { if (update.docChanged) { - setDraft(update.state.doc.toString()) setDirty(true) } }), @@ -226,6 +227,17 @@ export function TextEditor(props: FileViewerProps) { if (mode === 'edit') viewRef.current?.requestMeasure() }, [mode]) + // Snapshot the live document into the draft whenever the preview needs + // it: entering preview (the markdown preview renders `draft ?? content`) + // and a content swap (the view was just re-created above, so the read is + // the new document — matching the reset-to-null of a clean tab). The + // updateListener used to re-stringify the WHOLE document on every + // keystroke (O(docLength) per key) for a draft only preview reads. + useEffect(() => { + const view = viewRef.current + setDraft(view === null ? null : view.state.doc.toString()) + }, [mode, content]) + const save = (): void => { const view = viewRef.current if (view === null || savingRef.current) return diff --git a/src/client/layout.css b/src/client/layout.css index 17075c6..c31a94a 100644 --- a/src/client/layout.css +++ b/src/client/layout.css @@ -35,14 +35,16 @@ width var(--ds-transition-duration-slow) var(--ds-ease-in-out); } -/* The AppFrame's center column, anchored by data attributes (see above). - Composite selector: DSH 0.1.x versions name the center grid item - `[data-pane="conversation"]`, while rc.8-era shells put a - `[data-slot="conversation"]` child inside it — both selectors resolve to - the SAME element on live pages (verified), and keeping both future-proofs - the rule against a host rename without touching shell-specific markup. */ -#root [data-dsh-frame] > [data-pane="conversation"], -#root :has(> [data-slot="conversation"]) { +/* The AppFrame's center column. The Sidebar shell's locator (Sidebar.tsx + locate/measureCenter) tags the measured column node with + [data-dsh-center-col] — one attribute write per node lifetime. The rule + used to anchor on a structural has()-selector over the conversation slot + wrapper, whose match cache invalidates on every #root subtree mutation + (the streaming chat mutates it constantly), re-evaluating the selector + against the whole document; the tag is evaluated once per node instead. + A stretched grid item shrinks by its margins, so the conversation + content (output + input bar) lifts without touching the sidebars. */ +#root [data-dsh-center-col] { margin-bottom: var(--dsh-sidebar-height, 0px); /* Grid/flex items default to min-height:auto. A long unbreakable token (OAuth URL) then grows this column past the viewport and clips the @@ -68,8 +70,7 @@ body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > hea } body[data-dsh-sidebar-dragging] #root, -body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-pane="conversation"], -body[data-dsh-sidebar-dragging] #root :has(> [data-slot="conversation"]) { +body[data-dsh-sidebar-dragging] #root [data-dsh-center-col] { transition: none; } @@ -94,8 +95,7 @@ body[data-dsh-sidebar-dragging] #root :has(> [data-slot="conversation"]) { @media (prefers-reduced-motion: reduce) { #root, - #root [data-dsh-frame] > [data-pane="conversation"], - #root :has(> [data-slot="conversation"]) { + #root [data-dsh-center-col] { transition: none; } } diff --git a/tests/e2e/drag-layout.e2e.ts b/tests/e2e/drag-layout.e2e.ts index dbc1ed2..9f131d7 100644 --- a/tests/e2e/drag-layout.e2e.ts +++ b/tests/e2e/drag-layout.e2e.ts @@ -612,35 +612,28 @@ test('bottom panel never flashes full-width after a width drag release (issue #2 } }) -test('the bottom-push anchor resolves through the composite selectors (at least one; same element when both)', async ({ page }) => { - // layout.css pushes the bottom panel via the center column. The selector - // is COMPOSITE on purpose: `[data-pane="conversation"]` (0.1.x naming) - // and `:has(> [data-slot="conversation"])` (rc.8-era naming) — HOST - // VERSIONS MAY RENAME THE ATTRIBUTE (issue #208 comment / PR #226), so - // the contract is "at least one resolves", and when both resolve they - // must hit the SAME element (otherwise the push would land twice). +test('the bottom-push anchor resolves through the shell-written center-column tag', async ({ page }) => { + // layout.css pushes the bottom panel via the center column, anchored on + // the [data-dsh-center-col] tag the Sidebar shell's locator writes onto + // the measured column node (Sidebar.tsx locate — the slot wrapper + // [data-slot="conversation"]'s parent). The tag must sit on exactly ONE + // node: a stale tag on a swapped-out node (boot swap / HMR) would leave + // the push rule anchorless or doubled. await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 }) + await expect + .poll(async () => page.evaluate(() => document.querySelectorAll('#root [data-dsh-center-col]').length), { timeout: 90_000 }) + .toBe(1) const anchors = await page.evaluate(() => { - const a = document.querySelector('#root [data-dsh-frame] > [data-pane="conversation"]') - const b = document.querySelector('#root :has(> [data-slot="conversation"])') - const frame = document.querySelector('#root [data-dsh-frame]') + const tagged = document.querySelector('#root [data-dsh-center-col]') + const slotParent = document.querySelector('#root [data-slot="conversation"]')?.parentElement ?? null return { - a: a !== null, - b: b !== null, - same: a !== null && b !== null && a === b, - frameChildren: frame !== null - ? [...frame.children].map(el => `${el.tagName}[${[...el.attributes].map(attr => attr.name).filter(name => name.startsWith('data-')).join(',')}]`) - : [], + tagged: tagged !== null, + same: tagged !== null && slotParent !== null && tagged === slotParent, } }) - expect( - anchors.a || anchors.b, - `at least one bottom-push anchor must resolve on this host (frame children: ${anchors.frameChildren.join(' / ') || 'no [data-dsh-frame]'})`, - ).toBe(true) - if (anchors.a && anchors.b) { - expect(anchors.same, 'both selectors must hit the SAME center-column element').toBe(true) - } + expect(anchors.tagged, 'the tagged center column must resolve').toBe(true) + expect(anchors.same, 'the tag must sit on the conversation slot wrapper\'s parent (the measured column)').toBe(true) }) /* ── bottom-strip drag with the right panel CLOSED must not touch the host diff --git a/tests/layout-css.spec.ts b/tests/layout-css.spec.ts index c90d889..6613607 100644 --- a/tests/layout-css.spec.ts +++ b/tests/layout-css.spec.ts @@ -9,9 +9,13 @@ import { describe, expect, it } from 'vitest' const css = readFileSync('src/client/layout.css', 'utf8') describe('layout.css conversation column', () => { - it('targets the AppFrame center column', () => { - expect(css).toContain('[data-pane="conversation"]') - expect(css).toContain('[data-slot="conversation"]') + it('targets the AppFrame center column through the shell-written tag', () => { + // The bottom-push rule anchors on the [data-dsh-center-col] tag the + // Sidebar shell's locator writes onto the measured column node (see + // Sidebar.tsx locate) — not on a `:has()` selector, whose cache + // invalidates on every #root subtree mutation (the streaming chat). + expect(css).toContain('[data-dsh-center-col]') + expect(css).not.toContain(':has(') }) it('allows the center column to shrink and wrap long tokens', () => { @@ -21,7 +25,7 @@ describe('layout.css conversation column', () => { it('leaves overflow ownership to the host conversation descendants', () => { const conversationRule = css.match( - /#root \[data-dsh-frame\][\s\S]*?\{([\s\S]*?)\n\}/, + /#root \[data-dsh-center-col\][\s\S]*?\{([\s\S]*?)\n\}/, )?.[1] expect(conversationRule).toBeDefined() expect(conversationRule).not.toMatch(/(?:^|[;\s])overflow\s*:/) From 8a01e5d881cf53b58b4d39298cbc21ba3113b8ea Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 1 Sep 2026 00:43:28 +0800 Subject: [PATCH 7/8] docs: record the perf optimization's before/after measurements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comparison table (3-run medians on the same machine against a real 0.1.2-alpha.2 host): mount latency 377→273ms (-28%), the host's aggregated client bundle ships 203KB less over the wire (-14%, matching the 19 dictionaries' gzip weight) and 640KB less decoded (their source weight), boot settings.get 4→2, the bottom-drag width leak 504px→0. Frame pacing was already at 8/10ms p50/p95 — the drag fix removed the host-layout snap, not a frame drop. Structural wins that don't show in a single sweep (git spawns per poll tick, fold passes per poll, transcript row re-renders, per-keystroke doc stringification) are listed with their per-event arithmetic. --- docs/plans/2026-08-31-perf-optimization.md | 87 ++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 docs/plans/2026-08-31-perf-optimization.md diff --git a/docs/plans/2026-08-31-perf-optimization.md b/docs/plans/2026-08-31-perf-optimization.md new file mode 100644 index 0000000..bb61520 --- /dev/null +++ b/docs/plans/2026-08-31-perf-optimization.md @@ -0,0 +1,87 @@ +# 侧边栏性能优化 + 底栏拖拽布局突变修复 + +**日期**:2026-08-31(2026-09-01 rebase 到 main c199212 并在 alpha.3 上复测) +**状态**:已实施(分支 `feat/sidebar-perf`,单 PR 多 commit) +**目标宿主**:DSH v0.1.2-alpha.2 起开发,宿主参考 `/Users/menghuan/Code/deepseek-harness`(只读);main 已切 alpha.3(v0.18.1-alpha.0),rebase 后在 alpha.3 真机全量复测(§3.1) +**测量环境**:macOS arm64 本机,真实 `dsh web`(npx 钉版)+ scratch profile 挂载 tarball,Playwright Chromium 无头,每指标 3 次取中位数(`tests/e2e/perf.e2e.ts` 输出 `PERF_JSON`)。 + +## 1. 修复的 bug:底栏拖拽引发原生左侧边栏突变 + +**症状**:右侧面板关闭时拖动底栏 resizer,DSH 原生左侧边栏突然折叠/跳动;松手后弹回。插件自己的右侧边栏不受影响。 + +**根因**(源码级定位,`Sidebar.tsx`):底栏拖拽的 `applyDrag` 把「持久化的右栏宽度偏好」(如 400px)无条件写进 `--dsh-sidebar-width`,不看 `panelOpen`;而静止时的 push effect 走 `layoutPushSize` 门控(面板关闭 → 0)。于是拖拽首帧该变量 0 → ~400px: + +1. `layout.css` 的 `#root { margin-right; width: calc(100% - …) }` 瞬间挤掉宿主 400px(拖拽期间 transition 被 `body[data-dsh-sidebar-dragging]` 关闭——瞬跳而非动画); +2. 宿主 `AppFrame` 的 ResizeObserver → `setViewport` → viewport 跌破 1024px 断点 → `computeColumns` 把原生左栏折叠成 56px rail(0.3s grid 轨道动画); +3. 松手 `commitDrag` → store 提交 → push effect 又写回 0 → `#root` 弹回 → 左栏再展开。 + +插件右栏在 `#root` 之外的 `document.body` fixed 层(`[data-dsh-panel-host]`),几何上碰不到——这正是「左栏突变、右栏无恙」的不对称来源。底栏是**唯一**在 `panelOpen === false` 时可达的拖拽(宽度/角点拖拽都要求面板开着),所以只有它触发。`abortDrag` 路径此前已修过(走 `layoutPushSize` 门控),move/up 路径漏网。 + +**修复**:`applyDrag` 里 `writeGeometry` 的 width 实参加上与 push effect 一致的门控(`!narrow && panelOpen ? min(width, vw) : 0`);面板 DOM 写入(`panelRef`/`bottomRef`/`lastDragSize`)保持原始 width(底栏 `right` 定位派生自它)。 + +**验证**:e2e 回归(`drag-layout.e2e.ts`)——右面板关闭 + 底栏打开拖 strip,逐帧断言 pushed width 恒 0、宿主 grid 模板 / 左栏渲染宽度 / `#root` 右边缘全程不变。旧代码上实测泄漏 504px(红),修复后通过(绿)。 + +## 2. 性能改动清单 + +| # | 改动 | 类别 | commit | +|---|---|---|---| +| 1 | boot 路径 `loadPrefs` + `loadExternalDisable` 合并为一次 `settingsGet`(原来同文档串行拉两次,第二个无超时——settings RPC 挂起则侧边栏永不出现) | 启动 | perf: cut the boot and poll overhead | +| 2 | GitLens 2s silent 轮询不再每 tick 重列 worktrees(每 tick 少 spawn 1 个 git 进程;每 15 tick ≈30s 才重列+自动选择一次) | 运行时开销 | 同上 | +| 3 | ChangesTab 的 `extractFileOps` fold 从「每 poll 两遍(badge 一遍 + render 一遍)」改为每 poll 一遍、结果进 ref 共享(4000 条事件 × 每条 JSON.parse) | CPU | 同上 | +| 4 | SideChat 转录行引用复用:`transcriptRows` 接受上次结果,位置对齐内容相同的行复用旧对象——2s 轮询只重渲染变化的尾部(原来全部行连同 markdown 重解析每轮重渲染) | 渲染 | perf: stabilize renders | +| 5 | FileTree `expanded`/`revealed` 数组 → Set(原来每行 `includes()` 线性扫描,整树 O(rows×expanded)) | 渲染 | 同上 | +| 6 | `buildNewTabOptions` 从两处 JSX 内联调用改为共享 memo(原来每次 render 两个新数组,击穿 LeafView 的 props 比较) | 渲染 | 同上 | +| 7 | DiffPane 高度拖拽、TerminalView ResizeObserver 走每帧批处理(原来每 pointer/resize 事件一次 setState / 一次含字形度量的 fit());SessionLens 的 `new Blob().size`(整段编辑内容的 UTF-8 编码)从每行每 render 移到每变化一次的 memo | 渲染 | 同上 | +| 8 | 19 个非 zh/en 字典移入 lazy `locale` chunk(`lib/client-locale.js`,仅安装 better-locale 时按需拉取;核心包只留 zh/en——`t()` 本来也只查 zh/en + override store) | 启动体积 | perf: move the 19 dictionaries | +| 9 | 底栏推挤锚点从 `:has(> [data-slot=conversation])` 换成 shell 定位器打的 `[data-dsh-center-col]` 标注(`:has()` 匹配缓存随流式聊天的 #root 子树变动不断失效,每次布局推挤帧全文档重估) | 拖拽帧成本 | perf: tag the center column | +| 10 | TextEditor 不再每键 `doc.toString()` 全文字符串化进 React state(O(docLength)/键);listener 只翻 dirty,进预览/换文件时从 live view 快照一次;保存本就现读 | 编辑 | 同上 | + +## 3. Before / After 对比 + +测量说明:`mount-sweep` = 冷导航 → 插件挂载 → 展开面板 → 依次打开全部 6 个内置 tab;`bottom-drag` = 面板关闭 + 底栏打开时拖 12 步的 rAF 帧间隔。同机同夜连续跑,数字用于相对比较而非绝对承诺。 + +| 指标 | before(main d70db8f) | after(本分支) | 变化 | +|---|---|---|---| +| 挂载时延(navigationStart → host attach,中位) | 376.9 ms | 272.9 ms | **-28%** | +| 挂载+扫 tab 期间 longtask 数 / 总时长 / 最长 | 2 / 161 ms / 96 ms | 2 / 146 ms / 84 ms | 总时长 -9%,最长 -13% | +| 核心 client.js 体积(未压缩) | 1.46 MB | 0.80 MB | **-45%** | +| 宿主聚合 client bundle 传输量(含全部插件,gzip) | 1,436,478 B | 1,233,778 B | **-203 KB / -14%** | +| 宿主聚合 client bundle 解码体积 | 5,136,894 B | 4,497,363 B | -640 KB(即字典总量,吻合) | +| 侧栏 shape 的资源请求数 / 其中 settings.get | 20 / ×4 | 18 / ×2 | boot 合并生效 | +| 底栏拖拽帧间隔 p50 / p95 / max | 8 / 10 / 11 ms | 8 / 10 / 11 ms | 持平(本就不掉帧;修复的是布局突变) | +| 底栏拖拽 pushed width 泄漏(右面板关闭时) | **504 px**(bug) | **0 px** | 修复 | + +### 结构性收益(不随单次测量波动) + +- **核心 bundle**:19 个字典(源码 ~640KB)出核心包,`client.js` 1.46MB → 822KB(构建产物实测)。绝大多数会话(未装 better-locale)永远不拉 `client-locale.js`(gzip 171KB);装了的用户按需拉一次(HTTP 缓存 + ETag 复用)。 +- **boot RPC**:settings 文档从 2 次串行 → 1 次;`loadExternalDisable` 有了 2s 超时兜底。 +- **GitLens 轮询的 git 进程 spawn**:每分钟 60 个(2 个/tick × 30 tick)→ ~34 个(1 个/tick + 每 15 tick 多 1 个),-43%。 +- **ChangesTab fold**:每 2.5s poll 的 fold 次数 2 → 1(每次 fold 对至多 4000 条事件逐条处理、tool/call 还要 `JSON.parse`)。 +- **SideChat 转录**:内容未变的行不再重建对象 → 下游 React reconcile + markdown 解析 + DOMPurify 全部跳过(引用稳定性由 `sidechat-transcript.spec.ts` 契约钉住)。 +- **TextEditor**:每次按键的 O(文档长度) 字符串化 + shell 重渲染 → 0(仅 dirty 布尔)。 + +### 3.1 alpha.3 复测(rebase 到 main c199212 之后) + +main 在本分支开发期间合入了 alpha.3 适配(peer `^0.1.2-alpha.3`,v0.18.1-alpha.0)与 sidechat 渲染升级(新增 `turnSummary` 行 kind 与 tool `card`——`rowsEqual` 已补齐两者,card 只在 tool/result 落地时变化、被 `executing`/`failed`/`resultText` 的比较覆盖,无需值比较)。rebase 后在 alpha.3 真机重测(同法 3 次中位,before 侧 = main c199212 代码 + 本分支的测量工具): + +| 指标 | before(main c199212) | after(本分支) | 变化 | +|---|---|---|---| +| 挂载时延(中位) | 372.3 ms | 316.5 ms | **-15%** | +| longtask 总时长 / 最长 | 170 / 100 ms | 165 / 96 ms | -3% / -4% | +| 宿主聚合 bundle 传输 | 1,456,668 B | 1,246,595 B | **-210 KB / -14%** | +| boot settings.get | ×4 | ×2 | 合并生效 | +| 底栏拖拽 pushed width 泄漏 | **504 px**(复现) | **0 px** | 修复 | + +全部收益在新 main 基线上成立;完整 mount lane(17 测试)在 alpha.3 真机全绿。§3 主表为 alpha.2 上的原始测量记录(两端同宿主,对比自洽),保留作历史基线。 + +## 4. 守护 + +- e2e:`drag-layout.e2e.ts` 新增底栏回归(宿主布局全程静止)与 center-col 标注断言(恰一节点、为 conversation slot 的父级);`perf.e2e.ts` 的 width-leak 断言在每次测量 lane 里复检。 +- 单测:`prefs.spec.ts`(boot 决策单次 fetch)、`sidechat-transcript.spec.ts`(行引用复用契约)、`locales.spec.ts`(chunk 内全部字典 key-set 与 zh 一致)、`layout-css.spec.ts`(锚点选择器契约,禁 `:has` 回潮)、`bundle-route.spec.ts`(chunk 清单含 locale)。 +- 完整 mount lane(17 测试)在本分支全绿(alpha.2 与 alpha.3 真机各跑一轮)。 + +## 5. 明确不做(取舍) + +- `pinnedEntries` 的 store 版本号改造、连续 `store.reduce` 合并:侵入 store API,收益不明确,KISS 取舍。 +- FileTree / SideChat 列表虚拟化:Set 化 + 引用复用后若真实负载仍不达标再立项。 +- `e2e-mount.sh` 的 `command -v "$DSH_CMD"` 对带空格命令失效(回退未钉版本的 npx)是**宿主侧脚本的既有行为**,本地以 `DSH_CMD=/tmp/dsh-alpha2.sh` 包装脚本绕过,不改脚本(CI 全局安装钉版本,不受影响)。 From e06ce8cef1c8786fd40398577f3e7830a57b7725 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 1 Sep 2026 00:43:28 +0800 Subject: [PATCH 8/8] test: fix the perf lane's resource typing for typecheck getEntriesByType widens to PerformanceEntry; transferSize/ decodedBodySize live on PerformanceResourceTiming (the e2e specs sit outside tsconfig.build.json, so the bundler never saw this). --- tests/e2e/perf.e2e.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/e2e/perf.e2e.ts b/tests/e2e/perf.e2e.ts index d8bf184..0e90493 100644 --- a/tests/e2e/perf.e2e.ts +++ b/tests/e2e/perf.e2e.ts @@ -149,12 +149,15 @@ test('measure: mount latency, longtasks and bundle cost through a full tab sweep const resources = await page.evaluate(() => performance.getEntriesByType('resource') .filter(entry => /sidebar/.test(entry.name)) - .map(entry => ({ - name: entry.name.replace(/^https?:\/\/[^/]+/, ''), - transferSize: entry.transferSize, - decodedBodySize: entry.decodedBodySize, - duration: entry.duration, - })) as ResourceStat[], + .map(entry => { + const resource = entry as PerformanceResourceTiming + return { + name: entry.name.replace(/^https?:\/\/[^/]+/, ''), + transferSize: resource.transferSize, + decodedBodySize: resource.decodedBodySize, + duration: entry.duration, + } + }) as ResourceStat[], ) const longtasks = probe.longtasks ?? []