feat(dsh): adapt to DSH 0.1.5-rc.1 and restore the guide description

Baseline moves to DSH 0.1.5-rc.1 — the 0.1.5 line's first release
candidate, and the npm `latest` dist-tag now: peer floor ^0.1.5-rc.1,
devDependency pins, the CI mount-lane pin and dsh.plugin.json engines
all follow. 0.1.5-alpha.2 users stay on v0.19.0-alpha.1.

The upstream delta from alpha.2 is small: 373 changed files, mostly
version bumps, sidebar preview polish and test snapshots. Exactly two
changes touch this plugin, and only one needs work:

- `SidebarRightGuideEntry.description` is back (optional). The native
  guide renders it only while it lists at most 4 entries; a longer list
  drops every description. The plugin restores `TabDescriptor.description`
  and the six built-in `guideDesc*` lines (20 dictionaries) verbatim from
  0b6ac5d^, but deliberately does NOT restore the old generic
  `nativeGuideDesc` fallback: the host has no fallback of its own, so a
  descriptor that declares none now ships no `description` field at all
  and renders as a clean title-only capsule.

  Consequence worth knowing: the plugin contributes six guide entries, so
  the default composition is over the 4-entry limit and shows titles only.
  The mount lane proves the restored field end to end by shrinking the
  enabled set to three types through the plugin's own settings route
  (scratch profile only) and requiring the Files capsule to grow its line.

- The built-in `files` type declares a guide description again and uses a
  coloured folder glyph; the plugin owns that kind, so nothing to do.

Verified unchanged from alpha.2 (so nobody re-checks them): the global
main-panel model, the file address grammar (util/workspace-path changed
only its package.json), the native tab-body host contract (.paneBody is
still a block scroller with a definite height), the core/agent/session/
subagent host APIs and the ui-primitives export surface.

