feat: adapt to DSH 0.1.7-alpha.1 and yield the read-only previews

DSH 0.1.7-alpha.1 is 1299 commits past 0.1.6-alpha.2 and rewrites four
contracts this plugin was built on. The peer floor moves to
^0.1.7-alpha.1 (semver's prerelease rule means ^0.1.6-alpha.2 can never
match a 0.1.7 prerelease); 0.1.6-alpha.2 and earlier stay on v0.19.1.
Released as 0.21.0-alpha.1 (dist-tag alpha).

Hard breaks:
- dsh-settings was rewritten: register(ns, schema) is gone, a form is
  addressed by the plugin row's Loader entry id and read from the
  module's exported Config. The preferences are merged into Config and
  marked volatile — which requires DSH's own schemastery, because only
  that build wraps a volatile field in the cosmokit reference the
  loader's volatile commit walks. With the public package a settings
  write reports success and revision 1 while the effective value never
  changes.
- ui-primitives renamed its whole icon family (IconXxx14/16 ->
  IconXxxRegular/Medium): 26 imported symbols, 17 files. The failing
  mode is render-time, not load-time. Five call sites that relied on the
  old 14px default now pass size={14} explicitly.
- Session format v4 rejects source.kind === 'plugin'; the side-chat
  boundary injection now writes plugin:dsh-better-sidebar, the same kind
  DSH's own v3->v4 migration produces, so historical threads survive.
  Tool results moved to role 'tool' with top-level content/isError; all
  four parsers accept both shapes.
- The client session snapshot dropped subagentsByParent/jobsBySession
  and the on-demand subagent-catalog observation API. The Tasks page
  reads projectionsBySession and a new jobs.list route.

Yields and convergence:
- The read-only previews go to DSH's documentpreview (Office->PDF, a
  spreadsheet worker, zoom, per-directory refresh); editor.canOpen
  refuses those 26 extensions. Markdown, HTML (with its sandbox
  switches) and the editable code buffer stay.
- The host browser kind is desktop-profile-only now, so the web e2e
  lanes expect no browser entry and assert its absence.
- Link takeover claims only urlTarget links and passes the rest to the
  host, fixing a regression from the 0.1.6 round: the fallback pointed
  at a 'browser' type the plugin no longer registers, so http links in
  its own markdown did nothing.

Also fixed, found along the way:
- The file tree refreshes live (new directory watch over the plugin's
  own socket), since the plugin owns the `files` kind.
- ctx.sessions.list has no `current` field at either tag; the three
  readers were dead. They now use ctx.sidebarRight.mounted.
- package.json.files was missing lib/client-locale.js.
- Two genuinely dead call sites: sessions.openSubagent/open/
  refreshSubagents do not exist on the current baseline, so the
  topology click, the jump-back and the retry were all no-ops.
- The test harness window shim lacked addEventListener, which the 0.1.7
  primitives call at module-evaluation time; and the e2e token exchange
  needs Playwright's request stack, not Node's fetch.
