feat(dsh): adapt to DSH 0.1.5-alpha.1

Move the plugin onto the DSH 0.1.5-alpha.1 baseline (peer floor
^0.1.5-alpha.1, CI pinned to the alpha dist-tag) and fix the two host
contract changes that alpha line introduced.

Baseline:
- all @deepseek-ai/dsh-* peer/dev deps -> 0.1.5-alpha.1; optional peer
  @deepseek-ai/dsh-client-ui-sidebar-right (declared, unused until the
  native-surface phase); devDep @deepseek-ai/dsh-agent-loop (inbox fold)
- dsh.plugin.json engines >=0.1.5-alpha.1; version 0.19.0-alpha.0;
  SIDEBAR_SERVICE_VERSION follows

Live assistant stream (assistant/chunk is gone):
- 0.1.5 publishes in-flight model deltas as process-local
  agent/assistant-stream frames instead of durable assistant/chunk events
  and embeds the settled stream in assistant/message / assistant/attempt.
  src/assistant-live.ts folds the frames into a bounded per-session buffer;
  sidechat.events serves the current attempt as `live` rows (replaced every
  poll, never a delta) and the transcript renders them; the inherited
  in-progress snapshot reads message content / attempt stream / live frames.

Cold session reads (persistence.inspect is gone):
- 0.1.5 reads a stored session through an explicit read handle
  (open(id,'read') -> handle.read() -> close()). src/session-store.ts owns
  that protocol; every cold read (sidechat transcript, cold-resume preset,
  session.cwd, changes replay) goes through it.

Dead code and docs:
- drop the remote.session.openWorkspacePath interception: 0.1.5's chat opens
  files through ctx.sidebarRight.openResource instead, so the wrapper had no
  caller and would hijack the host's "open in app" gesture; the
  interceptOpenPath pref, its settings row and its locale keys go with it
- AGENTS.md §3 + external-plugin-guide.md describe the new baseline
- vitest setup bridges Node's localStorage accessor to jsdom's store (the
  pre-existing 26 failures on Node >= 22)
- desktop-layout e2e seeds through host RPC and types into the Lexical
  composer (0.1.5 replaced the textarea) instead of driving the directory
  picker

