Files
Menghuan1918 0b6ac5d267 feat(dsh): adapt to DSH 0.1.5-alpha.2
Baseline moves to DSH 0.1.5-alpha.2 only (peer floor ^0.1.5-alpha.2,
CI pin, manifest engines); alpha.1 users stay on v0.19.0-alpha.0.

Host-contract changes handled:
- the native guide entry lost its `description` field (entries are
  icon+title capsules now): `TabDescriptor.description` and the six
  `guideDesc*` keys plus the `nativeGuideDesc` fallback are removed.
- the file address grammar changed: `fileAddressFor` always returns a
  session-scoped address (absolute paths keep their leading `/`),
  `parseFileAddress` is prefix-based and strips `?`/`#`. The plugin's
  purity-gated mirror follows.
- global main panels replaced the `conversation` root slot with the
  keyed `main` slot (`main.conversation`): the bottom workbench's
  center-column locator now accepts both keys and skips
  `display: contents` slot hosts.

Fixes a pre-existing fill bug the alpha.2 probe made obvious: the native
tab body host is a block scroller with a definite height, so tab roots
that only declared `flex: 1` collapsed to content height and the side
chat composer drifted off the pane bottom. The native adapter now wraps
every tab body in a `height: 100%` column flex host.

Also promotes the dependencies `dsh-client-ui-primitives@0.1.5-alpha.2`
dropped from its manifest (anser / shiki / mdast-util-* / micromark-* /
katex / @shikijs/langs) and the newly unmet `dsh-session-persistence`
peer into devDependencies.
2026-09-09 23:09:48 +08:00

110 lines
4.6 KiB
TypeScript

/**
* Anchor selectors for the DSH AppFrame conversation slot host, newest first:
*
* - DSH 0.1.5-alpha.2 replaced the root `conversation` slot with a root-scoped
* keyed `main` slot, so the conversation renders under `main.conversation`
* (ConversationPanel -> `renderSlot('main.conversation')`);
* - alpha.1 and earlier spell the same host `conversation`.
*
* Both keys are accepted deliberately so one build spans the alpha.1 → alpha.2
* boundary; only one exists in any given host.
*/
const CENTER_COLUMN_SELECTOR = '#root [data-slot="main.conversation"], #root [data-slot="conversation"]'
/** Match Sidebar's existing last-resort retry cadence (issue #248). */
export const CENTER_COLUMN_REVALIDATE_MS = 1500
interface CenterColumnResolveOptions {
/** Explicit document for tests / callers that do not yet have a cached node. */
document?: Document
/** Injectable DOM lookup for tests. */
query?: () => Element | null
/** Injectable clock for deterministic retry-boundary tests. */
now?: () => number
/** Injectable <html style> fingerprint for deterministic HMR tests. */
htmlStyle?: () => string | null
/** Override only for tests; production follows the 1.5s safety-net cadence. */
revalidateMs?: number
}
/** Last full DOM validation for each center-column node. Weak keys avoid leaks. */
const validatedAt = new WeakMap<HTMLElement, number>()
/**
* The html-style watcher is an HMR/layout resync signal. Remember the last
* fingerprint per document so a style change can bypass the connected-node
* fast path immediately, without requiring Sidebar to run a second locator.
*/
const documentStyleState = new WeakMap<Document, string | null>()
/**
* From the matched conversation slot host, walk up through transparent slot
* hosts to the real column: DSH slot hosts render with `display: contents`,
* so on alpha.2 the `main.conversation` host (and any further keyed slot
* wrappers above it) contributes no box, and the center column is the first
* ancestor whose computed display is NOT `contents`.
*/
function columnOfSlotHost(host: Element): HTMLElement | null {
const view = host.ownerDocument.defaultView
let node = host.parentElement
while (node !== null && (view?.getComputedStyle(node).display ?? '') === 'contents') {
node = node.parentElement
}
return node
}
/**
* Resolve the AppFrame center column while keeping streaming mutations cheap.
*
* Sidebar intentionally keeps both recovery mechanisms that predate #403:
* the `#root` subtree MutationObserver catches boot/HMR DOM swaps, and the
* 1.5s interval is a last-resort safety net for interleavings no observer is
* guaranteed to see (#248). The expensive part was letting every scheduled
* locate scan `#root` with querySelector at streaming-token cadence.
*
* Once a connected column is cached, normal calls therefore reuse it without
* a document query. A full query is still forced when either:
* - the cached node disconnects;
* - `<html style>` changes (the existing HMR/layout resync signal); or
* - 1.5s has elapsed since the last full validation (the safety-net cadence).
*
* This preserves recovery semantics while bounding whole-tree selector work
* to low-frequency validation instead of chat mutation frequency.
*/
export function resolveCenterColumn(
current: HTMLElement | null,
options: CenterColumnResolveOptions = {},
): HTMLElement | undefined {
const doc = options.document ?? current?.ownerDocument ?? document
const now = (options.now ?? Date.now)()
const revalidateMs = options.revalidateMs ?? CENTER_COLUMN_REVALIDATE_MS
const htmlStyle = (options.htmlStyle ?? (() => doc.documentElement.getAttribute('style')))()
const previousStyle = documentStyleState.get(doc)
const styleChanged = previousStyle !== undefined && previousStyle !== htmlStyle
documentStyleState.set(doc, htmlStyle)
if (current !== null && current.isConnected) {
const lastValidation = validatedAt.get(current)
// A connected node passed in for the first time is already a trustworthy
// cache hit. Start its safety-net window without paying an eager query.
if (lastValidation === undefined && !styleChanged) {
validatedAt.set(current, now)
return current
}
if (!styleChanged && lastValidation !== undefined && now - lastValidation < revalidateMs) {
return current
}
}
const query = options.query ?? (() => doc.querySelector(CENTER_COLUMN_SELECTOR))
const host = query()
const col = host === null || host === undefined ? null : columnOfSlotHost(host)
if (col === null || col === undefined || !col.isConnected) return undefined
validatedAt.set(col, now)
return col
}