This commit is contained in:
Menghuan1918
2026-09-22 17:09:08 +08:00
parent de99fd4cfb
commit 6586e3e2d3
96 changed files with 4567 additions and 2080 deletions
+47 -4
View File
@@ -5,9 +5,11 @@
*
* - the `editor` type is both a page (the files window) and a resource
* viewer — it claims `dsh-resource://file/**` at the default `extension`
* band, which outranks the built-in `text` preview (`fallback`) and the
* built-in `files` tree (`builtin`), so every file the product opens lands
* in the plugin's editor;
* band, which outranks the built-in `text` preview (`fallback`), so a file
* the product opens lands in the plugin's editor — EXCEPT the formats
* {@link HOST_OWNED_EXTS} hands back to DSH's own previews, which are
* strictly better for binary documents (host-side Office→PDF conversion,
* a worker-backed spreadsheet table, a zoom viewport);
* - the built-in `files` page kind is TAKEN OVER by an `extension`
* registration of the same kind, so `openTab('files')` draws the plugin's
* explorer instead of the built-in tree; the built-in resumes when this
@@ -34,6 +36,42 @@ import {
type NativeTabRecords,
} from './tab-adapter.tsx'
/**
* Extensions DSH's own previews own, and this plugin therefore refuses.
*
* DSH 0.1.7 grew a real document-preview package (host-side Office→PDF
* conversion, a worker-backed spreadsheet table, image/PDF zoom viewports and
* per-directory auto-refresh) for exactly these formats, while the plugin's
* equivalents are read-only fallbacks. The plugin keeps what the built-in does
* NOT do: Markdown through its own renderer, HTML through its sandboxed route
* with the `htmlViewerNoSandbox` safety valve, and — through the `code`
* catch-all — a genuinely EDITABLE CodeMirror buffer for every text file.
*
* Refusing here is what hands the address over: `canOpen` returning false
* leaves the built-in `text` type (the `fallback` band) as the only claimant.
*/
const HOST_OWNED_EXTS: ReadonlySet<string> = new Set([
// Spreadsheets: the built-in renders a table in a worker.
'xlsx', 'xls', 'xlsb', 'xlt', 'xltx', 'xltm', 'ods', 'ots', 'fods', 'csv', 'tsv',
// PDF: the built-in viewer pages and zooms.
'pdf',
// Images: the built-in viewer adds a zoom viewport.
'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif',
// Office documents: the built-in converts them to PDF host-side.
'doc', 'docx', 'dot', 'dotx', 'ppt', 'pptx',
])
/**
* Whether DSH's own preview owns one path.
* @param path - the address's decoded path.
* @returns true for a {@link HOST_OWNED_EXTS} extension (a dotfile is not one).
*/
function hostOwnedPath(path: string): boolean {
const name = path.slice(path.lastIndexOf('/') + 1)
const dot = name.lastIndexOf('.')
return dot > 0 && HOST_OWNED_EXTS.has(name.slice(dot + 1).toLowerCase())
}
/** The native tab-type registry face (`ctx.sidebarRightTabs`). */
interface NativeTabRegistry {
register(definition: {
@@ -190,7 +228,12 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
...(isEditor
? {
patterns: ['dsh-resource://file/**'],
canOpen: (address: string) => parseFileAddress(address) !== undefined,
canOpen: (address: string) => {
const file = parseFileAddress(address)
// A non-file address, or one of the formats the built-in
// previews own, leaves the address to DSH's own `text` type.
return file !== undefined && !hostOwnedPath(file.path)
},
}
: {}),
// An external implementation outranks the product's own viewers, which
+93 -16
View File
@@ -10,10 +10,13 @@
*
* - the surface exists only while a session's panel is mounted, and the
* service's public face (`ISidebarRight`) writes only into THAT session.
* The controller also carries `openTabIn` / `openResourceIn` /
* `closeIn`, which act on any session whose store the runtime has minted;
* both are probed at call time, and an open for a session that has no
* store yet is QUEUED and replayed when that session comes on screen;
* "Which session that is" comes from the controller's `mounted` observation
* ({@link mountedSessions}) — never from the session list, which has no
* current-session field. The controller also carries `openTabIn` /
* `openResourceIn` / `closeIn`, which act on any session whose store the
* runtime has minted; both are probed at call time, and an open for a
* session that has no store yet is QUEUED and replayed when that session
* comes on screen;
* - layout state is memory-only, so a queued open is not durable either.
*/
import type { Context } from '../../context-types.ts'
@@ -26,11 +29,24 @@ type Pending =
| { kind: 'tab'; sessionId: string; tabKind: string; params: NativeTabParams; revealIfOpened: boolean }
| { kind: 'resource'; sessionId: string; address: string; line: number | undefined; revealIfOpened: boolean }
/**
* The observation "which session's seat is on screen": DSH 0.1.7 publishes it
* as `ISidebarRight.mounted` (`ObservableSnapshot<SessionId | undefined>`, set
* only when the mounted seat really changes). `undefined` means NO seat is
* drawn — a global panel, or a right column that was never mounted.
*/
export interface MountedSessions {
getSnapshot(): string | undefined
subscribe(listener: () => void): () => void
}
/** The controller face this module uses (a structural slice of `ISidebarRight`). */
interface NativeController {
openTab(kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
openResource(address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
close(tabId: string): void
/** The mounted-seat observation (0.1.7 `ISidebarRight.mounted`). */
mounted?: MountedSessions
/** Not part of `ISidebarRight`: the concrete controller's per-session writes. */
openTabIn?(sessionId: string, kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
openResourceIn?(sessionId: string, address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
@@ -41,35 +57,82 @@ interface NativeController {
export interface NativeSurface extends SidebarSurface {
/** Replay opens that were queued for a session that had no mounted surface. */
flushPending(): void
/** Stop observing the session list. */
/** Stop observing the session list and the mounted-seat feed. */
dispose(): void
}
/** The active session id, as the client list reports it. */
function activeSessionId(ctx: Context): string | undefined {
/** The native controller, probed at call time (the service can arrive late). */
function controllerOf(ctx: Context): NativeController | undefined {
try {
return ctx.sessions.list.getSnapshot().current
return ctx.get('sidebarRight') as unknown as NativeController | undefined
} catch {
return undefined
}
}
/**
* The on-screen-session feed, as anything outside this module should read it.
*
* The session-list snapshot carries NO current-session field in any DSH
* release (0.1.6 and 0.1.7 both publish only `ids` / `byId` / `phase` plus
* projections), so a read of one was always `undefined`: `mounted` is the
* only sanctioned source, and the plugin's own type invented the field it
* used to read.
*
* The probe tolerates a host without `mounted` (or a controller the runtime
* has not provided yet — the seat race this plugin already hit once on
* 0.1.5): the session list then doubles as the change pulse, so a late
* service is still picked up on the next list publish instead of never.
*
* @param ctx - the client context.
* @returns the observable face of the mounted seat's session id.
*/
export function mountedSessions(ctx: Context): MountedSessions {
return {
getSnapshot: () => {
try {
const mounted = controllerOf(ctx)?.mounted
return typeof mounted?.getSnapshot === 'function' ? mounted.getSnapshot() : undefined
} catch {
return undefined
}
},
subscribe: (listener) => {
const mounted = controllerOf(ctx)?.mounted
return typeof mounted?.subscribe === 'function'
? mounted.subscribe(listener)
: ctx.sessions.list.subscribe(listener)
},
}
}
/**
* The session whose seat is on screen, or `undefined` — no seat is mounted
* (global panel, column not mounted) or the host has no mounted feed. Callers
* treat `undefined` as "not this session": nothing the plugin draws belongs to
* that surface, so it must neither write into it nor manage its column.
*
* @param ctx - the client context.
* @returns the on-screen session id, when there is one.
*/
export function mountedSessionId(ctx: Context): string | undefined {
return mountedSessions(ctx).getSnapshot()
}
/**
* Bind the plugin's write face to the native controller.
* @param ctx - the client context (session list + `ctx.sidebarRight`).
* @param records - the plugin's native tab record registry.
* @returns the surface, plus a disposer unbinding its session subscription.
* @returns the surface, plus a disposer unbinding its two feed subscriptions.
*/
export function createNativeSurface(ctx: Context, records: NativeTabRecords): NativeSurface {
const pending: Pending[] = []
const controller = (): NativeController | undefined =>
ctx.get('sidebarRight') as unknown as NativeController | undefined
const controller = (): NativeController | undefined => controllerOf(ctx)
const place = (entry: Pending): boolean => {
const api = controller()
if (api === undefined) return false
const active = activeSessionId(ctx)
const onScreen = active !== undefined && active === entry.sessionId
const onScreen = mountedSessionId(ctx) === entry.sessionId
if (entry.kind === 'tab') {
const options = { params: entry.params, revealIfOpened: entry.revealIfOpened }
if (onScreen) {
@@ -109,7 +172,18 @@ export function createNativeSurface(ctx: Context, records: NativeTabRecords): Na
if (!place(entry)) pending.push(entry)
}
const unsubscribe = ctx.sessions.list.subscribe(flushPending)
// Two feeds flush the queue: the mounted seat (a session coming on screen)
// and the session list, which also stays the pulse that picks the native
// service up when it is provided after this surface was created.
let mountedUnsubscribe: (() => void) | undefined
const onListChange = (): void => {
if (mountedUnsubscribe === undefined) {
const mounted = controller()?.mounted
if (typeof mounted?.subscribe === 'function') mountedUnsubscribe = mounted.subscribe(flushPending)
}
flushPending()
}
const unsubscribeList = ctx.sessions.list.subscribe(onListChange)
return {
openTab({ sessionId, kind, params, revealIfOpened }) {
enqueue({ kind: 'tab', sessionId, tabKind: kind, params, revealIfOpened })
@@ -126,7 +200,7 @@ export function createNativeSurface(ctx: Context, records: NativeTabRecords): Na
records.drop(tabId)
const api = controller()
if (api !== undefined) {
if (sessionId === activeSessionId(ctx)) api.close(tabId)
if (sessionId === mountedSessionId(ctx)) api.close(tabId)
else if (api.closeIn !== undefined) api.closeIn(sessionId, tabId)
}
return { type: record.tab.type, title: record.tab.title }
@@ -144,6 +218,9 @@ export function createNativeSurface(ctx: Context, records: NativeTabRecords): Na
},
has: tabId => records.has(tabId),
flushPending,
dispose: () => { unsubscribe() },
dispose: () => {
unsubscribeList()
mountedUnsubscribe?.()
},
}
}