Verified: pnpm typecheck / lint / build / test (124 files, 1327 tests) /
check:consumer-types, plus pnpm test:mount against a real
@deepseek-ai/dsh@0.1.5-alpha.1 host (18/18).
This commit is contained in:
Menghuan1918
2026-09-09 11:15:30 +08:00
parent d88dcfc3a5
commit ba0114749f
59 changed files with 1163 additions and 1011 deletions
+40 -24
View File
@@ -2,14 +2,16 @@
import { mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { expect, test, type Page } from '@playwright/test'
import { PAGE_URL } from './host'
import { expect, test, type APIRequestContext, type Page } from '@playwright/test'
import { PAGE_URL, createHostApi, hostRpc } from './host'
const WORKSPACE_PATH = process.env.DSH_E2E_DESKTOP_WORKSPACE
?? (process.env.DSH_E2E_WORKSPACE === undefined
? join(homedir(), 'dsh-e2e-desktop-layout-workspace')
: `${process.env.DSH_E2E_WORKSPACE}-desktop-layout`)
let api: APIRequestContext
async function dismissOnboarding(page: Page): Promise<void> {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later|继续|稍后配置)$/ }).count(), { timeout: 60_000 })
@@ -28,8 +30,20 @@ async function dismissOnboarding(page: Page): Promise<void> {
throw new Error('onboarding takeovers did not settle after visible dismissal')
}
test.beforeAll(() => {
test.beforeAll(async () => {
mkdirSync(WORKSPACE_PATH, { recursive: true })
// Seed one workspace + one session through the host's own RPC surface (the
// calls the UI makes) instead of driving the workspace picker: DSH 0.1.5
// ships BOTH a browser directory dialog and an OS-native chooser and picks
// one by platform, so a UI-driven flow only works on hosts whose chooser
// renders in the page.
api = await createHostApi()
const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH })
await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId })
})
test.afterAll(async () => {
await api?.dispose()
})
test('right panel keeps desktop session actions in their header positions', async ({ page }) => {
@@ -45,38 +59,37 @@ test('right panel keeps desktop session actions in their header positions', asyn
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
await dismissOnboarding(page)
await page.getByRole('button', { name: /^(Choose workspace|选择工作区)$/ }).click()
const picker = page.getByRole('dialog', { name: /^(Select Workspace Directory|选择工作区目录)$/ })
await expect(picker).toBeVisible()
await picker.getByRole('button', { name: /^(Edit path|编辑路径)$/ }).click()
const pathInput = picker.getByRole('textbox', { name: /^(Edit path|编辑路径)$/ })
await pathInput.fill(WORKSPACE_PATH)
await pathInput.press('Enter')
await expect(pathInput).toBeHidden()
await picker.getByRole('button', { name: /^(Open|打开)$/ }).click()
// Target the ACTIVE composer by its accessible name. DSH 0.1.2-alpha hosts
// keep an INERT `data-composer-input` "Choose workspace" textbox (with
// contenteditable="false") later in DOM order than the real composer — a
// bare `getByRole('textbox').last()` resolved to that one and fill() threw
// ("not an <input>, <textarea> or [contenteditable] element").
// The seeded blank session opens with its workspace already attached (the
// chip shows the workspace title), so the hero composer is live without
// touching the workspace picker at all. One message gives the header its
// real actions, which this spec measures.
const composer = page.getByRole('textbox', { name: /^(Describe what you want to build|描述你想要构建)/ })
await expect(composer).toBeVisible()
await composer.fill('Create a desktop side-card layout test session.')
await page.getByRole('button', { name: /^(Send message|发送消息)$/ }).click()
// DSH 0.1.5's composer is a Lexical contenteditable: fill() writes the DOM
// but not the editor model, so the send button would stay disabled — type
// real keys instead.
await composer.click()
await page.keyboard.type('Create a desktop side-card layout test session.')
const send = page.getByRole('button', { name: /^(Send message|发送消息)$/ })
await expect(send).toBeEnabled({ timeout: 30_000 })
await send.click()
const sessionLog = page.getByRole('button', { name: 'Session log', exact: true })
const sessionLogLabel = sessionLog.getByText('Session log', { exact: true })
await expect(sessionLogLabel, 'the desktop session-log action starts as a text button').toBeVisible({ timeout: 30_000 })
const root = page.locator('#root')
const frame = page.locator('#root [data-dsh-frame], #root > [data-slot="root"] > div').first()
const appSidebarExpanded = root.getByRole('button', { name: /^(Collapse sidebar|收起侧边栏)$/ })
const sidebar = page.locator('[data-dsh-better-sidebar]')
const sessionLog = page.getByRole('button', { name: 'Session log', exact: true })
const sessionLogLabel = sessionLog.getByText('Session log', { exact: true })
await expect(sessionLogLabel, 'the desktop session-log action starts as a text button').toBeVisible({ timeout: 30_000 })
await expect(appSidebarExpanded, 'the desktop frame starts with its app sidebar expanded').toBeVisible()
const beforeRoot = await root.boundingBox()
const beforeFrame = await frame.boundingBox()
expect(beforeRoot).not.toBeNull()
expect(beforeFrame).not.toBeNull()
// The header action's own width is the baseline: the assertion below is
// "the panel does not squeeze it", not a host-styling constant.
const beforeSessionLog = await sessionLog.boundingBox()
expect(beforeSessionLog).not.toBeNull()
await sidebar.getByRole('button', { name: /^(Expand sidebar|展开侧边栏)$/ }).click()
const panel = page.locator('[data-dsh-panel]:not([data-dsh-bottom-panel])')
@@ -103,7 +116,10 @@ test('right panel keeps desktop session actions in their header positions', asyn
const [sessionLogBox, panelBox] = await Promise.all([sessionLog.boundingBox(), panel.boundingBox()])
expect(sessionLogBox).not.toBeNull()
expect(panelBox).not.toBeNull()
expect(sessionLogBox!.width, 'the desktop session-log action must keep its full button width').toBeGreaterThanOrEqual(110)
expect(
sessionLogBox!.width,
'the desktop session-log action must keep its full button width',
).toBeGreaterThanOrEqual(beforeSessionLog!.width - 1)
expect(sessionLogBox!.x + sessionLogBox!.width, 'the session-log action must not overlap the plugin panel chrome').toBeLessThanOrEqual(panelBox!.x - 8)
expect(pageErrors).toEqual([])
expect(consoleErrors).toEqual([])