Restore caveat fixed in the lane: the settings route merges patches
key-wise, so undoing a temporary `tabsEnabled` change by posting the
original (empty) map leaves the disabled keys behind — the e2e restores
every key it touched explicitly.
This commit is contained in:
Menghuan1918
2026-09-10 14:41:16 +08:00
parent 036a747c80
commit 725b2f7cec
36 changed files with 726 additions and 362 deletions
+22 -5
View File
@@ -45,11 +45,9 @@ describe('built-in tab registrations', () => {
})
it('every visible tab declares a non-empty, mutually distinct title', () => {
// The native new-tab list (guide page) is an icon+title capsule at DSH
// 0.1.5-alpha.2 — the `description` field is gone from the host contract.
// The title is therefore the only per-tab guide text: it must exist, and
// no two visible tabs may share one (identical capsules would be
// indistinguishable in the guide and the tab strip).
// The title is the tab's identity in the native new-tab list (guide
// page) and the tab strip: it must exist, and no two visible tabs may
// share one (identical capsules would be indistinguishable).
const { service } = setup()
const visible = service.getTabs().filter(descriptor => descriptor.hidden !== true)
expect(visible.length).toBeGreaterThan(0)
@@ -62,6 +60,25 @@ describe('built-in tab registrations', () => {
expect(new Set(titles).size, 'titles must differ per tab').toBe(visible.length)
})
it('every visible tab declares a non-empty, mutually distinct description', () => {
// DSH 0.1.5-rc.1 renders `description` under the title while the guide
// lists at most 4 entries (a longer list drops every description). With
// the host no longer substituting a generic fallback, a tab without one
// renders the title alone — so every visible tab declares the real
// purpose of its page, and no two may read identically.
const { service } = setup()
const visible = service.getTabs().filter(descriptor => descriptor.hidden !== true)
expect(visible.length).toBeGreaterThan(0)
for (const descriptor of visible) {
expect(descriptor.description, `${descriptor.id} must declare a description`).toBeDefined()
const line = typeof descriptor.description === 'function' ? descriptor.description() : descriptor.description
expect(line, `${descriptor.id} description must be non-empty`).toBeTruthy()
}
const lines = visible.map(descriptor =>
typeof descriptor.description === 'function' ? descriptor.description() : descriptor.description)
expect(new Set(lines).size, 'descriptions must differ per tab').toBe(visible.length)
})
it('the changes tab declares no settings of its own (the diff always docks)', () => {
const { service } = setup()
expect(service.getTab('git')?.settings).toBeUndefined()
+50
View File
@@ -347,6 +347,56 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
)
.toBe('filled')
// DSH 0.1.5-rc.1 renders a guide entry's `description` line only while
// the guide lists at most 4 entries (`MAX_DESCRIBED_ENTRIES` in the host's
// GuideBody) — a longer list drops every description and shows titles
// alone. Shrink the enabled set through the plugin's OWN settings route
// (scratch-profile prefs only, never the user's) so three types stay
// enabled (files / sidechat / browser), then require the Files capsule to
// really grow its description line. This is the host-side proof of the
// rc.1 description restore: before it every entry was a title-only
// capsule, so no entry count could ever surface the text.
const settingsGet = await api.post(sidebarApi('settings.get'), { data: {} })
expect(settingsGet.ok(), `settings.get: ${settingsGet.status()}`).toBe(true)
const settingsGetBody = (await settingsGet.json()) as { value?: { tabsEnabled?: Record<string, boolean> } }
const originalTabsEnabled = settingsGetBody.value?.tabsEnabled ?? {}
/** The types this check switches off (leaving three entries, i.e. ≤4). */
const shrunken = ['git', 'subagent', 'terminal'] as const
try {
// Send the FULL map back (the route's patch is key-wise merged, so a
// full map is correct whether the host merges or replaces).
const tabsEnabled: Record<string, boolean> = { ...originalTabsEnabled }
for (const id of shrunken) tabsEnabled[id] = false
const tabsUpdate = await api.post(sidebarApi('settings.update'), { data: { patch: { tabsEnabled } } })
expect(tabsUpdate.ok(), `settings.update (tabsEnabled): ${tabsUpdate.status()} ${await tabsUpdate.text()}`).toBe(true)
// Re-seed the guide (a pane holds one guide tab) and wait for the
// shrunken list: the three remaining entries render their descriptions.
if (await page.locator('[data-sidebar-right-guide]').count() === 0) await addTab.click()
const filesCapsule = page.locator('[data-sidebar-right-guide-entry="files"]')
await expect(filesCapsule, 'the Files entry must still be offered after the shrink').toHaveCount(1, { timeout: 30_000 })
await expect(
filesCapsule,
'with ≤4 guide entries the Files capsule must render its description line',
).toContainText('workspace tree', { timeout: 30_000 })
for (const disabled of shrunken) {
await expect(
page.locator(`[data-sidebar-right-guide-entry="${disabled}"]`),
`the disabled "${disabled}" type must leave the guide`,
).toHaveCount(0)
}
} finally {
// Restore the profile's original prefs even on failure. The patch merges
// key-wise, so sending the original map back is NOT enough — the three
// `false` entries written above would survive. Every key this check
// touched is restored explicitly (an absent key means enabled, so a key
// the profile never set goes back to `true`). This lane's later tests
// (perf.e2e.ts) sweep the guide and would otherwise find types missing.
const restored: Record<string, boolean> = { ...originalTabsEnabled }
for (const id of shrunken) restored[id] = originalTabsEnabled[id] ?? true
const restore = await api.post(sidebarApi('settings.update'), { data: { patch: { tabsEnabled: restored } } })
expect(restore.ok(), `settings.update (restore tabsEnabled): ${restore.status()} ${await restore.text()}`).toBe(true)
}
// Side Chat host-route smoke against the REAL host: create a thread child
// under the seeded session (custom-seed creation through AgentRegistry),
// deliver a follow-up, cancel, and release it. The turn itself cannot run
+22 -13
View File
@@ -165,8 +165,9 @@ describe('registerNativeSurface lifecycle (service-driven registration)', () =>
const store = createSidebarStore()
store.setSession('s1')
const service = createBetterSidebarService(store)
service.registerTab({ id: 'terminal', title: 'Terminal', component: () => null })
service.registerTab({ id: 'editor', title: 'Files', component: () => null, icon: () => null })
service.registerTab({ id: 'terminal', title: 'Terminal', component: () => null, description: () => 'Runs a shell' })
service.registerTab({ id: 'editor', title: 'Files', component: () => null, icon: () => null, description: () => 'Browse the tree' })
service.registerTab({ id: 'browser', title: 'Browser', component: () => null })
const records = createNativeTabRecords()
const registered: Array<{ id: string; kind: string; title: (address: string) => string; guide: unknown }> = []
@@ -208,7 +209,7 @@ describe('registerNativeSurface lifecycle (service-driven registration)', () =>
},
}
runInjected?.()
expect(registered.map(entry => entry.kind).sort()).toEqual(['editor', 'files', 'terminal'])
expect(registered.map(entry => entry.kind).sort()).toEqual(['browser', 'editor', 'files', 'terminal'])
expect(registered.map(entry => entry.id)).toContain('dsh-better-sidebar:files')
expect(slotKeys).toContain('dsh-better-sidebar:terminal')
expect(slotKeys).toContain('dsh-better-sidebar:files')
@@ -228,19 +229,27 @@ describe('registerNativeSurface lifecycle (service-driven registration)', () =>
expect(filesType?.guide).toBeDefined()
// The takeover carries the editor's glyph, so the "Files" guide row is
// not the only one with a blank icon slot.
const filesGuide = filesType?.guide as Array<{ icon?: unknown; title: () => string; description?: unknown }> | undefined
const filesGuide = filesType?.guide as Array<{ icon?: unknown; title: () => string; description?: () => string }> | undefined
expect(filesGuide?.[0]?.icon).toBeDefined()
// DSH 0.1.5-alpha.2 renders guide entries as icon+title capsules — the
// `description` field is GONE from the host contract, so no entry may
// carry one (a leftover would be silently ignored, not rendered).
expect(filesGuide?.[0]?.description).toBeUndefined()
expect('description' in (filesGuide?.[0] ?? {})).toBe(false)
expect(filesGuide?.[0]?.title?.()).toBe('Files')
// DSH 0.1.5-rc.1 restored the guide `description` as an optional
// `() => string` (rendered only while the guide lists at most 4
// entries). The takeover IS the editor's page, so its guide line is the
// EDITOR descriptor's description (the takeover reuses it, exactly as it
// reuses the glyph), evaluated fresh per call so a thunk follows the
// active locale.
expect(filesGuide?.[0]?.description?.()).toBe('Browse the tree')
const terminalGuide = registered.find(entry => entry.kind === 'terminal')?.guide as
Array<{ description?: () => string }> | undefined
expect(terminalGuide?.[0]?.description?.()).toBe('Runs a shell')
// A descriptor that declares NO description must reach the host with no
// `description` field at all — the host has no fallback of its own, so
// an empty thunk would render a blank second line instead of a clean
// title-only capsule.
const browserGuide = registered.find(entry => entry.kind === 'browser')?.guide as
Array<{ title: () => string; description?: unknown }> | undefined
expect(terminalGuide?.[0]?.description).toBeUndefined()
expect('description' in (terminalGuide?.[0] ?? {})).toBe(false)
expect(terminalGuide?.[0]?.title?.()).toBe('Terminal')
expect(browserGuide?.[0]?.description).toBeUndefined()
expect('description' in (browserGuide?.[0] ?? {})).toBe(false)
expect(browserGuide?.[0]?.title?.()).toBe('Browser')
dispose()
})