Phase 2 of the 0.1.5 adaptation: the plugin stops drawing its own right
panel and contributes tab TYPES to DSH's native right Sidebar, which owns
the right column. The bottom workbench (terminal dock) stays plugin-owned.
Registrations (src/client/native/):
- every TabDescriptor becomes a native tab type (kind = descriptor.id, the
default `extension` band) plus a keyed body in `sidebar.right.pane.tab`
and a live chip title in `sidebar.right.pane.tab.title`
- the `editor` type also claims `dsh-resource://file/**`, so it outranks
the built-in `text` preview (fallback) and every file the product opens
lands in the plugin's editor
- the built-in `files` page kind is TAKEN OVER by an extension
registration of the same kind: `openTab('files')` draws the plugin's
explorer, and the built-in resumes when the plugin unregisters
- registrations follow the plugin registry + the side-card settings: a
type registered later (external plugin) gets its native type too, a
disabled type is unregistered (absent from the guide, refuses opens)
Adapter (src/client/native/tab-adapter.tsx):
- one synthetic plugin `SidebarTab` per native tab id, minted from the
native record + navigation params (or the descriptor's own createTab,
which a native guide open bypasses), kept live across navigations and
updateTab writes
- the explorer's per-tab expansion/reveal sets and the per-kind instance
counter, which the plugin's own layout used to hold
Service routing (src/client/service.ts):
- `setSurface` installs the native write face; openTab/openFile land
natively (a path becomes a resource address, a path-less editor open
becomes the `files` page, the rest carry the seed as navigation params),
closeTab/updateTab/activateTab recognise native tab ids
- new optional `OpenTabSeed.target` ('right' default, 'bottom' keeps the
bottom workbench); the bottom panel's own + menu and auto-terminal pass
'bottom'
- src/client/native/surface.ts probes the controller's per-session writes
(`openTabIn`/`openResourceIn`/`closeIn`, not in `ISidebarRight`) and
queues opens for a session whose surface is not mounted yet
Right panel retired behaviourally (Phase 3 deletes the code): Sidebar
renders its bottom workbench only, keeps the panel collapsed, and hides
the right-panel toggle. The panel-specific e2e specs are removed; the
mount lane now drives the native guide (tab sweep), the plugin explorer
inside the native pane (editor/mermaid/README chunks) and the sidechat
host routes.
Verified: pnpm typecheck / lint / build / test (126 files, 1349 tests) /
check:consumer-types, plus pnpm test:mount against a real
@deepseek-ai/dsh@0.1.5-alpha.1 host (7/7).
278 lines
12 KiB
TypeScript
278 lines
12 KiB
TypeScript
/**
|
||
* 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, sendFirstMessage } 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 NATIVE_TABS = ['files', 'git', 'subagent', 'sidechat', 'terminal', 'browser']
|
||
|
||
let api: APIRequestContext
|
||
|
||
async function seedSession(): Promise<void> {
|
||
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<void> {
|
||
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)
|
||
|
||
// The native Sidebar's way in lives in the conversation header's corner,
|
||
// which DSH renders only for a session with content.
|
||
await sendFirstMessage(page)
|
||
|
||
// Open DSH's native right Sidebar and sweep every plugin tab type through
|
||
// its guide page — the same crash-sweep surface as mount.e2e.ts, reused
|
||
// here as the workload (the plugin's content lives in the native surface on
|
||
// 0.1.5; the plugin's own panel keeps only its bottom workbench).
|
||
await page.locator('[data-sidebar-right-expand]').first().click()
|
||
const pane = page.locator('[data-sidebar-right-panel]')
|
||
await expect(pane).toBeVisible({ timeout: 90_000 })
|
||
const addTab = page.locator('[data-dockkit-add-tab]').first()
|
||
for (const kind of NATIVE_TABS) {
|
||
if (await page.locator('[data-sidebar-right-guide]').count() === 0) await addTab.click()
|
||
const entry = page.locator(`[data-sidebar-right-guide-entry="${kind}"]`)
|
||
await expect(entry, `native tab type "${kind}" must be offered by the guide`).toHaveCount(1)
|
||
await entry.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 => {
|
||
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 ?? []
|
||
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<HTMLElement>('*')].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<HTMLElement>('*')].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)
|
||
})
|