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
+140
View File
@@ -0,0 +1,140 @@
/**
* Live refresh for the file tree.
*
* The tree lists a folder when it is expanded and caches the level, so
* anything written outside the plugin — a build, a formatter, the model's own
* `bash` — stayed invisible until the reader refreshed. DSH 0.1.7 gave its own
* tree per-directory watching; this plugin owns its tree (it took the `files`
* kind over), so it carries its own.
*
* One socket per session, open for as long as the tree is mounted. The client
* announces the folders it has expanded and the host watches exactly those;
* every notice names a directory whose listing is stale, and the tree drops
* that one cached level and re-lists it. Folders the reader collapsed are
* un-watched, so a long session does not accumulate handles.
*/
import { useEffect, useRef } from 'react'
/** One server frame: a stale directory, a watch verdict, or a refusal. */
interface FsWatchFrame {
dir?: unknown
ok?: unknown
}
/** What the tree hands the hook. */
export interface DirectoryWatchOptions {
/** Session whose workspace is being browsed; no socket while undefined. */
sessionId: string | undefined
/** The workspace root, always watched (the tree always lists it). */
root: string | undefined
/** Expanded directories below the root, in the host's absolute path form. */
dirs: readonly string[]
/**
* A directory whose listing is stale.
* @param dir - absolute directory path, exactly the form the tree keys levels by.
*/
onStale: (dir: string) => void
}
/** How long to wait before re-opening a dropped socket; failures back off to the cap. */
const RETRY_BASE_MS = 1000
const RETRY_MAX_MS = 15_000
/**
* Keep the file tree's expanded folders watched for the component's lifetime.
* @param options - session, root, expanded directories, and the staleness callback.
*/
export function useDirectoryWatch(options: DirectoryWatchOptions): void {
const { sessionId, root, dirs, onStale } = options
// The expanded set is a fresh array on every render, so neither the socket
// effect nor the staleness callback may depend on their identity.
const wantedRef = useRef<string[]>([])
wantedRef.current = root === undefined ? [...dirs] : [root, ...dirs]
const staleRef = useRef(onStale)
staleRef.current = onStale
/** Set by the socket effect; reconciles the host's watch set with the tree's. */
const reconcileRef = useRef<() => void>(() => {})
// One string per expanded-set change: a stable dependency for the effect below.
const wantedKey = wantedRef.current.join('\u0000')
useEffect(() => {
if (sessionId === undefined) return
let socket: WebSocket | null = null
let retry: number | undefined
let closed = false
let failures = 0
/** Directories the host confirmed it is watching (its own resolved paths). */
const watching = new Set<string>()
const send = (op: 'watch' | 'unwatch', path: string): void => {
if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ op, path }))
}
/**
* Bring the host's watch set in line with the tree's expanded set. The
* host answers every `watch` with the directory it resolved, so `watching`
* is the host's own answer rather than a client-side guess.
*/
const reconcile = (): void => {
const wanted = new Set(wantedRef.current)
for (const path of wanted) send('watch', path)
for (const dir of [...watching]) {
if (wanted.has(dir)) continue
send('unwatch', dir)
watching.delete(dir)
}
}
reconcileRef.current = reconcile
const connect = (): void => {
if (closed) return
const url = new URL('/sidebar/ws/fs-watch', location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.search = new URLSearchParams({ sessionId }).toString()
socket = new WebSocket(url.toString())
socket.onopen = () => {
failures = 0
watching.clear()
reconcile()
}
socket.onmessage = (event) => {
if (typeof event.data !== 'string') return
let frame: FsWatchFrame
try {
frame = JSON.parse(event.data) as FsWatchFrame
} catch {
return
}
if (frame === null || typeof frame !== 'object' || typeof frame.dir !== 'string') return
// `ok: true` confirms a watch; `ok: false` refuses one (fenced path,
// watcher cap, unreadable directory) and must NOT re-list. A frame
// carrying only `dir` is the stale notice.
if (frame.ok === true) {
watching.add(frame.dir)
return
}
if (frame.ok === undefined) staleRef.current(frame.dir)
}
socket.onclose = () => {
if (closed) return
failures += 1
retry = window.setTimeout(connect, Math.min(RETRY_BASE_MS * 2 ** (failures - 1), RETRY_MAX_MS))
}
}
connect()
return () => {
closed = true
reconcileRef.current = () => {}
if (retry !== undefined) window.clearTimeout(retry)
socket?.close()
}
}, [sessionId])
// Reconciling on its own effect keeps an expand/collapse from tearing the
// socket down and re-opening it.
useEffect(() => {
void wantedKey
reconcileRef.current()
}, [wantedKey])
}