Merge remote-tracking branch 'origin/master' into feat/send-unify
This commit is contained in:
@@ -14,7 +14,10 @@ export type {
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
// transportError moved down to the apiproxy api layer (it belongs beside
|
||||
// RpcResult, its subject); re-exported here so connection consumers keep one
|
||||
// contract entry point.
|
||||
export { RpcId, transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
@@ -31,16 +34,3 @@ import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export function resultOf<T>(response: RpcResponse<T>): RpcResult<T> {
|
||||
return response.result
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a transport exception into the RpcResult error branch (unified error
|
||||
* surface; 'internal' as the catch-all code).
|
||||
* @param error - the thrown value from the carrier.
|
||||
* @returns the error branch of an RpcResult.
|
||||
*/
|
||||
export function transportError<T>(error: unknown): RpcResult<T> {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
|
||||
}
|
||||
}
|
||||
|
||||
19
packages/client/hmr/README.md
Normal file
19
packages/client/hmr/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# @deepseek-ai/dsh-client-hmr
|
||||
|
||||
Hot reload for fetch-arrival client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
|
||||
|
||||
The plugin subscribes to the webserver's system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame, serialized through a queue (the bundle handoff slot is single). The sequence per frame — `prefetch` (fetch the new bundle before touching anything), `invalidate`, `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. Rebuild detection lives on the webserver: in dev mode it stat-polls each plugin's built `lib/client.js` (`fs.watchFile`) and broadcasts the `rebuilt` frame when the bundle's rev changes, so any tsdown watch process producing the bundle triggers HMR with no builder→host channel.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the reload driver is browser-side machinery; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out.
|
||||
- **No failure rollback** — a reload that fails leaves the entry FAILED and loud in the loader status projection; restoring the previous bundle automatically is deferred until a real need shows.
|
||||
- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless (the bundle endpoint serves no-cache); rev refresh lands with the reconnect-handshake mechanism.
|
||||
51
packages/client/hmr/package.json
Normal file
51
packages/client/hmr/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-hmr",
|
||||
"description": "Dev-only hot-reload driver for fetch-arrival client entries: SSE rebuilt frames → prefetch/invalidate → fiber swap through the vendored Loader entry",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-client-modules": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
]
|
||||
}
|
||||
191
packages/client/hmr/src/client/index.ts
Normal file
191
packages/client/hmr/src/client/index.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* client-hmr, browser half: hot-reload driver for client plugin entries.
|
||||
*
|
||||
* Listens on the host's system SSE channel (`GET /plugins/events`); on a
|
||||
* `rebuilt` frame it re-fetches the entry's bundle and swaps the cordis
|
||||
* fiber in place. Every graph entry is a plugin bundle under the web2 model
|
||||
* — `immediately` rows differ only in stage-one prefetch (a boot
|
||||
* optimization), so all nine plugin packages share these reload semantics;
|
||||
* normal packages (react family, cordis, shell, pure libs) are not entries
|
||||
* and shell changes still mean a page reload. Cascade is zero-touch:
|
||||
* downstream fibers key their activation epoch on provider fiber uids
|
||||
* (vendor/cordis/src/fiber.ts `_refresh`), so replacing a provider fiber
|
||||
* re-cascades natively — reloading a data-layer plugin (connection/runtime)
|
||||
* cascades into its UI dependents with no HMR-side bookkeeping.
|
||||
*
|
||||
* Reload order (lazy CJS table): invalidate (drop the stale factory and
|
||||
* materialized record) → prefetch (fetch + execute + register the fresh
|
||||
* factory) → registry-first teardown → drain old fiber unload → remove
|
||||
* owned `<style data-plugin>` tags → `entry.refresh()` materializes the new
|
||||
* factory. Invalidate MUST precede prefetch: a live factory makes prefetch
|
||||
* a no-op, and re-executing a bundle over an undeleted registration is a
|
||||
* loud duplicate. The swap is safe because execution is pure registration
|
||||
* under the lazy model — every module side effect (CSS injection included)
|
||||
* lives in the factory closure and runs at materialization, inside
|
||||
* refresh(). That also keeps the CSS ordering guarantee: owned styles are
|
||||
* removed after the old fiber's disposers drained (SlotCore one-owner
|
||||
* unregister) and before materialization re-injects tags under the same
|
||||
* stable tag ids.
|
||||
*
|
||||
* Failure window: if prefetch rejects after invalidate, the module is left
|
||||
* unregistered while the OLD fiber keeps running untouched (teardown never
|
||||
* started) — degraded but recoverable, the next rebuilt frame retries from
|
||||
* scratch. Consistent with the v1 no-rollback policy below. Known dev-only
|
||||
* race: a rebuilt frame overlapping a still-in-flight boot arrival shares
|
||||
* that arrival's task and may materialize the pre-rebuild bytes; the next
|
||||
* rebuilt frame self-heals.
|
||||
*
|
||||
* Why not the naive `entry.fiber.dispose()` → `entry.refresh()` path —
|
||||
* confirmed against vendor sources:
|
||||
* 1. `Entry.fiber` is never cleared on dispose (vendor/loader/src/config/
|
||||
* entry.ts assigns it only in `_init`), so `refresh()` hits its
|
||||
* `if (this.fiber) return` guard and no-ops.
|
||||
* 2. A bare `fiber.dispose()` lands in Loader's self-dispose branch
|
||||
* (vendor/loader/src/index.ts `internal/plugin` case 4: the registry
|
||||
* still holds the runtime at emit time), which flags the entry
|
||||
* `disabled: true` — permanently.
|
||||
* vendor/hmr's reload skeleton documents the fix: delete the runtime record
|
||||
* FIRST (`registry.delete` → case 4 returns early, the entry stays enabled),
|
||||
* then rebuild. We additionally clear `entry.fiber` ourselves so
|
||||
* `entry.refresh()` re-imports and re-plugins through the Loader's own
|
||||
* `_init` (entry-resolved config, automatic `fiber.entry` rebinding) instead
|
||||
* of hand-rolling `registry.plugin`. Client entries have exactly one fiber
|
||||
* per runtime, so `registry.delete` never collaterally disposes siblings.
|
||||
*
|
||||
* Self-reload: this plugin is itself a graph entry, so a rebuilt frame may
|
||||
* name it. The in-flight reload keeps running in the old bundle's closure
|
||||
* (its EventSource closes with the old fiber's effects); the new bundle's
|
||||
* apply opens a fresh channel. Frames arriving during the gap are lost —
|
||||
* acceptable for the dev channel, the next rebuild renotifies.
|
||||
*
|
||||
* Failure policy (v1): no rollback. An import failure leaves the entry
|
||||
* fiberless (the next rebuilt frame retries from scratch); an apply failure
|
||||
* leaves a FAILED fiber for the shell's status projection. Both log loudly.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { Entry, Loader } from '@cordisjs/plugin-loader'
|
||||
import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
|
||||
|
||||
/**
|
||||
* Frames on the `GET /plugins/events` system SSE channel (owned host-side by
|
||||
* dsh-host-webserver's PluginEventFrame). Mirrored here because this is a
|
||||
* wire boundary: frames arrive as JSON text and are validated at the parse
|
||||
* point, not shared as a same-process typed seam.
|
||||
*/
|
||||
export type PluginsEventFrame =
|
||||
| { type: 'graph'; graph: WebBootGraph }
|
||||
| { type: 'rebuilt'; id: string; rev: string }
|
||||
|
||||
/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
|
||||
export const EVENTS_ENDPOINT = '/plugins/events'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'client-hmr'
|
||||
|
||||
/** Required services: the vendored Loader (entry governance) and the client module system (boot provide, service name `modules`). */
|
||||
export const inject = ['loader', 'modules']
|
||||
|
||||
/** Find the loader entry whose module specifier is `id` (entry tree ids are random; the package name lives in `options.name`). */
|
||||
function findEntry(loader: Loader, id: string): Entry | undefined {
|
||||
for (const entry of loader.entries()) {
|
||||
if (entry.options.name === id) return entry
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Remove every `<style data-plugin>` tag owned by `id` (attribute compared verbatim — no CSS-selector escaping pitfalls). */
|
||||
function removeOwnedStyles(id: string): void {
|
||||
for (const el of document.querySelectorAll('style[data-plugin]')) {
|
||||
if (el.getAttribute('data-plugin') === id) el.remove()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the HMR driver: subscribe to the system SSE channel and hot-swap
|
||||
* rebuilt entries.
|
||||
* @param ctx - plugin context with `loader` and `modules` available.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
// Both are declared injections (typed Context merges: `modules` from the
|
||||
// client module loader package, `loader` from the vendored Loader).
|
||||
const modLoader = ctx.modules
|
||||
const loader: Loader = ctx.loader
|
||||
|
||||
async function reload(id: string): Promise<void> {
|
||||
const entry = findEntry(loader, id)
|
||||
if (entry === undefined) {
|
||||
ctx.logger.warn(`client-hmr: rebuilt frame for unknown entry "${id}" (not in the loader tree)`)
|
||||
return
|
||||
}
|
||||
// Invalidate first (drop stale factory + record — a live factory makes
|
||||
// prefetch a no-op and re-registration a loud duplicate), then run the
|
||||
// async half while the old fiber still serves: fetch + execute registers
|
||||
// the fresh factory with zero side effects (lazy CJS — module bodies run
|
||||
// at materialization, not execution).
|
||||
modLoader.invalidate(id)
|
||||
await modLoader.prefetch(id)
|
||||
|
||||
const oldFiber = entry.fiber
|
||||
if (oldFiber !== undefined) {
|
||||
// Registry-first teardown (see module comment): the runtime record must
|
||||
// be gone before the fiber's disposer emits internal/plugin, or the
|
||||
// Loader flags the entry disabled.
|
||||
const runtime = oldFiber.runtime
|
||||
if (runtime !== null) entry.ctx.registry.delete(runtime.callback)
|
||||
// Drain the unload: effect disposers (slots, subscriptions) must finish
|
||||
// before the new bundle executes and the new apply re-registers.
|
||||
while (oldFiber.inertia !== undefined) await oldFiber.inertia
|
||||
delete entry.fiber
|
||||
}
|
||||
// Old owned styles go before materialization re-injects them (the CSS
|
||||
// idempotency guard keys on stable tag ids).
|
||||
removeOwnedStyles(id)
|
||||
// Re-init through the entry: fiber cleared above, so refresh() re-imports
|
||||
// — materializing the prefetched factory (CSS injects here) — and
|
||||
// re-plugins under the entry context. Import failures are logged by
|
||||
// Entry._init and leave the entry fiberless (retryable).
|
||||
await entry.refresh()
|
||||
// Surface apply failures loudly (v1: no rollback, FAILED state stays).
|
||||
await entry.fiber?.await()
|
||||
}
|
||||
|
||||
// Serialize reloads: frames can arrive faster than a swap completes, and
|
||||
// interleaved dispose/execute chains would corrupt the single-slot handoff.
|
||||
let queue: Promise<void> = Promise.resolve()
|
||||
const handle = (frame: PluginsEventFrame): void => {
|
||||
switch (frame.type) {
|
||||
case 'rebuilt':
|
||||
queue = queue.then(() => reload(frame.id)).catch((error: unknown) => {
|
||||
ctx.logger.error(`client-hmr: reload of "${frame.id}" failed`)
|
||||
ctx.logger.error(error)
|
||||
})
|
||||
break
|
||||
case 'graph':
|
||||
// Connect-time snapshot, unused in v1. The loader's cached graph rev
|
||||
// goes stale after rebuilds — harmless, since prefetch hits the
|
||||
// network anyway (host serves bundles no-cache); graph rev refresh
|
||||
// lands with the reconnect-handshake mechanism.
|
||||
break
|
||||
default:
|
||||
// Merge-extensible frame union: unknown frame types from newer hosts
|
||||
// are ignored by design.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
ctx.effect(() => {
|
||||
const source = new EventSource(EVENTS_ENDPOINT)
|
||||
source.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
let frame: PluginsEventFrame
|
||||
try {
|
||||
frame = JSON.parse(event.data) as PluginsEventFrame
|
||||
} catch {
|
||||
// Wire boundary: a malformed dev-channel frame is dropped loudly.
|
||||
ctx.logger.warn(`client-hmr: unparseable event frame: ${event.data}`)
|
||||
return
|
||||
}
|
||||
handle(frame)
|
||||
})
|
||||
return () => { source.close() }
|
||||
}, 'client-hmr: event source')
|
||||
}
|
||||
9
packages/client/hmr/src/index.ts
Normal file
9
packages/client/hmr/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* HMR plugin, node half. The package IS a dshClient plugin (dev-only row in
|
||||
* the host graph): the reload driver lives in its client half in full
|
||||
* (src/client/); the empty apply exists so the plugin appears in the host
|
||||
* Loader (lifecycle governance + dshClient discovery).
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for the HMR plugin. */
|
||||
export function apply(): void {}
|
||||
33
packages/client/hmr/src/invariant.ts
Normal file
33
packages/client/hmr/src/invariant.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-hmr`.
|
||||
* @module @deepseek-ai/dsh-client-hmr/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-hmr-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a dev-only reload driver — it consumes the loader
|
||||
* entry tree and module cache but owns no events and no cross-plugin mutable
|
||||
* state; reload correctness (dispose → style removal → re-execute ordering)
|
||||
* is observable only through the assembled browser runtime, not a host-side
|
||||
* event relation.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
14
packages/client/hmr/tests/node-half.spec.ts
Normal file
14
packages/client/hmr/tests/node-half.spec.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Node half of the HMR plugin: an empty apply placeholder (the reload driver
|
||||
* lives in the client half) whose only contract is mounting and disposing
|
||||
* cleanly in the host Loader.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { apply } from '@deepseek-ai/dsh-client-hmr'
|
||||
|
||||
describe('hmr node half', () => {
|
||||
it('apply is a no-op host placeholder', () => {
|
||||
apply()
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
})
|
||||
})
|
||||
30
packages/client/hmr/tsconfig.json
Normal file
30
packages/client/hmr/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"lib": [
|
||||
"ES2024",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../modules"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/hmr/tsdown.config.ts
Normal file
3
packages/client/hmr/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-hmr', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -27,10 +27,6 @@
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^"
|
||||
|
||||
20
packages/client/modules/README.md
Normal file
20
packages/client/modules/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# @deepseek-ai/dsh-client-modules
|
||||
|
||||
Client module system: the browser peer of Node's internal ESM loader, built as a lazy CJS table. The web shell mounts the vendored cordis Loader for entry governance (fiber lifecycle, inject waiting, update/refresh) and injects this package's `ClientModuleLoader` as its `internal` seam — the vendored side's only consumption point is `EntryTree.import`, so replacing `internal` replaces exactly "how plugin code arrives" and nothing else.
|
||||
|
||||
Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → export surface, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `<id>/client` and the bare id name the same surface (a plugin bundle IS its package's client half).
|
||||
|
||||
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook).
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the module loader is browser-side kernel machinery; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (loadCache/edges/invalidate) is shaped for a general module graph so the externalization granularity can change without an interface change.
|
||||
- **No unload bookkeeping of its own** — style removal and fiber teardown ordering live with the HMR driver (`@deepseek-ai/dsh-client-hmr`); the loader only inventories owned style tag ids per record.
|
||||
37
packages/client/modules/package.json
Normal file
37
packages/client/modules/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-modules",
|
||||
"description": "Client module loader: the browser peer of Node's internal ESM loader, consumed by the vendored cordis Loader as its internal seam (resolve/import/loadCache/invalidate over seed table, static registry and fetch bundles)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
175
packages/client/modules/src/index.ts
Normal file
175
packages/client/modules/src/index.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Client module system: the browser peer of Node's internal ESM loader, built
|
||||
* as a lazy CJS table. The vendored cordis Loader consumes this object
|
||||
* through its `internal` seam (the only call site is `EntryTree.import` →
|
||||
* `internal.import`), which keeps entry governance (fiber lifecycle, inject
|
||||
* waiting, update/refresh) entirely on the vendored side while this package
|
||||
* owns code arrival.
|
||||
*
|
||||
* Lazy CJS model (web2 §0): executing a plugin bundle only REGISTERS its
|
||||
* factory (`window.__ModuleLoader__.load({id, factory})`); every module body
|
||||
* side effect — including CSS injection — lives inside the factory closure
|
||||
* and runs at materialization, not at script execution. Materialization
|
||||
* (factory(require) → export surface) happens on first import/require and is
|
||||
* memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
|
||||
* another registered-but-unmaterialized module materializes it recursively,
|
||||
* so load order needs no external sequencing.
|
||||
*
|
||||
* Resolution branch order (import): seed word → shell instance; memoized
|
||||
* record → surface; static registry (shell-own modules, e.g. app-shell) →
|
||||
* module; registered factory → materialize; graph row → fetch + execute +
|
||||
* materialize; anything else → throw (loud — the runtime mirror of the
|
||||
* build-time bundle purity gate). The synchronous `require` handed to
|
||||
* factories walks the same order minus the fetch branch: fetching is async,
|
||||
* so only already-executed bundles can be required — and cross-plugin value
|
||||
* imports are a build error anyway.
|
||||
* @module @deepseek-ai/dsh-client-modules
|
||||
*/
|
||||
|
||||
import { ClientModuleLoaderImpl } from './loader.ts'
|
||||
|
||||
export { ClientModuleLoaderImpl }
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** The client module system the web shell provides at boot (contract C5). */
|
||||
modules: ClientModuleLoader
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One composed client entry pushed by the host (web2 §0 graph row).
|
||||
* `immediately` marks stage-one prefetch; `inject` is informational graph
|
||||
* metadata (the authoritative edges live in each package's dshClient
|
||||
* declaration and reach fibers through entry creation).
|
||||
*
|
||||
* Wire contract, held on both sides: the producing peer lives in
|
||||
* `@deepseek-ai/dsh-host-webserver` (host packages keep zero workspace
|
||||
* dependencies, so neither side imports the other's shape — drift between
|
||||
* the two declarations is a bug against the web2 contract).
|
||||
*/
|
||||
export interface WebBootEntry {
|
||||
/** Entry name == package name (or a shell-owned pseudo id, e.g. app-shell). */
|
||||
id: string
|
||||
/**
|
||||
* Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. Absent only on
|
||||
* shell-owned pseudo rows (app-shell) whose module is statically registered
|
||||
* — a row that is neither fetchable nor static-registered fails loud.
|
||||
*/
|
||||
url?: string
|
||||
/** Bundle content hash (cache-busting consistency anchor); absent with url. */
|
||||
rev?: string
|
||||
/** Package-name dependency edges, informational (preflight display / HMR diffing). */
|
||||
inject?: string[]
|
||||
/** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
|
||||
immediately?: boolean
|
||||
}
|
||||
|
||||
/** The composed client entry graph the host injects as `window.__DSH_BOOT__` (dual-held wire contract — see {@link WebBootEntry}). */
|
||||
export interface WebBootGraph {
|
||||
/** Consistency anchor over the whole graph (content + bundle hashes). */
|
||||
rev: string
|
||||
/** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
|
||||
entries: WebBootEntry[]
|
||||
}
|
||||
|
||||
/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff, contract C6). */
|
||||
export interface ClientPluginHandoff {
|
||||
/** Plugin id (package name) — the registration key; must match the graph row being executed. */
|
||||
id: string
|
||||
/**
|
||||
* Closure factory holding the whole bundle body: receives the synchronous
|
||||
* require bound to the module table and returns the bundle's export
|
||||
* surface. Runs once, at materialization.
|
||||
*/
|
||||
factory: (require: (spec: string) => unknown) => Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Window surface this loader owns (bundle side of the handoff protocol) plus the host-injected graph. */
|
||||
export interface DshWindow {
|
||||
/** Host-composed entry graph, injected before the shell bundle runs. */
|
||||
__DSH_BOOT__?: WebBootGraph
|
||||
/** Bundle registration sink; installed once per page by {@link createClientModuleLoader} (contract C6). */
|
||||
__ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
|
||||
}
|
||||
|
||||
/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph seam, flat today). */
|
||||
export interface ClientModuleRecord {
|
||||
/** Module id (entry name / package name). */
|
||||
id: string
|
||||
/** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */
|
||||
surface: unknown
|
||||
/** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
|
||||
styles: string[]
|
||||
/** Observed `require()` edges (module-graph seam; only table words can appear today). */
|
||||
edges: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* The internal-seam subset the vendored Loader and the client HMR plugin
|
||||
* consume. Mounted on `ctx.loader.internal` by the shell boot and provided
|
||||
* as `ctx.modules` (contract C5).
|
||||
*/
|
||||
export interface ClientModuleLoader {
|
||||
/** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
|
||||
version: 'client'
|
||||
/** Materialized-module registry: id → record. The governance-side read face for entry export surfaces. */
|
||||
loadCache: Map<string, ClientModuleRecord>
|
||||
/**
|
||||
* Internal seam consumed by the vendored Loader's `tree.import`. Resolves
|
||||
* `specifier` through the branch order documented on the module, fetching
|
||||
* and executing a bundle when needed.
|
||||
* @param specifier - module specifier (entry name or table word).
|
||||
* @param parentURL - importer URL (unused — the client module graph is flat).
|
||||
* @param attrs - import attributes (unused; interface parity with Node's seam).
|
||||
* @returns the module's export surface.
|
||||
*/
|
||||
import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
|
||||
/**
|
||||
* Register a shell-own module (app-shell — code that ships inside the shell
|
||||
* bundle and never arrives as a plugin bundle).
|
||||
* @param id - entry name (shell-owned pseudo id).
|
||||
* @param module - the statically imported module namespace.
|
||||
*/
|
||||
registerStatic(id: string, module: unknown): void
|
||||
/**
|
||||
* Stage-one arrival: fetch the entry's bundle and execute it, registering
|
||||
* its factory (no materialization — module side effects wait for import).
|
||||
* No-op for static-registered ids and ids whose factory is already
|
||||
* registered; concurrent calls share one in-flight task. To force a fresh
|
||||
* fetch (HMR), {@link invalidate} first.
|
||||
* @param id - graph entry name.
|
||||
*/
|
||||
prefetch(id: string): Promise<void>
|
||||
/**
|
||||
* Full reset of one module: drop its registered factory, its materialized
|
||||
* record, and any consumed bundle text, so the next prefetch/import
|
||||
* refetches and re-executes (the HMR invalidation hook).
|
||||
* @param id - entry name to invalidate.
|
||||
*/
|
||||
invalidate(id: string): void
|
||||
}
|
||||
|
||||
/** Options for {@link createClientModuleLoader} (assembled by the web shell at boot). */
|
||||
export interface ClientModuleLoaderOptions {
|
||||
/** Host-composed entry graph. */
|
||||
graph: WebBootGraph
|
||||
/** Module-table seed: platform-singleton specifier → shell instance. */
|
||||
staticModules: Record<string, unknown>
|
||||
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
|
||||
fetchBundle?: (url: string) => Promise<string>
|
||||
/**
|
||||
* Bundle execution seam (synchronously performs the load() registration).
|
||||
* Defaults to a <script> element carrying the code.
|
||||
*/
|
||||
executeBundle?: (code: string, url: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the client module system.
|
||||
* @param options - entry graph, module-table staticModules, fetch/execute seams.
|
||||
* @returns the loader the shell mounts as `ctx.loader.internal` and provides as `ctx.modules`.
|
||||
*/
|
||||
export function createClientModuleLoader(options: ClientModuleLoaderOptions): ClientModuleLoader {
|
||||
return new ClientModuleLoaderImpl(options)
|
||||
}
|
||||
34
packages/client/modules/src/invariant.ts
Normal file
34
packages/client/modules/src/invariant.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-modules`.
|
||||
* @module @deepseek-ai/dsh-client-modules/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-modules'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-modules-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the module loader is pre-plugin kernel machinery —
|
||||
* it emits no cordis events (the vendored Loader owns entry lifecycle events)
|
||||
* and its mutable state (loadCache, handoff slot) lives below the plugin
|
||||
* layer where invariant observers cannot mount before it runs; resolve branch
|
||||
* order and handoff discipline are asserted by the web boot specs against the
|
||||
* real execution path.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
239
packages/client/modules/src/loader.ts
Normal file
239
packages/client/modules/src/loader.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* ClientModuleLoaderImpl — the implementation behind the {@link ClientModuleLoader}
|
||||
* seam. The conceptual contract (lazy CJS model, resolution branch order) is
|
||||
* documented on the package module and the public interfaces in `./index.ts`;
|
||||
* this file owns the state tables and the fetch/execute/materialize machinery.
|
||||
*/
|
||||
import type {
|
||||
ClientModuleLoader, ClientModuleLoaderOptions, ClientModuleRecord,
|
||||
ClientPluginHandoff, DshWindow, WebBootEntry,
|
||||
} from './index.ts'
|
||||
|
||||
/** A registered-but-unmaterialized bundle: the factory plus its source URL (diagnostics). */
|
||||
interface RegisteredFactory {
|
||||
factory: ClientPluginHandoff['factory']
|
||||
url: string
|
||||
}
|
||||
|
||||
/** Default bundle fetch seam: same-origin fetch().text(). */
|
||||
const defaultFetchBundle = async (url: string): Promise<string> => {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`client-modules: bundle fetch ${url} answered ${String(res.status)}`)
|
||||
return res.text()
|
||||
}
|
||||
|
||||
/** Default bundle execution seam: a <script> element carrying the code. */
|
||||
const defaultExecuteBundle = (code: string, url: string): void => {
|
||||
const el = document.createElement('script')
|
||||
// Inline execution (not src) so the fetch half stays parallelizable; the
|
||||
// sourceURL comment keeps devtools stack frames attributed to the bundle.
|
||||
el.textContent = `${code}\n//# sourceURL=${url}`
|
||||
document.head.appendChild(el)
|
||||
// Execution is synchronous for inline scripts: the factory is registered by
|
||||
// now, so the node (and its source text) has no further job. Removing it
|
||||
// keeps repeated HMR rebuilds from accumulating dead script nodes.
|
||||
el.remove()
|
||||
}
|
||||
|
||||
const urlOf = (row: WebBootEntry): string => {
|
||||
// url is conditional on the wire (shell-own pseudo rows omit it); those
|
||||
// ids resolve through the static registry and never reach a fetch.
|
||||
if (row.url === undefined) throw new Error(`client-modules: entry "${row.id}" has no bundle url and no static registration`)
|
||||
return row.url
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin bundle IS its package's client half: `<id>/client` (the exports
|
||||
* subpath external bundles emit) and the bare graph id name the same
|
||||
* surface, so table lookups normalize the suffix away.
|
||||
*/
|
||||
const stripClientSuffix = (spec: string): string =>
|
||||
spec.endsWith('/client') ? spec.slice(0, -'/client'.length) : spec
|
||||
|
||||
/**
|
||||
* Claim and inventory the <style> tags a factory injected during
|
||||
* materialization: preset-emitted tags arrive pre-tagged with data-plugin;
|
||||
* any untagged tag is claimed for the materializing plugin (HMR bookkeeping).
|
||||
*/
|
||||
const claimStyles = (id: string): string[] => {
|
||||
if (typeof document === 'undefined') return []
|
||||
for (const el of document.querySelectorAll('style:not([data-plugin])')) {
|
||||
el.setAttribute('data-plugin', id)
|
||||
}
|
||||
const owned: string[] = []
|
||||
for (const el of document.querySelectorAll(`style[data-plugin=${JSON.stringify(id)}]`)) {
|
||||
owned.push(el.getAttribute('data-plugin-css') ?? id)
|
||||
}
|
||||
return owned
|
||||
}
|
||||
|
||||
/**
|
||||
* The client module system: state tables plus the arrival/materialization
|
||||
* machinery implementing {@link ClientModuleLoader} (whose members carry the
|
||||
* seam contract docs). Construction indexes the boot graph and installs the
|
||||
* `window.__ModuleLoader__` registration sink (contract C6) — once per page.
|
||||
*/
|
||||
export class ClientModuleLoaderImpl implements ClientModuleLoader {
|
||||
readonly version = 'client'
|
||||
readonly loadCache = new Map<string, ClientModuleRecord>()
|
||||
|
||||
private readonly seed: Map<string, unknown>
|
||||
private readonly statics = new Map<string, unknown>()
|
||||
private readonly factories = new Map<string, RegisteredFactory>()
|
||||
/** In-flight prefetch (fetch + execute) per id; concurrent callers share it. */
|
||||
private readonly pendingArrival = new Map<string, Promise<void>>()
|
||||
/** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
|
||||
private readonly materializing = new Set<string>()
|
||||
private readonly graphRows = new Map<string, WebBootEntry>()
|
||||
// Execution URL of the bundle currently being executed (bound into the
|
||||
// factory registration so diagnostics can name the source).
|
||||
private executingUrl = ''
|
||||
// Graph id of the row currently being executed ('' outside arrive):
|
||||
// the load sink cross-checks the handoff id against it so a mis-stamped
|
||||
// bundle cannot register under another entry's identity.
|
||||
private executingId = ''
|
||||
|
||||
private readonly fetchBundle: (url: string) => Promise<string>
|
||||
private readonly executeBundle: (code: string, url: string) => void
|
||||
|
||||
/**
|
||||
* Build the module system over the host graph.
|
||||
* @param options - entry graph, module-table staticModules, fetch/execute seams.
|
||||
*/
|
||||
constructor(options: ClientModuleLoaderOptions) {
|
||||
this.seed = new Map(Object.entries(options.staticModules))
|
||||
this.fetchBundle = options.fetchBundle ?? defaultFetchBundle
|
||||
this.executeBundle = options.executeBundle ?? defaultExecuteBundle
|
||||
|
||||
for (const entry of options.graph.entries) {
|
||||
if (this.graphRows.has(entry.id)) throw new Error(`client-modules: duplicate graph entry "${entry.id}"`)
|
||||
this.graphRows.set(entry.id, entry)
|
||||
}
|
||||
|
||||
const win = globalThis as DshWindow
|
||||
if (win.__ModuleLoader__ !== undefined) throw new Error('client-modules: window.__ModuleLoader__ already installed (double boot?)')
|
||||
win.__ModuleLoader__ = {
|
||||
load: (handoff: ClientPluginHandoff): void => {
|
||||
// Registration is keyed by the handoff id; a duplicate means a bundle
|
||||
// executed twice without an invalidate — always a bug, always loud.
|
||||
if (this.factories.has(handoff.id)) throw new Error(`client-modules: duplicate factory registration for "${handoff.id}" (bundle executed twice without invalidate?)`)
|
||||
// A fetched row's bundle must register the id its row names — a
|
||||
// mis-stamped bundle registering under another entry's identity
|
||||
// would let that entry silently materialize foreign exports.
|
||||
if (this.executingId !== '' && handoff.id !== this.executingId) {
|
||||
throw new Error(`client-modules: bundle ${this.executingUrl} registered "${handoff.id}" while arriving for "${this.executingId}" (mis-stamped bundle id)`)
|
||||
}
|
||||
this.factories.set(handoff.id, { factory: handoff.factory, url: this.executingUrl })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch + execute one graph row so its factory is registered (idempotent per in-flight arrival). */
|
||||
private arrive(row: WebBootEntry): Promise<void> {
|
||||
const { id } = row
|
||||
const pending = this.pendingArrival.get(id)
|
||||
if (pending !== undefined) return pending
|
||||
if (this.factories.has(id)) return Promise.resolve()
|
||||
const task = (async (): Promise<void> => {
|
||||
const url = urlOf(row)
|
||||
const code = await this.fetchBundle(url)
|
||||
this.executingUrl = url
|
||||
this.executingId = id
|
||||
try {
|
||||
this.executeBundle(code, url)
|
||||
} finally {
|
||||
this.executingUrl = ''
|
||||
this.executingId = ''
|
||||
}
|
||||
if (!this.factories.has(id)) {
|
||||
throw new Error(`client-modules: bundle ${url} executed without registering "${id}" via __ModuleLoader__.load`)
|
||||
}
|
||||
})().finally(() => { this.pendingArrival.delete(id) })
|
||||
this.pendingArrival.set(id, task)
|
||||
return task
|
||||
}
|
||||
|
||||
/** Materialize a registered factory (synchronous; memoized in loadCache). */
|
||||
private materialize(id: string): ClientModuleRecord {
|
||||
const existing = this.loadCache.get(id)
|
||||
if (existing !== undefined) return existing
|
||||
const registered = this.factories.get(id)
|
||||
/* v8 ignore next -- callers check the factory branch before dispatching here. */
|
||||
if (registered === undefined) throw new Error(`client-modules: no registered factory for "${id}"`)
|
||||
if (this.materializing.has(id)) {
|
||||
throw new Error(`client-modules: require cycle through "${id}" (factory-form CJS cannot deliver partial exports)`)
|
||||
}
|
||||
this.materializing.add(id)
|
||||
try {
|
||||
const edges = new Set<string>()
|
||||
const surface = registered.factory(this.makeRequire(edges))
|
||||
const record: ClientModuleRecord = { id, surface, styles: claimStyles(id), edges }
|
||||
this.loadCache.set(id, record)
|
||||
return record
|
||||
} finally {
|
||||
this.materializing.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The synchronous require answered to factories: seed → static → memoized
|
||||
* record → registered factory (recursive materialization — this is what
|
||||
* makes load order self-resolving). Fetching is async and therefore
|
||||
* unreachable from here; an unregistered plugin specifier is loud (and a
|
||||
* cross-plugin value import is already a build error upstream).
|
||||
*/
|
||||
private makeRequire(edges: Set<string>): (spec: string) => unknown {
|
||||
return (spec: string): unknown => {
|
||||
edges.add(spec)
|
||||
if (this.seed.has(spec)) return this.seed.get(spec)
|
||||
if (this.statics.has(spec)) return this.statics.get(spec)
|
||||
const id = stripClientSuffix(spec)
|
||||
const record = this.loadCache.get(id)
|
||||
if (record !== undefined) return record.surface
|
||||
if (this.factories.has(id)) return this.materialize(id).surface
|
||||
throw new Error(
|
||||
`client-modules: require("${spec}") missed the module table — not a platform seed word, not a shell-own module, `
|
||||
+ 'and no registered factory (a build-time externals drift, or a forbidden cross-plugin value import)',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async import(specifier: string): Promise<unknown> {
|
||||
if (this.seed.has(specifier)) return this.seed.get(specifier)
|
||||
const existing = this.loadCache.get(specifier)
|
||||
if (existing !== undefined) return existing.surface
|
||||
if (this.statics.has(specifier)) {
|
||||
const surface = this.statics.get(specifier)
|
||||
this.loadCache.set(specifier, { id: specifier, surface, styles: [], edges: new Set() })
|
||||
return surface
|
||||
}
|
||||
if (!this.factories.has(specifier)) {
|
||||
const row = this.graphRows.get(specifier)
|
||||
if (row === undefined) {
|
||||
throw new Error(
|
||||
`client-modules: cannot resolve "${specifier}" — not a seed word, not a shell-own module, `
|
||||
+ 'and not a row in the boot graph (the runtime mirror of the bundle purity gate)',
|
||||
)
|
||||
}
|
||||
await this.arrive(row)
|
||||
}
|
||||
return this.materialize(specifier).surface
|
||||
}
|
||||
|
||||
registerStatic(id: string, module: unknown): void {
|
||||
if (this.statics.has(id)) throw new Error(`client-modules: shell-own module "${id}" registered twice`)
|
||||
this.statics.set(id, module)
|
||||
}
|
||||
|
||||
async prefetch(id: string): Promise<void> {
|
||||
if (this.statics.has(id)) return
|
||||
const row = this.graphRows.get(id)
|
||||
if (row === undefined) throw new Error(`client-modules: prefetch("${id}") — not a graph entry`)
|
||||
await this.arrive(row)
|
||||
}
|
||||
|
||||
invalidate(id: string): void {
|
||||
this.factories.delete(id)
|
||||
this.loadCache.delete(id)
|
||||
}
|
||||
}
|
||||
306
packages/client/modules/tests/loader.spec.ts
Normal file
306
packages/client/modules/tests/loader.spec.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ClientModuleLoaderImpl behavior: lazy CJS arrival (bundle execution only
|
||||
* registers the factory), materialization on first import/require with
|
||||
* memoization and recursive self-sequencing, the resolution branch order,
|
||||
* shared in-flight arrival, invalidate-refetch (HMR), style claiming, the
|
||||
* default transport seams, and the loud failure modes (duplicate
|
||||
* registration, cycles, table misses, double boot).
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ClientModuleLoaderImpl, createClientModuleLoader,
|
||||
type ClientModuleLoader, type ClientPluginHandoff, type DshWindow, type WebBootEntry,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const win = globalThis as DshWindow
|
||||
|
||||
type Factory = ClientPluginHandoff['factory']
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
delete win.__ModuleLoader__
|
||||
delete (document as unknown as Record<string, unknown>).__realmBridge
|
||||
for (const el of document.querySelectorAll('style, script')) el.remove()
|
||||
})
|
||||
|
||||
const row = (id: string): WebBootEntry => ({ id, url: `/plugins/${id}/client.js?rev=0` })
|
||||
|
||||
interface Bench {
|
||||
loader: ClientModuleLoader
|
||||
fetched: string[]
|
||||
gates: Map<string, () => void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Loader over scripted bundles: fetch resolves to the row url (optionally
|
||||
* gated on a release callback); execute registers the scripted factory
|
||||
* through the window sink (`null` scripts a bundle that never calls load).
|
||||
*/
|
||||
function bench(
|
||||
entries: WebBootEntry[],
|
||||
bundles: Record<string, Factory | null> = {},
|
||||
opts: { seed?: Record<string, unknown>; gated?: string[] } = {},
|
||||
): Bench {
|
||||
const fetched: string[] = []
|
||||
const gates = new Map<string, () => void>()
|
||||
const loader = createClientModuleLoader({
|
||||
graph: { rev: 'test', entries },
|
||||
staticModules: opts.seed ?? {},
|
||||
fetchBundle: (url) => {
|
||||
fetched.push(url)
|
||||
if (opts.gated?.includes(url) === true) {
|
||||
return new Promise((resolve) => { gates.set(url, () => { resolve(url) }) })
|
||||
}
|
||||
return Promise.resolve(url)
|
||||
},
|
||||
executeBundle: (code) => {
|
||||
const id = /\/plugins\/(.+)\/client\.js/.exec(code)?.[1]
|
||||
const factory = id === undefined ? undefined : bundles[id]
|
||||
if (factory == null || id === undefined) return
|
||||
win.__ModuleLoader__?.load({ id, factory })
|
||||
},
|
||||
})
|
||||
return { loader, fetched, gates }
|
||||
}
|
||||
|
||||
describe('lazy CJS arrival', () => {
|
||||
it('prefetch fetches and executes but does not run the factory', async () => {
|
||||
const ran: string[] = []
|
||||
const b = bench([row('a')], { a: () => { ran.push('a'); return {} } })
|
||||
await b.loader.prefetch('a')
|
||||
expect(b.fetched).toEqual(['/plugins/a/client.js?rev=0'])
|
||||
expect(ran).toEqual([])
|
||||
expect(b.loader.loadCache.size).toBe(0)
|
||||
})
|
||||
|
||||
it('import materializes once and memoizes the export surface', async () => {
|
||||
const ran: string[] = []
|
||||
const b = bench([row('a')], { a: () => { ran.push('a'); return { marker: 'a' } } })
|
||||
const first = await b.loader.import('a', '', {})
|
||||
const second = await b.loader.import('a', '', {})
|
||||
expect(first).toBe(second)
|
||||
expect((first as { marker: string }).marker).toBe('a')
|
||||
expect(ran).toEqual(['a'])
|
||||
expect(b.loader.loadCache.get('a')?.id).toBe('a')
|
||||
})
|
||||
|
||||
it('import without prefetch fetches, executes, and materializes in one call', async () => {
|
||||
const b = bench([row('a')], { a: () => ({ marker: 'direct' }) })
|
||||
const surface = await b.loader.import('a', '', {})
|
||||
expect((surface as { marker: string }).marker).toBe('direct')
|
||||
expect(b.fetched).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('concurrent callers share one in-flight arrival and materialize once', async () => {
|
||||
const ran: string[] = []
|
||||
const url = '/plugins/a/client.js?rev=0'
|
||||
const b = bench([row('a')], { a: () => { ran.push('a'); return { marker: 'a' } } }, { gated: [url] })
|
||||
const first = b.loader.import('a', '', {})
|
||||
const second = b.loader.import('a', '', {})
|
||||
const third = b.loader.prefetch('a')
|
||||
b.gates.get(url)?.()
|
||||
const [s1, s2] = await Promise.all([first, second, third])
|
||||
expect(s1).toBe(s2)
|
||||
expect(b.fetched).toEqual([url])
|
||||
expect(ran).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('prefetch after registration is a no-op without invalidate', async () => {
|
||||
const b = bench([row('a')], { a: () => ({}) })
|
||||
await b.loader.prefetch('a')
|
||||
await b.loader.prefetch('a')
|
||||
expect(b.fetched).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('require resolution', () => {
|
||||
it('a factory requiring a registered-but-unmaterialized module materializes it recursively', async () => {
|
||||
const order: string[] = []
|
||||
const b = bench([row('a'), row('b')], {
|
||||
a: (req) => {
|
||||
order.push('a')
|
||||
const dep = req('b/client') as { helper: string }
|
||||
return { got: dep.helper }
|
||||
},
|
||||
b: () => { order.push('b'); return { helper: 'from-b' } },
|
||||
})
|
||||
await b.loader.prefetch('a')
|
||||
await b.loader.prefetch('b')
|
||||
const surface = await b.loader.import('a', '', {})
|
||||
expect((surface as { got: string }).got).toBe('from-b')
|
||||
expect(order).toEqual(['a', 'b'])
|
||||
expect(b.loader.loadCache.get('a')?.edges.has('b/client')).toBe(true)
|
||||
expect(b.loader.loadCache.has('b')).toBe(true)
|
||||
})
|
||||
|
||||
it('require prefers the platform seed word over the module table', async () => {
|
||||
const react = { marker: 'react' }
|
||||
const b = bench([row('a')], {
|
||||
a: req => ({ dep: req('react') }),
|
||||
}, { seed: { react } })
|
||||
const surface = await b.loader.import('a', '', {})
|
||||
expect((surface as { dep: unknown }).dep).toBe(react)
|
||||
expect(await b.loader.import('react', '', {})).toBe(react)
|
||||
expect(b.loader.loadCache.has('react')).toBe(false)
|
||||
})
|
||||
|
||||
it('require answers an already-materialized module from the cache', async () => {
|
||||
let built = 0
|
||||
const b = bench([row('a'), row('c')], {
|
||||
a: req => ({ dep: req('c') }),
|
||||
c: () => { built += 1; return { marker: 'c' } },
|
||||
})
|
||||
const c = await b.loader.import('c', '', {})
|
||||
const a = await b.loader.import('a', '', {})
|
||||
expect((a as { dep: unknown }).dep).toBe(c)
|
||||
expect(built).toBe(1)
|
||||
})
|
||||
|
||||
it('a require that misses the module table is loud', async () => {
|
||||
const b = bench([row('a')], { a: req => ({ dep: req('ghost') }) })
|
||||
await expect(b.loader.import('a', '', {})).rejects.toThrow('require("ghost") missed the module table')
|
||||
})
|
||||
|
||||
it('a require cycle is fatal', async () => {
|
||||
const b = bench([row('a'), row('b')], {
|
||||
a: req => ({ dep: req('b') }),
|
||||
b: req => ({ dep: req('a') }),
|
||||
})
|
||||
await b.loader.prefetch('b')
|
||||
await expect(b.loader.import('a', '', {})).rejects.toThrow('require cycle through "a"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('static registry', () => {
|
||||
it('serves shell-own modules to import and require without any fetch', async () => {
|
||||
const shell = { marker: 'app-shell' }
|
||||
const b = bench([row('a'), { id: 'app-shell' }], {
|
||||
a: req => ({ dep: req('app-shell') }),
|
||||
})
|
||||
b.loader.registerStatic('app-shell', shell)
|
||||
await b.loader.prefetch('app-shell')
|
||||
expect(await b.loader.import('app-shell', '', {})).toBe(shell)
|
||||
expect(b.loader.loadCache.get('app-shell')?.styles).toEqual([])
|
||||
expect((await b.loader.import('a', '', {}) as { dep: unknown }).dep).toBe(shell)
|
||||
expect(b.fetched).toEqual(['/plugins/a/client.js?rev=0'])
|
||||
})
|
||||
|
||||
it('duplicate static registration is loud', () => {
|
||||
const b = bench([])
|
||||
b.loader.registerStatic('app-shell', {})
|
||||
expect(() => { b.loader.registerStatic('app-shell', {}) }).toThrow('registered twice')
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure modes', () => {
|
||||
it('duplicate factory registration is loud', () => {
|
||||
bench([])
|
||||
win.__ModuleLoader__?.load({ id: 'x', factory: () => ({}) })
|
||||
expect(() => win.__ModuleLoader__?.load({ id: 'x', factory: () => ({}) }))
|
||||
.toThrow('duplicate factory registration for "x"')
|
||||
})
|
||||
|
||||
it('a bundle that never registers its id is loud', async () => {
|
||||
const b = bench([row('a')], { a: null })
|
||||
await expect(b.loader.import('a', '', {})).rejects.toThrow('without registering "a"')
|
||||
})
|
||||
|
||||
it('an unknown import specifier is loud', async () => {
|
||||
const b = bench([])
|
||||
await expect(b.loader.import('nope', '', {})).rejects.toThrow('cannot resolve "nope"')
|
||||
})
|
||||
|
||||
it('an unknown prefetch id is loud', async () => {
|
||||
const b = bench([])
|
||||
await expect(b.loader.prefetch('nope')).rejects.toThrow('prefetch("nope") — not a graph entry')
|
||||
})
|
||||
|
||||
it('a graph row with no url and no static registration is loud', async () => {
|
||||
const b = bench([{ id: 'ghost' }])
|
||||
await expect(b.loader.import('ghost', '', {})).rejects.toThrow('no bundle url and no static registration')
|
||||
})
|
||||
|
||||
it('a duplicate graph entry is loud at construction', () => {
|
||||
expect(() => bench([row('a'), row('a')])).toThrow('duplicate graph entry "a"')
|
||||
})
|
||||
|
||||
it('double boot is loud', () => {
|
||||
bench([])
|
||||
expect(() => new ClientModuleLoaderImpl({ graph: { rev: 't', entries: [] }, staticModules: {} }))
|
||||
.toThrow('already installed (double boot?)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HMR reset', () => {
|
||||
it('invalidate drops the factory and record so the module refetches and re-registers', async () => {
|
||||
let generation = 0
|
||||
const b = bench([row('a')], { a: () => ({ generation: ++generation }) })
|
||||
const first = await b.loader.import('a', '', {})
|
||||
b.loader.invalidate('a')
|
||||
expect(b.loader.loadCache.has('a')).toBe(false)
|
||||
await b.loader.prefetch('a')
|
||||
const second = await b.loader.import('a', '', {})
|
||||
expect(b.fetched).toHaveLength(2)
|
||||
expect((first as { generation: number }).generation).toBe(1)
|
||||
expect((second as { generation: number }).generation).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('style claiming', () => {
|
||||
it('claims untagged style tags for the materializing plugin and inventories owned css ids', async () => {
|
||||
const foreign = document.createElement('style')
|
||||
foreign.setAttribute('data-plugin', 'other')
|
||||
document.head.appendChild(foreign)
|
||||
const b = bench([row('a')], {
|
||||
a: () => {
|
||||
document.head.appendChild(document.createElement('style'))
|
||||
const tagged = document.createElement('style')
|
||||
tagged.setAttribute('data-plugin', 'a')
|
||||
tagged.setAttribute('data-plugin-css', 'sheet-1')
|
||||
document.head.appendChild(tagged)
|
||||
return {}
|
||||
},
|
||||
})
|
||||
await b.loader.import('a', '', {})
|
||||
expect(b.loader.loadCache.get('a')?.styles).toEqual(['a', 'sheet-1'])
|
||||
expect(document.querySelectorAll('style[data-plugin="a"]')).toHaveLength(2)
|
||||
expect(foreign.getAttribute('data-plugin')).toBe('other')
|
||||
})
|
||||
|
||||
it('materialization without a document skips the style inventory', async () => {
|
||||
const b = bench([row('a')], { a: () => ({}) })
|
||||
vi.stubGlobal('document', undefined)
|
||||
try {
|
||||
await b.loader.import('a', '', {})
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
expect(b.loader.loadCache.get('a')?.styles).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('default transport seams', () => {
|
||||
it('fetches same-origin and executes through an inline script tag', async () => {
|
||||
// In a browser the loader's globalThis IS the page window; vitest's jsdom
|
||||
// evaluates <script> in a separate realm that shares only the document,
|
||||
// so the fixture bundle restores the sink from a document bridge before
|
||||
// using the normal calling convention.
|
||||
const code = 'window.__ModuleLoader__ = document.__realmBridge;\n'
|
||||
+ 'window.__ModuleLoader__.load({ id: "dee", factory: function () { return { marker: "via-script" } } })'
|
||||
vi.stubGlobal('fetch', async () => ({ ok: true, text: async () => code }))
|
||||
const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
|
||||
;(document as unknown as Record<string, unknown>).__realmBridge = win.__ModuleLoader__
|
||||
const surface = await loader.import('dee', '', {})
|
||||
expect((surface as { marker: string }).marker).toBe('via-script')
|
||||
// The script node is removed right after its synchronous execution —
|
||||
// repeated HMR rebuilds must not accumulate dead script nodes.
|
||||
expect([...document.querySelectorAll('script')]).toEqual([])
|
||||
})
|
||||
|
||||
it('a non-ok bundle response is loud with the status', async () => {
|
||||
vi.stubGlobal('fetch', async () => ({ ok: false, status: 404 }))
|
||||
const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
|
||||
await expect(loader.prefetch('dee')).rejects.toThrow('answered 404')
|
||||
})
|
||||
})
|
||||
24
packages/client/modules/tsconfig.json
Normal file
24
packages/client/modules/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"lib": [
|
||||
"ES2024",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-runtime",
|
||||
"description": "Client cordis boot and core services: SlotsService, SessionsService (scope tree + object layer), ClientLoader",
|
||||
"description": "Client core services: SlotsService, SessionsService (scope tree + object layer)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -15,10 +15,6 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./loader": {
|
||||
"types": "./lib/types/client/loader/index.d.ts",
|
||||
"default": "./lib/loader.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
@@ -37,6 +33,7 @@
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"immer": "^10.1.1",
|
||||
@@ -56,7 +53,6 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/loader.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
|
||||
@@ -2,17 +2,15 @@
|
||||
* Browser half: the whole runtime contract surface (api-contracts v3 §4) —
|
||||
* SlotsService (declaration ledger + renderer seam + store axis, built-in
|
||||
* 'root'), SessionsService (list store + current selection + scope tree +
|
||||
* object layer), the ClientLoader interface, and the cordis Context/Events
|
||||
* merges. apply
|
||||
* mounts ctx.slots + ctx.sessions and wires the connection stream loop into
|
||||
* the object layer. The loader machinery implementation is NOT in the plugin
|
||||
* bundle — it ships via the package's `./loader` subpath, statically held by
|
||||
* the web shell (a loader cannot load itself).
|
||||
* object layer), and the cordis Context/Events merges. apply mounts
|
||||
* ctx.slots + ctx.sessions and wires the connection stream loop into the
|
||||
* object layer. A static-arrival entry: the web shell bundles this module
|
||||
* and mounts it through the host graph (module loading lives in
|
||||
* @deepseek-ai/dsh-client-modules, entry governance in the vendored Loader).
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore } from './contract/store.ts'
|
||||
import { SlotsService } from './slots.ts'
|
||||
import { SessionsService } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
@@ -95,48 +93,9 @@ declare module 'cordis' {
|
||||
interface Context {
|
||||
slots: import('./slots.ts').SlotsService
|
||||
sessions: import('./sessions/service.ts').SessionsService
|
||||
loader: ClientLoader
|
||||
}
|
||||
}
|
||||
|
||||
/** One __DSH_BOOT__ manifest row. */
|
||||
export interface BootPluginEntry { id: string; url: string; inject: string[]; immediately?: boolean }
|
||||
|
||||
/** Per-plugin load status store shape. */
|
||||
export type LoaderStatus = Record<string, 'loading' | 'active' | 'failed'>
|
||||
|
||||
/**
|
||||
* Client bundle loader. The immediately group loads first (parallel fetch,
|
||||
* apply in inject topology order); remaining plugins follow in inject
|
||||
* topology. Loaded bundle export surfaces are registered back into the
|
||||
* require module table. Implementation lives in the `./loader` subpath
|
||||
* (shell-held machinery).
|
||||
*/
|
||||
export interface ClientLoader {
|
||||
/** Start loading from window.__DSH_BOOT__ (non-blocking). */
|
||||
start(): void
|
||||
/**
|
||||
* Load one plugin bundle (script inject, factory handoff, ctx.plugin, style registration).
|
||||
* @param id - plugin id (package name).
|
||||
*/
|
||||
load(id: string): Promise<void>
|
||||
/**
|
||||
* Unload a plugin. P-I: not implemented (full chain lands with HMR).
|
||||
* @param id - plugin id.
|
||||
*/
|
||||
unload(id: string): Promise<void>
|
||||
/** Resolves when every manifest plugin reached active (AppRoot gates the real UI on this). */
|
||||
settled(): Promise<void>
|
||||
/**
|
||||
* Read a loaded module's export surface from the module table (same
|
||||
* implementation the bundle-facing require uses; unknown spec throws).
|
||||
* @param spec - module specifier (package name or seeded library id).
|
||||
*/
|
||||
requireModule(spec: string): unknown
|
||||
/** Per-plugin status store. */
|
||||
readonly status: SnapshotStore<LoaderStatus>
|
||||
}
|
||||
|
||||
/** Required services: the wire handle mounted by the connection plugin. */
|
||||
export const inject = ['connection']
|
||||
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
/**
|
||||
* ClientLoader implementation (shell-held machinery — the loader cannot load
|
||||
* itself, so the web shell imports this subpath statically and mounts the
|
||||
* instance as ctx.loader; the runtime package's own client bundle never
|
||||
* includes it).
|
||||
*
|
||||
* Load chain per plugin: fetch bundle text → execute (script injection) → the
|
||||
* bundle calls window.DSHClientProxy.loadPlugin({id, factory}) (single-slot
|
||||
* handoff, id reconciled) → factory(require) with require bound to the module
|
||||
* table → ctx.plugin(exports.apply) → the export surface is registered into
|
||||
* the module table under the plugin id (inject topology guarantees later
|
||||
* loaders can require earlier ones) → <style data-plugin> ownership recorded.
|
||||
*
|
||||
* start(): the `immediately` group is fetched in parallel and executed in
|
||||
* group-internal inject topology (execution is serial — the handoff slot is
|
||||
* single); a full-group barrier precedes the remaining plugins, which then
|
||||
* load one by one in inject topology.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
|
||||
|
||||
export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
|
||||
|
||||
/** The shape a client bundle hands to window.DSHClientProxy.loadPlugin. */
|
||||
export interface ClientPluginHandoff {
|
||||
/** Plugin id (package name) — must match the manifest row being loaded. */
|
||||
id: string
|
||||
/**
|
||||
* Closure factory: receives the DI require and returns the module's export
|
||||
* surface; an `apply` export is applied as a cordis plugin.
|
||||
*/
|
||||
factory: (require: (spec: string) => unknown) => Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Window surface the loader owns (bundle side of the handoff protocol). */
|
||||
interface DshWindow {
|
||||
__DSH_BOOT__?: { plugins: BootPluginEntry[] }
|
||||
DSHClientProxy?: { loadPlugin(handoff: ClientPluginHandoff): void }
|
||||
}
|
||||
|
||||
/** Options for createClientLoader (assembled by the web shell at boot). */
|
||||
export interface ClientLoaderOptions {
|
||||
/** Client root context: plugin applies mount under it. */
|
||||
ctx: Context
|
||||
/**
|
||||
* Seeded module table: pure-library entities (react, react-dom, cordis,
|
||||
* ui-slots, web-react, ui-primitives). The loader takes ownership and
|
||||
* registers loaded bundle export surfaces alongside them.
|
||||
*/
|
||||
modules: Record<string, unknown>
|
||||
/**
|
||||
* Boot manifest; defaults to window.__DSH_BOOT__. Fixture pages inject the
|
||||
* same protocol shape.
|
||||
*/
|
||||
boot?: { plugins: BootPluginEntry[] }
|
||||
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
|
||||
fetchBundle?: (url: string) => Promise<string>
|
||||
/**
|
||||
* Bundle execution seam (serial half; execution synchronously performs the
|
||||
* loadPlugin handoff). Defaults to a <script> element carrying the code.
|
||||
*/
|
||||
executeBundle?: (code: string, url: string) => void
|
||||
}
|
||||
|
||||
/** Per-plugin bookkeeping across the load chain. */
|
||||
interface PluginRecord {
|
||||
entry: BootPluginEntry
|
||||
state: 'idle' | 'loading' | 'active' | 'failed'
|
||||
fetch?: Promise<string>
|
||||
load?: Promise<void>
|
||||
}
|
||||
|
||||
const NOT_LOADED = Symbol('dsh.loader.not-loaded')
|
||||
|
||||
/**
|
||||
* Build the client bundle loader.
|
||||
* @param options - ctx, seeded module table, boot manifest, fetch/execute seams.
|
||||
* @returns the ClientLoader the shell mounts as ctx.loader.
|
||||
*/
|
||||
export function createClientLoader(options: ClientLoaderOptions): ClientLoader {
|
||||
const { ctx } = options
|
||||
const win = globalThis as DshWindow
|
||||
const boot = options.boot ?? win.__DSH_BOOT__
|
||||
if (boot === undefined) throw new Error('client-loader: no boot manifest (window.__DSH_BOOT__ missing)')
|
||||
|
||||
const modules = new Map<string, unknown>(Object.entries(options.modules))
|
||||
const records = new Map<string, PluginRecord>()
|
||||
for (const entry of boot.plugins) {
|
||||
if (records.has(entry.id)) throw new Error(`client-loader: duplicate manifest id "${entry.id}"`)
|
||||
records.set(entry.id, { entry, state: 'idle' })
|
||||
}
|
||||
|
||||
const status = createSnapshotStore<LoaderStatus>({})
|
||||
const publish = (id: string, state: 'loading' | 'active' | 'failed'): void => {
|
||||
status.update((draft) => { draft[id] = state })
|
||||
}
|
||||
|
||||
// Single-slot handoff: bundle execution synchronously calls loadPlugin;
|
||||
// doLoad arms the slot before executing and reconciles the id after.
|
||||
let slot: ClientPluginHandoff | typeof NOT_LOADED = NOT_LOADED
|
||||
if (win.DSHClientProxy !== undefined) throw new Error('client-loader: window.DSHClientProxy already installed (double boot?)')
|
||||
win.DSHClientProxy = {
|
||||
loadPlugin: (handoff: ClientPluginHandoff): void => {
|
||||
if (slot !== NOT_LOADED) {
|
||||
throw new Error(`client-loader: overlapping loadPlugin handoff (got "${handoff.id}" while a previous handoff is unclaimed)`)
|
||||
}
|
||||
slot = handoff
|
||||
},
|
||||
}
|
||||
|
||||
const fetchBundle = options.fetchBundle ?? (async (url: string): Promise<string> => {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`client-loader: bundle fetch ${url} answered ${String(res.status)}`)
|
||||
return res.text()
|
||||
})
|
||||
|
||||
const executeBundle = options.executeBundle ?? ((code: string, url: string): void => {
|
||||
const el = document.createElement('script')
|
||||
// Inline execution (not src) so the fetch half stays parallelizable; the
|
||||
// sourceURL comment keeps devtools stack frames attributed to the bundle.
|
||||
el.textContent = `${code}\n//# sourceURL=${url}`
|
||||
document.head.appendChild(el)
|
||||
})
|
||||
|
||||
const requireModule = (spec: string): unknown => {
|
||||
if (!modules.has(spec)) {
|
||||
throw new Error(`client-loader: module "${spec}" is not available — not a seeded library and no loaded plugin registered it (check dshClient.inject ordering)`)
|
||||
}
|
||||
return modules.get(spec)
|
||||
}
|
||||
|
||||
/** Tag styles the bundle injected during execution (unload bookkeeping; plugin CSS lands untagged). */
|
||||
const claimStyles = (id: string): void => {
|
||||
if (typeof document === 'undefined') return
|
||||
for (const el of document.querySelectorAll('style:not([data-plugin])')) {
|
||||
el.setAttribute('data-plugin', id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Start (or reuse) the parallelizable fetch half. */
|
||||
const prefetch = (record: PluginRecord): Promise<string> =>
|
||||
(record.fetch ??= fetchBundle(record.entry.url))
|
||||
|
||||
async function doLoad(record: PluginRecord): Promise<void> {
|
||||
const { id } = record.entry
|
||||
record.state = 'loading'
|
||||
publish(id, 'loading')
|
||||
try {
|
||||
// Dependencies must already be active (start() sequences this; direct
|
||||
// load() callers get the same fail-loud check).
|
||||
for (const dep of record.entry.inject) {
|
||||
const depRecord = records.get(dep)
|
||||
if (depRecord === undefined) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
|
||||
if (depRecord.state !== 'active') throw new Error(`client-loader: "${id}" loaded before its dependency "${dep}" is active`)
|
||||
}
|
||||
const code = await prefetch(record)
|
||||
executeBundle(code, record.entry.url)
|
||||
if (slot === NOT_LOADED) throw new Error(`client-loader: bundle ${record.entry.url} executed without calling DSHClientProxy.loadPlugin`)
|
||||
const handoff = slot
|
||||
slot = NOT_LOADED
|
||||
if (handoff.id !== id) throw new Error(`client-loader: bundle id mismatch — manifest "${id}" vs handoff "${handoff.id}"`)
|
||||
const exports = handoff.factory(requireModule)
|
||||
if (typeof exports.apply !== 'function') throw new Error(`client-loader: plugin "${id}" exports no apply function`)
|
||||
// The whole export surface is the plugin: cordis object-plugin form
|
||||
// keeps the bundle's exported `inject`/`name` (an apply-only pass would
|
||||
// silently drop the dependency declaration — postmortem 0001).
|
||||
const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void })
|
||||
await fiber.await()
|
||||
// Register under both specifier forms bundles emit: the bare package
|
||||
// name (deep-import rewrites) and the /client subpath (CLIENT_EXTERNALS
|
||||
// form) — the loaded surface IS the client half either way.
|
||||
modules.set(id, exports)
|
||||
modules.set(`${id}/client`, exports)
|
||||
claimStyles(id)
|
||||
record.state = 'active'
|
||||
publish(id, 'active')
|
||||
} catch (error) {
|
||||
record.state = 'failed'
|
||||
publish(id, 'failed')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const load = (id: string): Promise<void> => {
|
||||
const record = records.get(id)
|
||||
if (record === undefined) return Promise.reject(new Error(`client-loader: unknown plugin "${id}"`))
|
||||
record.load ??= doLoad(record)
|
||||
return record.load
|
||||
}
|
||||
|
||||
/** Topologically order `ids` by inject (edges inside the set only — an early-group member never waits on a later-group one). */
|
||||
const topo = (ids: string[]): string[] => {
|
||||
const pool = new Set(ids)
|
||||
const ordered: string[] = []
|
||||
const done = new Set<string>()
|
||||
const visiting = new Set<string>()
|
||||
const visit = (id: string): void => {
|
||||
if (done.has(id)) return
|
||||
if (visiting.has(id)) throw new Error(`client-loader: inject cycle through "${id}"`)
|
||||
visiting.add(id)
|
||||
const record = records.get(id)
|
||||
/* v8 ignore next -- ids come from records; unknown ids are caught per-dep below. */
|
||||
if (record === undefined) throw new Error(`client-loader: manifest references unknown plugin "${id}"`)
|
||||
for (const dep of record.entry.inject) {
|
||||
if (!records.has(dep)) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
|
||||
if (pool.has(dep)) visit(dep)
|
||||
}
|
||||
visiting.delete(id)
|
||||
done.add(id)
|
||||
ordered.push(id)
|
||||
}
|
||||
for (const id of ids) visit(id)
|
||||
return ordered
|
||||
}
|
||||
|
||||
let settledPromise: Promise<void> | undefined
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const all = [...records.values()]
|
||||
const early = all.filter(r => r.entry.immediately === true)
|
||||
const rest = all.filter(r => r.entry.immediately !== true)
|
||||
// Early group: parallel fetch (all requests in flight at once), serial
|
||||
// inject-topology execution, full-group barrier before anything else.
|
||||
const earlyOrder = topo(early.map(r => r.entry.id))
|
||||
for (const record of early) void prefetch(record).catch(() => {}) // surfaced by the awaited load below
|
||||
for (const id of earlyOrder) await load(id)
|
||||
// Remaining plugins: one by one in inject topology.
|
||||
for (const id of topo(rest.map(r => r.entry.id))) await load(id)
|
||||
}
|
||||
|
||||
return {
|
||||
start: () => {
|
||||
settledPromise ??= run()
|
||||
// Failures surface through settled()/status — start() itself is fire-and-forget.
|
||||
settledPromise.catch(() => {})
|
||||
},
|
||||
load,
|
||||
unload: (id: string) => Promise.reject(new Error(`client-loader: unload("${id}") is not implemented (lands with HMR)`)),
|
||||
settled: () => {
|
||||
if (settledPromise === undefined) throw new Error('client-loader: settled() before start()')
|
||||
return settledPromise
|
||||
},
|
||||
requireModule,
|
||||
status,
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
// List data never enters zustand; React connects via subscribe/getListSnapshot.
|
||||
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
|
||||
import { flattenLineage } from './lineage.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
|
||||
@@ -168,6 +168,18 @@ export class SessionsService {
|
||||
return this.resolve(id)?.ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the session scope tag off a context. Service-method seam: fetch
|
||||
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
|
||||
* value import of the standalone helper would inline a second module
|
||||
* instance whose private tag Symbol never matches.
|
||||
* @param ctx - any client context.
|
||||
* @returns the session id, or undefined on root contexts.
|
||||
*/
|
||||
scopeOf(ctx: Context): SessionId | undefined {
|
||||
return scopeOf(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the stable session binding (scope-addressed assembly feed). Pure
|
||||
* resolution — no staging, no window side effects.
|
||||
|
||||
@@ -9,7 +9,9 @@ import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
|
||||
SessionId, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import type {
|
||||
ConversationNode, ConversationSnapshot, OpenState, PromptError, RunningToolCall,
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
/**
|
||||
* ClientLoader: handoff protocol (single slot, id reconciliation), DI require
|
||||
* with export-surface re-registration, immediately-group barrier (parallel
|
||||
* fetch / topology execution / full-group barrier), status store, settled,
|
||||
* failure modes (missing handoff, unknown dep, cycle, unload stub).
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createClientLoader } from '../src/client/loader/index.ts'
|
||||
import type { BootPluginEntry, ClientPluginHandoff } from '../src/client/loader/index.ts'
|
||||
|
||||
type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; __DSH_BOOT__?: { plugins: BootPluginEntry[] } }
|
||||
const win = globalThis as Win
|
||||
|
||||
afterEach(() => {
|
||||
delete win.DSHClientProxy
|
||||
delete win.__DSH_BOOT__
|
||||
})
|
||||
|
||||
interface FakeBundle {
|
||||
handoff: ClientPluginHandoff | null | ((require: (spec: string) => unknown) => Record<string, unknown>)
|
||||
}
|
||||
|
||||
interface Bench {
|
||||
loader: ReturnType<typeof createClientLoader>
|
||||
fetched: string[]
|
||||
executed: string[]
|
||||
fetchGate: Map<string, () => void>
|
||||
}
|
||||
|
||||
/** Build a loader over scripted fake bundles keyed by url; fetches resolve when released (or immediately). */
|
||||
function bench(
|
||||
plugins: BootPluginEntry[],
|
||||
bundles: Record<string, FakeBundle>,
|
||||
opts: { modules?: Record<string, unknown>; gated?: string[] } = {},
|
||||
): Bench {
|
||||
const ctx = new Context()
|
||||
const fetched: string[] = []
|
||||
const executed: string[] = []
|
||||
const fetchGate = new Map<string, () => void>()
|
||||
const loader = createClientLoader({
|
||||
ctx,
|
||||
modules: opts.modules ?? { react: { marker: 'react' } },
|
||||
boot: { plugins },
|
||||
fetchBundle: (url) => {
|
||||
fetched.push(url)
|
||||
if (opts.gated?.includes(url) === true) {
|
||||
return new Promise<string>((resolve) => { fetchGate.set(url, () => { resolve(url) }) })
|
||||
}
|
||||
return Promise.resolve(url)
|
||||
},
|
||||
executeBundle: (code) => {
|
||||
executed.push(code)
|
||||
const bundle = bundles[code]
|
||||
if (bundle === undefined) throw new Error(`no fake bundle for ${code}`)
|
||||
if (bundle.handoff === null) return // simulates a bundle that never calls loadPlugin
|
||||
if (typeof bundle.handoff === 'function') {
|
||||
win.DSHClientProxy?.loadPlugin({ id: code.replace('/client.js', '').replace('/plugins/', ''), factory: bundle.handoff })
|
||||
return
|
||||
}
|
||||
win.DSHClientProxy?.loadPlugin(bundle.handoff)
|
||||
},
|
||||
})
|
||||
return { loader, fetched, executed, fetchGate }
|
||||
}
|
||||
|
||||
const entry = (id: string, inject: string[] = [], immediately?: boolean): BootPluginEntry =>
|
||||
({ id, url: `/plugins/${id}/client.js`, inject, ...(immediately === true ? { immediately: true } : {}) })
|
||||
|
||||
const okBundle = (applied?: string[], exports: Record<string, unknown> = {}): FakeBundle => ({
|
||||
handoff: require => ({
|
||||
apply: (pluginCtx: Context) => { void pluginCtx; applied?.push('applied') },
|
||||
require,
|
||||
...exports,
|
||||
}),
|
||||
})
|
||||
|
||||
describe('load chain', () => {
|
||||
it('runs fetch→execute→handoff→factory(require)→apply→export re-registration→status active', async () => {
|
||||
const applied: string[] = []
|
||||
const b = bench(
|
||||
[entry('fake-base', [], true), entry('feature', ['fake-base'])],
|
||||
{
|
||||
'/plugins/fake-base/client.js': { handoff: () => ({ apply: () => { applied.push('fake-base') }, helper: 'base-helper' }) },
|
||||
'/plugins/feature/client.js': {
|
||||
handoff: (require) => {
|
||||
// Later loader requires the earlier one's export surface (inject topology guarantee).
|
||||
const fakeBase = ['fake','base'].join('-') // assembled so knip's static require() scan skips the fake id
|
||||
const base = require(fakeBase) as { helper: string }
|
||||
expect(base.helper).toBe('base-helper')
|
||||
expect((require('react') as { marker: string }).marker).toBe('react')
|
||||
return { apply: () => { applied.push('feature') } }
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
b.loader.start()
|
||||
await b.loader.settled()
|
||||
expect(applied).toEqual(['fake-base', 'feature'])
|
||||
expect(b.loader.status.getSnapshot()).toEqual({ 'fake-base': 'active', feature: 'active' })
|
||||
expect((b.loader.requireModule('fake-base') as { helper: string }).helper).toBe('base-helper')
|
||||
expect(() => b.loader.requireModule('ghost')).toThrow(/not available/)
|
||||
})
|
||||
|
||||
it('fetches the immediately group in parallel and holds the barrier before the rest', async () => {
|
||||
const b = bench(
|
||||
[entry('a', [], true), entry('b', ['a'], true), entry('later')],
|
||||
{
|
||||
'/plugins/a/client.js': okBundle(),
|
||||
'/plugins/b/client.js': okBundle(),
|
||||
'/plugins/later/client.js': okBundle(),
|
||||
},
|
||||
{ gated: ['/plugins/a/client.js'] },
|
||||
)
|
||||
b.loader.start()
|
||||
await Promise.resolve()
|
||||
// Both early fetches are in flight before any execution; the late plugin is not fetched yet.
|
||||
expect(b.fetched).toEqual(['/plugins/a/client.js', '/plugins/b/client.js'])
|
||||
expect(b.executed).toEqual([])
|
||||
b.fetchGate.get('/plugins/a/client.js')?.()
|
||||
await b.loader.settled()
|
||||
expect(b.executed).toEqual(['/plugins/a/client.js', '/plugins/b/client.js', '/plugins/later/client.js'])
|
||||
})
|
||||
|
||||
it('orders execution by inject topology within each group', async () => {
|
||||
const b = bench(
|
||||
[entry('z-ui', ['a-base']), entry('a-base')],
|
||||
{ '/plugins/a-base/client.js': okBundle(), '/plugins/z-ui/client.js': okBundle() },
|
||||
)
|
||||
b.loader.start()
|
||||
await b.loader.settled()
|
||||
expect(b.executed).toEqual(['/plugins/a-base/client.js', '/plugins/z-ui/client.js'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure modes (fail loud)', () => {
|
||||
it('rejects settled and marks failed when a bundle never calls loadPlugin', async () => {
|
||||
const b = bench([entry('silent')], { '/plugins/silent/client.js': { handoff: null } })
|
||||
b.loader.start()
|
||||
await expect(b.loader.settled()).rejects.toThrow(/without calling DSHClientProxy.loadPlugin/)
|
||||
expect(b.loader.status.getSnapshot().silent).toBe('failed')
|
||||
})
|
||||
|
||||
it('rejects on manifest/handoff id mismatch', async () => {
|
||||
const b = bench([entry('expected')], {
|
||||
'/plugins/expected/client.js': { handoff: { id: 'imposter', factory: () => ({ apply: () => {} }) } },
|
||||
})
|
||||
b.loader.start()
|
||||
await expect(b.loader.settled()).rejects.toThrow(/id mismatch/)
|
||||
})
|
||||
|
||||
it('rejects unknown inject targets, cycles, missing apply, unknown load ids, duplicate manifest ids', async () => {
|
||||
// Sequential benches: each loader owns the window proxy, so release it between them.
|
||||
const fresh = <T>(build: () => T): T => {
|
||||
delete win.DSHClientProxy
|
||||
return build()
|
||||
}
|
||||
|
||||
const missing = fresh(() => bench([entry('x', ['nope'])], { '/plugins/x/client.js': okBundle() }))
|
||||
missing.loader.start()
|
||||
await expect(missing.loader.settled()).rejects.toThrow(/injects unknown plugin "nope"/)
|
||||
|
||||
const cyclic = fresh(() => bench(
|
||||
[entry('p', ['q']), entry('q', ['p'])],
|
||||
{ '/plugins/p/client.js': okBundle(), '/plugins/q/client.js': okBundle() },
|
||||
))
|
||||
cyclic.loader.start()
|
||||
await expect(cyclic.loader.settled()).rejects.toThrow(/inject cycle/)
|
||||
|
||||
const applyless = fresh(() => bench([entry('noap')], { '/plugins/noap/client.js': { handoff: { id: 'noap', factory: () => ({}) } } }))
|
||||
applyless.loader.start()
|
||||
await expect(applyless.loader.settled()).rejects.toThrow(/exports no apply/)
|
||||
|
||||
const b = fresh(() => bench([entry('a')], { '/plugins/a/client.js': okBundle() }))
|
||||
await expect(b.loader.load('ghost')).rejects.toThrow(/unknown plugin "ghost"/)
|
||||
|
||||
expect(() => fresh(() => bench([entry('dup'), entry('dup')], {}))).toThrow(/duplicate manifest id/)
|
||||
})
|
||||
|
||||
it('throws on missing boot manifest, double proxy install, and pre-start settled', () => {
|
||||
expect(() => createClientLoader({ ctx: new Context(), modules: {} })).toThrow(/no boot manifest/)
|
||||
const b = bench([], {})
|
||||
expect(() => b.loader.settled()).toThrow(/settled\(\) before start\(\)/)
|
||||
// First bench installed the proxy; a second loader must refuse.
|
||||
expect(() => createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })).toThrow(/already installed/)
|
||||
})
|
||||
|
||||
it('direct load() before a dependency is active fails loud (same check start() sequences)', async () => {
|
||||
const b = bench(
|
||||
[entry('dep', [], true), entry('needy', ['dep'])],
|
||||
{ '/plugins/dep/client.js': okBundle(), '/plugins/needy/client.js': okBundle() },
|
||||
)
|
||||
await expect(b.loader.load('needy')).rejects.toThrow(/loaded before its dependency "dep" is active/)
|
||||
})
|
||||
|
||||
it('direct load() naming an unknown inject target fails loud', async () => {
|
||||
const b = bench([entry('solo', ['phantom'])], { '/plugins/solo/client.js': okBundle() })
|
||||
await expect(b.loader.load('solo')).rejects.toThrow(/injects unknown plugin "phantom"/)
|
||||
})
|
||||
|
||||
it('an immediately-group fetch failure surfaces through settled, not as an unhandled prefetch rejection', async () => {
|
||||
// The fire-and-forget prefetch swallow arm must absorb the early
|
||||
// rejection; the awaited load surfaces the same failure via settled().
|
||||
const ctx = new Context()
|
||||
delete win.DSHClientProxy
|
||||
const loader = createClientLoader({
|
||||
ctx,
|
||||
modules: {},
|
||||
boot: { plugins: [{ id: 'kaboom', url: '/plugins/kaboom/client.js', inject: [], immediately: true }] },
|
||||
fetchBundle: () => Promise.reject(new Error('bundle fetch exploded')),
|
||||
executeBundle: () => {},
|
||||
})
|
||||
loader.start()
|
||||
await expect(loader.settled()).rejects.toThrow(/bundle fetch exploded/)
|
||||
})
|
||||
|
||||
it('unload is the P-I stub', async () => {
|
||||
const b = bench([], {})
|
||||
await expect(b.loader.unload('x')).rejects.toThrow(/not implemented/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DOM default seams (stubbed globals)', () => {
|
||||
it('default fetchBundle uses fetch, rejects non-OK; default executeBundle injects an inline script; claimStyles tags orphans', async () => {
|
||||
const origFetch = globalThis.fetch
|
||||
const appended: { textContent?: string | null }[] = []
|
||||
const styleTag = {
|
||||
attrs: {} as Record<string, string>,
|
||||
setAttribute(k: string, v: string) { this.attrs[k] = v },
|
||||
}
|
||||
const fakeDoc = {
|
||||
createElement: () => {
|
||||
const el = { textContent: null as string | null }
|
||||
return el
|
||||
},
|
||||
head: { appendChild: (el: { textContent?: string | null }) => { appended.push(el) } },
|
||||
querySelectorAll: () => [styleTag],
|
||||
}
|
||||
const g = globalThis as { document?: unknown; fetch: typeof fetch }
|
||||
g.document = fakeDoc
|
||||
g.fetch = (url: URL | RequestInfo) => Promise.resolve(
|
||||
(typeof url === 'string' ? url : url instanceof URL ? url.href : url.url).includes('bad')
|
||||
? new Response('x', { status: 500 })
|
||||
: new Response('window.DSHClientProxy.loadPlugin(globalThis.__seamHandoff)', { status: 200 }),
|
||||
)
|
||||
try {
|
||||
delete win.DSHClientProxy
|
||||
const ctx = new Context()
|
||||
const loader = createClientLoader({
|
||||
ctx,
|
||||
modules: {},
|
||||
boot: { plugins: [
|
||||
{ id: 'seam-ok', url: '/plugins/seam-ok/client.js', inject: [] },
|
||||
{ id: 'seam-bad', url: '/plugins/bad/client.js', inject: [] },
|
||||
] },
|
||||
// NO seams injected (keys omitted, not undefined — exactOptional):
|
||||
// the DOM defaults are under test.
|
||||
})
|
||||
const seamHandoff: ClientPluginHandoff = {
|
||||
id: 'seam-ok',
|
||||
factory: () => ({ apply: () => {} }),
|
||||
}
|
||||
// Default executeBundle only APPENDS the script element (no execution in
|
||||
// our fake DOM), so drive the handoff manually before load resolves it.
|
||||
const loadOk = loader.load('seam-ok')
|
||||
await Promise.resolve()
|
||||
;(globalThis as Win).DSHClientProxy?.loadPlugin(seamHandoff)
|
||||
await loadOk
|
||||
expect(appended).toHaveLength(1)
|
||||
expect(appended[0]?.textContent).toContain('sourceURL=/plugins/seam-ok/client.js')
|
||||
expect(styleTag.attrs['data-plugin']).toBe('seam-ok')
|
||||
await expect(loader.load('seam-bad')).rejects.toThrow(/answered 500/)
|
||||
} finally {
|
||||
g.fetch = origFetch
|
||||
delete (globalThis as { document?: unknown }).document
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('handoff slot protocol', () => {
|
||||
it('rejects an overlapping loadPlugin before the loader claims the pending handoff', () => {
|
||||
delete win.DSHClientProxy
|
||||
createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })
|
||||
const proxy = (globalThis as Win).DSHClientProxy
|
||||
proxy?.loadPlugin({ id: 'first', factory: () => ({ apply: () => {} }) })
|
||||
expect(() => proxy?.loadPlugin({ id: 'second', factory: () => ({ apply: () => {} }) }))
|
||||
.toThrow(/overlapping loadPlugin handoff/)
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
@@ -1,23 +1,3 @@
|
||||
import type { UserConfig } from 'tsdown'
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
/**
|
||||
* Standard dual-entry shape plus the loader lib half: exports["./loader"]
|
||||
* promises lib/loader.js (the web shell statically imports the machinery —
|
||||
* a loader cannot load itself), and the shared preset only emits
|
||||
* lib/{index,invariant}.js, so the extra config supplies it.
|
||||
*/
|
||||
const configs = clientBundle('@deepseek-ai/dsh-client-runtime', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
|
||||
const loaderLib: UserConfig = {
|
||||
entry: { loader: 'lib/types/client/loader/index.js' },
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'neutral',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
}
|
||||
|
||||
export default [...configs, loaderLib]
|
||||
export default clientBundle('@deepseek-ai/dsh-client-runtime', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Shared tsdown preset for UI plugin client bundles. Emits a closure-factory
|
||||
* artifact: the bundle calls window.DSHClientProxy.loadPlugin({id, factory})
|
||||
* artifact: the bundle calls window.__ModuleLoader__.load({id, factory})
|
||||
* and resolves externals through the injected require (loader module table —
|
||||
* cordis DI entities, no globals, no import map). CSS Modules are compiled by
|
||||
* lightningcss inside the bundle: importing `x.module.css` yields the
|
||||
@@ -11,6 +11,7 @@ import { readFile } from 'node:fs/promises'
|
||||
import { basename, dirname, resolve as resolvePath } from 'node:path'
|
||||
import type { UserConfig } from 'tsdown'
|
||||
import { transform } from 'lightningcss'
|
||||
import { PLATFORM_MODULES } from './web/src/platform.ts'
|
||||
|
||||
/**
|
||||
* Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline
|
||||
@@ -28,22 +29,20 @@ const CSS_VIRTUAL_SUFFIX = '.mjs'
|
||||
*/
|
||||
export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
|
||||
|
||||
/** Externals resolved from the loader module table (keep in sync with the shell's seeding list). */
|
||||
export const CLIENT_EXTERNALS = [
|
||||
'react',
|
||||
'react-dom',
|
||||
'react/jsx-runtime',
|
||||
'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-web-react',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@deepseek-ai/dsh-client-connection/client',
|
||||
'@deepseek-ai/dsh-client-runtime/client',
|
||||
'@deepseek-ai/dsh-client-ui-layout/client',
|
||||
'@deepseek-ai/dsh-client-ui-conversation/client',
|
||||
'@deepseek-ai/dsh-client-ui-theme/client',
|
||||
'@deepseek-ai/dsh-client-i18n/client',
|
||||
]
|
||||
/**
|
||||
* Documented TEMPORARY exemption, not a platform module (hence not in
|
||||
* platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
|
||||
* shallowEqual) lives in runtime pending its promotion-time rehoming, and
|
||||
* five importers (i18n, ui-layout, ui-conversation ×3) ride this single
|
||||
* exemption. At runtime the lazy CJS table answers the require natively:
|
||||
* runtime is an immediately-tier row, its factory is registered before any
|
||||
* dependent bundle materializes. TODO(webload/store-rehome): remove with the
|
||||
* store-engine relocation follow-up.
|
||||
*/
|
||||
const RUNTIME_STORE_EXEMPTION = '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Externals resolved from the loader module table: the platform seed entries plus the documented runtime exemption. */
|
||||
export const CLIENT_EXTERNALS: readonly string[] = [...PLATFORM_MODULES, RUNTIME_STORE_EXEMPTION]
|
||||
|
||||
/**
|
||||
* Build the tsdown config for one UI plugin package: the node-half lib build
|
||||
@@ -51,8 +50,8 @@ export const CLIENT_EXTERNALS = [
|
||||
* the root workspace shape, so the lib half must be restated here — dropping
|
||||
* it leaves the package without lib/index.js and the host Loader cannot
|
||||
* import its node half.
|
||||
* @param id - plugin id (package name), stamped into the loadPlugin handoff
|
||||
* and onto the injected style tags.
|
||||
* @param id - plugin id (package name), stamped into the __ModuleLoader__.load
|
||||
* handoff and onto the injected style tags.
|
||||
* @param libEntry - node-half entries, spelled at the call site so the
|
||||
* package-invariants gate can see `lib/types/invariant.js` in each package's
|
||||
* own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
|
||||
@@ -79,7 +78,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
|
||||
// Types ship from lib/types (tsc); dts here would wrap the banner/footer into .d.cts and break parsing.
|
||||
dts: false,
|
||||
clean: false,
|
||||
external: CLIENT_EXTERNALS,
|
||||
external: [...CLIENT_EXTERNALS],
|
||||
// Browser bundles inline node-idiom deps (zustand/immer read
|
||||
// process.env.NODE_ENV; zustand's esm build also probes
|
||||
// import.meta.env.MODE, which a CJS output cannot carry — rolldown flags
|
||||
@@ -102,24 +101,20 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
|
||||
// opinion for table entries (external above wins), bundle everything else.
|
||||
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
|
||||
plugins: [{
|
||||
// Bundle purity gate: a bare-name import of a module-table package would
|
||||
// slip past CLIENT_EXTERNALS (which lists the /client form) and INLINE a
|
||||
// second copy of that package — duplicate runtime identity (a second
|
||||
// scope Symbol was tonight's white-screen root cause). Resolve-time is
|
||||
// the earliest, most precise interception: rewrite bare table names to
|
||||
// their /client form (the loader registers both specifiers), and reject
|
||||
// any other @deepseek-ai/* leak that is not an inline-safe wire layer.
|
||||
// Bundle purity gate (build-time mirror of the module-edge rules):
|
||||
// platform seed entries stay external, inline-safe wire layers inline,
|
||||
// and every other @deepseek-ai value import is a build error — a
|
||||
// cross-plugin value import either inlines a duplicate runtime instance
|
||||
// or requires a specifier the frozen module table cannot answer.
|
||||
// Cross-plugin collaboration goes through cordis services instead.
|
||||
name: 'dsh-client-bundle-purity',
|
||||
resolveId(source: string) {
|
||||
if (!source.startsWith('@deepseek-ai/')) return null
|
||||
if (CLIENT_EXTERNALS.includes(source)) return null // external wins
|
||||
if (CLIENT_EXTERNALS.includes(`${source}/client`)) {
|
||||
return { id: `${source}/client`, external: true }
|
||||
}
|
||||
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
|
||||
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
|
||||
throw new Error(
|
||||
`client bundle purity: "${source}" is not in CLIENT_EXTERNALS and not an inline-safe wire layer — `
|
||||
+ 'import the /client form, add it to the module table, or it inlines a duplicate runtime instance',
|
||||
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
|
||||
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
|
||||
)
|
||||
},
|
||||
}, {
|
||||
@@ -158,7 +153,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
|
||||
}],
|
||||
outputOptions: {
|
||||
entryFileNames: 'client.js',
|
||||
banner: `window.DSHClientProxy.loadPlugin({ id: ${JSON.stringify(id)}, factory: (require) => {`,
|
||||
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
|
||||
footer: `return module.exports; } });`,
|
||||
intro: 'var module = { exports: {} }; var exports = module.exports;',
|
||||
},
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-i18n",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
],
|
||||
"platform": "web"
|
||||
@@ -34,21 +36,25 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"clsx": "^2.0.0",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -15,14 +15,10 @@
|
||||
*/
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
// Value import MUST use the /client subpath: only that specifier is in the
|
||||
// bundle externals (CLIENT_EXTERNALS), so it resolves to the shared runtime
|
||||
// module at load time. A bare-specifier value import gets INLINED as a second
|
||||
// module instance whose private scope-tag Symbol never matches the one
|
||||
// SessionsService tags contexts with — scopeOf then always returns undefined
|
||||
// in the browser while unit tests (single-instance path resolution) stay green.
|
||||
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { Session, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only imports: a plugin-to-plugin value import is a bundle purity
|
||||
// error, so scope resolution goes through the sessions service (scopeOf
|
||||
// method) instead of the standalone helper.
|
||||
import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
|
||||
export class ConversationService extends Service {
|
||||
@@ -83,11 +79,17 @@ export class ConversationService extends Service {
|
||||
|
||||
/** Resolve the caller scope's Session or throw on root contexts. */
|
||||
private scopedSession(op: string): Session {
|
||||
const id = scopeOf(this.ctx)
|
||||
const id = this.scopeId(op)
|
||||
return this.requireSessions().manager.get(id)
|
||||
}
|
||||
|
||||
/** Read the caller's session scope tag via the sessions service; root contexts fail loud. */
|
||||
private scopeId(op: string): SessionId {
|
||||
const id = this.requireSessions().scopeOf(this.ctx)
|
||||
if (id === undefined) {
|
||||
throw new Error(`conversation.${op} requires a session scope — address one via ctx.sessions.scope(id).conversation`)
|
||||
}
|
||||
return this.requireSessions().manager.get(id)
|
||||
return id
|
||||
}
|
||||
|
||||
private requireSessions(): SessionsService {
|
||||
|
||||
@@ -76,6 +76,7 @@ async function bench() {
|
||||
manager: { get: () => sessionFake },
|
||||
scope: (id: SessionId) => mint(id),
|
||||
cell: () => undefined,
|
||||
scopeOf,
|
||||
create: vi.fn(() => Promise.resolve(ROOT)),
|
||||
open: vi.fn(),
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ async function bench(opts?: { sessions?: boolean }) {
|
||||
create: createMock,
|
||||
open: openMock,
|
||||
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
|
||||
scopeOf,
|
||||
} as unknown as SessionsService
|
||||
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
|
||||
// Class-plugin mount — the same form apply.ts uses in production.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-client-ui-layout
|
||||
|
||||
Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. A closed sidebar retains a 56px control rail while details closes to zero width; collapse/expand animates the grid tracks on the deepsuite sider curve. Contract: api-contracts v3 §5.
|
||||
Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. The sidebar is fixed-width (it never concedes to viewport pressure — only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width; collapse/expand animates the grid tracks on the deepsuite sider curve. Contract: api-contracts v3 §5.
|
||||
|
||||
Slot declarations use the composed-props entry form (`owner` share, no full `props`): the exported OwnerShare contracts are `SidebarOwnerProps` / `ConvOwnerProps` / `DetailsOwnerProps` / `EmptyOwnerProps` — registrants reference them via `OwnerOf<'sidebar' | ...>` and compose their own injected share locally. No entry declares `children` (declaring it requires the registered component to carry the slots face — reserved for future business slots): delegation authority is the component-side whitelist, i.e. AppFrame's `ScopedSlots<FrameSlotKey>` face over sidebar/conversation/details/conversation.empty. Since the root-slot rework the frame itself registers into 'root' and renders those child slots at its own render sites; the shell only renders 'root'.
|
||||
|
||||
|
||||
@@ -33,19 +33,20 @@
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -86,11 +86,25 @@
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
background: var(--dsw-alias-button-floating-fill);
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
/* Hover affordance: the pill hides until the pointer is over the owning
|
||||
column (data-side pairs handle and column), the strip itself, or a drag. */
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.sidebarCol:hover ~ .handle[data-side='sidebar']::after,
|
||||
.detailsCol:hover ~ .handle[data-side='details']::after,
|
||||
.handle:hover::after,
|
||||
.handle[data-dragging='true']::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.handle:hover::after,
|
||||
.handle[data-dragging='true']::after {
|
||||
background: var(--dsw-alias-button-floating-hover);
|
||||
border-color: var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ function DetailsColumn(props: { children?: ReactNode }) {
|
||||
return <div className={css.detailsCol}>{props.children}</div>
|
||||
}
|
||||
|
||||
/** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. */
|
||||
function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) {
|
||||
/** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. `side` keys the hover-reveal CSS to the owning column. */
|
||||
function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) {
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const origin = useRef(0)
|
||||
const latest = useRef(0)
|
||||
@@ -72,6 +72,7 @@ function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: num
|
||||
<div
|
||||
className={css.handle}
|
||||
style={{ left: props.left }}
|
||||
data-side={props.side}
|
||||
data-dragging={dragging || undefined}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
@@ -161,8 +162,8 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App
|
||||
)}
|
||||
</SessionProvider>
|
||||
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
|
||||
{panels.sidebar > 0 && <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
|
||||
{cols.details > 0 && <DragHandle left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}
|
||||
{panels.sidebar > 0 && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
|
||||
{cols.details > 0 && <DragHandle side="details" left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Pure concession-chain column solver for the three-column AppFrame.
|
||||
* Chain order is fixed by contract: keep center >= CENTER_MIN by shrinking
|
||||
* details first, then sidebar, then auto-closing details (derived zero width —
|
||||
* persisted width preferences are never rewritten, so widening the window
|
||||
* restores them). Center absorbs any remaining deficit as the last resort.
|
||||
* Inputs are the layout store's plain width preferences (0 = closed); a
|
||||
* closed sidebar resolves to the fixed SIDEBAR_COLLAPSED control rail while
|
||||
* closed details resolve to zero width.
|
||||
* details, then auto-closing it (derived zero width — persisted width
|
||||
* preferences are never rewritten, so widening the window restores them).
|
||||
* The sidebar never concedes: its rendered width is always the drag
|
||||
* preference (or the collapsed rail), and center absorbs any remaining
|
||||
* deficit as the last resort. Inputs are the layout store's plain width
|
||||
* preferences (0 = closed); a closed sidebar resolves to the fixed
|
||||
* SIDEBAR_COLLAPSED control rail while closed details resolve to zero width.
|
||||
*/
|
||||
|
||||
/** Resolved widths for one frame; center may drop below CENTER_MIN only at the final fallback. */
|
||||
@@ -16,11 +17,11 @@ export interface Columns { sidebar: number; center: number; details: number }
|
||||
/** Center column floor; only the final fallback may go below it. */
|
||||
export const CENTER_MIN = 640
|
||||
/** Sidebar drag clamp floor. */
|
||||
export const SIDEBAR_MIN = 240
|
||||
export const SIDEBAR_MIN = 280
|
||||
/** Sidebar drag clamp ceiling. */
|
||||
export const SIDEBAR_MAX = 420
|
||||
/** Sidebar width before any user drag. */
|
||||
export const SIDEBAR_DEFAULT = 300
|
||||
/** Sidebar width before any user drag (= the drag floor). */
|
||||
export const SIDEBAR_DEFAULT = 280
|
||||
/** Closed-sidebar rail: a 24px icon column between 16px horizontal paddings. */
|
||||
export const SIDEBAR_COLLAPSED = 56
|
||||
/** Details drag clamp floor. */
|
||||
@@ -44,38 +45,26 @@ export function clampWidth(px: number, min: number, max: number): number {
|
||||
/**
|
||||
* Solve the three column widths for one viewport frame. Pure: no hysteresis —
|
||||
* the output is a function of (viewport, preferences) only, so recovery on
|
||||
* re-widening is automatic. After the auto-close step the details pressure is
|
||||
* gone, so the sidebar returns to its preferred width when it fits.
|
||||
* Preferences re-clamp here because they cross a durable boundary
|
||||
* (localStorage rehydration may carry stale ranges).
|
||||
* re-widening is automatic. Preferences re-clamp here because they cross a
|
||||
* durable boundary (localStorage rehydration may carry stale ranges).
|
||||
* @param viewport - available frame width in px.
|
||||
* @param sidebar - sidebar width preference in px (0 = closed).
|
||||
* @param details - details width preference in px (0 = closed).
|
||||
* @returns resolved widths; details 0 means visually closed (never unmounted), while a closed sidebar keeps its compact rail.
|
||||
*/
|
||||
export function computeColumns(viewport: number, sidebar: number, details: number): Columns {
|
||||
const s0 = sidebar === 0 ? SIDEBAR_COLLAPSED : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX)
|
||||
// The sidebar is fixed at its preference (or the rail) — it never concedes.
|
||||
const s = sidebar === 0 ? SIDEBAR_COLLAPSED : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX)
|
||||
const d0 = details === 0 ? 0 : clampWidth(details, DETAILS_MIN, DETAILS_MAX)
|
||||
|
||||
// Step 1: everything fits at preferred widths.
|
||||
if (s0 + d0 + CENTER_MIN <= viewport) return { sidebar: s0, center: viewport - s0 - d0, details: d0 }
|
||||
if (s + d0 + CENTER_MIN <= viewport) return { sidebar: s, center: viewport - s - d0, details: d0 }
|
||||
|
||||
// Step 2: shrink details toward its minimum.
|
||||
const d1 = d0 === 0 ? 0 : Math.max(DETAILS_MIN, viewport - s0 - CENTER_MIN)
|
||||
if (s0 + d1 + CENTER_MIN <= viewport) return { sidebar: s0, center: CENTER_MIN, details: d1 }
|
||||
const d1 = d0 === 0 ? 0 : Math.max(DETAILS_MIN, viewport - s - CENTER_MIN)
|
||||
if (s + d1 + CENTER_MIN <= viewport) return { sidebar: s, center: CENTER_MIN, details: d1 }
|
||||
|
||||
// Step 3: shrink sidebar toward its minimum (the collapsed rail never shrinks).
|
||||
const s1 = sidebar === 0 ? SIDEBAR_COLLAPSED : Math.max(SIDEBAR_MIN, viewport - d1 - CENTER_MIN)
|
||||
if (s1 + d1 + CENTER_MIN <= viewport) return { sidebar: s1, center: CENTER_MIN, details: d1 }
|
||||
|
||||
// Step 4: auto-close details (derived — preferences untouched). With the
|
||||
// details pressure gone the sidebar concession is re-solved from preference.
|
||||
if (d1 > 0) {
|
||||
if (s0 + CENTER_MIN <= viewport) return { sidebar: s0, center: viewport - s0, details: 0 }
|
||||
const s2 = sidebar === 0 ? SIDEBAR_COLLAPSED : Math.max(SIDEBAR_MIN, viewport - CENTER_MIN)
|
||||
return { sidebar: s2, center: Math.max(0, viewport - s2), details: 0 }
|
||||
}
|
||||
|
||||
// Step 5: center absorbs the deficit (may drop below CENTER_MIN).
|
||||
return { sidebar: s1, center: Math.max(0, viewport - s1 - d1), details: d1 }
|
||||
// Step 3: auto-close details (derived — preferences untouched); center
|
||||
// absorbs any remaining deficit (may drop below CENTER_MIN).
|
||||
return { sidebar: s, center: Math.max(0, viewport - s), details: 0 }
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapsho
|
||||
function mountFrame() {
|
||||
window.innerWidth = frameWidth // first-render viewport source before the observer fires
|
||||
const instance = createLayoutStore().create()
|
||||
instance.actions.openDetails() // seed: sidebar at default 300, details open at default 360
|
||||
instance.actions.openDetails() // seed: sidebar at default 280, details open at default 360
|
||||
const slotCalls: { key: string; props: unknown }[] = []
|
||||
const renderSlot = ((key: string, owner: object) => {
|
||||
slotCalls.push({ key, props: owner })
|
||||
@@ -116,7 +116,7 @@ afterEach(() => {
|
||||
describe('AppFrame', () => {
|
||||
it('renders three tracks from store state', () => {
|
||||
const { frame } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([300, 360])
|
||||
expect(tracks(frame)).toEqual([280, 360])
|
||||
})
|
||||
|
||||
it('renders the session pair with empty owner shares (sessionId is framework-standard)', () => {
|
||||
@@ -142,13 +142,13 @@ describe('AppFrame', () => {
|
||||
|
||||
it('sidebar slot receives live concession output as owner props', () => {
|
||||
const { slotCalls } = mountFrame()
|
||||
expect(slotCalls.find((c) => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 300 })
|
||||
expect(slotCalls.find((c) => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 })
|
||||
})
|
||||
|
||||
it('sidebar drag widens through rAF-batched pointer moves', () => {
|
||||
const { frame } = mountFrame()
|
||||
const handles = frame.querySelectorAll('[class*="handle"]')
|
||||
drag(handles[0]!, 300, 350)
|
||||
drag(handles[0]!, 280, 350)
|
||||
expect(tracks(frame)[0]).toBe(350)
|
||||
})
|
||||
|
||||
@@ -160,18 +160,18 @@ describe('AppFrame', () => {
|
||||
})
|
||||
|
||||
it('drag base is the rendered (concession-clamped) width, not the preference', () => {
|
||||
frameWidth = 1250 // step-2 squeeze: details renders 310 while preference is 360
|
||||
frameWidth = 1250 // step-2 squeeze: details renders 330 while preference is 360
|
||||
const { frame, instance } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([300, 310])
|
||||
expect(tracks(frame)).toEqual([280, 330])
|
||||
const handles = frame.querySelectorAll('[class*="handle"]')
|
||||
drag(handles[1]!, 940, 950) // shrink by 10 from the rendered width
|
||||
expect(instance.getSnapshot().details).toBe(300)
|
||||
drag(handles[1]!, 920, 930) // shrink by 10 from the rendered width
|
||||
expect(instance.getSnapshot().details).toBe(320)
|
||||
})
|
||||
|
||||
it('details column stays mounted at zero width', () => {
|
||||
const { frame, instance, getByTestId } = mountFrame()
|
||||
act(() => { instance.actions.closeDetails() })
|
||||
expect(tracks(frame)).toEqual([300, 0])
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
expect(getByTestId('details-content')).toBeTruthy()
|
||||
expect(frame.hasAttribute('data-details-collapsed')).toBe(true)
|
||||
})
|
||||
@@ -190,10 +190,10 @@ describe('AppFrame', () => {
|
||||
const { frame } = mountFrame()
|
||||
frameWidth = 1250
|
||||
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
expect(tracks(frame)).toEqual([300, 310])
|
||||
expect(tracks(frame)).toEqual([280, 330])
|
||||
frameWidth = 1920
|
||||
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
expect(tracks(frame)).toEqual([300, 360])
|
||||
expect(tracks(frame)).toEqual([280, 360])
|
||||
})
|
||||
|
||||
it('drag handles disappear for collapsed columns', () => {
|
||||
@@ -223,7 +223,7 @@ describe('AppFrame — guard branches', () => {
|
||||
it('two moves inside one frame coalesce through the pending rAF', () => {
|
||||
const { frame, instance } = mountFrame()
|
||||
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
|
||||
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
|
||||
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 280, bubbles: true })) })
|
||||
act(() => {
|
||||
// Two moves before the frame flushes: the second must ride the pending
|
||||
// rAF (frame.current ??= guard), and the flush sees the latest x.
|
||||
@@ -238,7 +238,7 @@ describe('AppFrame — guard branches', () => {
|
||||
it('pointerup with a pending rAF cancels it and commits the final position', () => {
|
||||
const { frame, instance } = mountFrame()
|
||||
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
|
||||
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
|
||||
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 280, bubbles: true })) })
|
||||
act(() => {
|
||||
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 360, bubbles: true }))
|
||||
// No timer advance: the rAF is still pending when pointerup arrives.
|
||||
@@ -252,7 +252,7 @@ describe('AppFrame — guard branches', () => {
|
||||
frameWidth = 0
|
||||
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
// Track template still reflects the last non-zero viewport.
|
||||
expect(tracks(frame)).toEqual([300, 360])
|
||||
expect(tracks(frame)).toEqual([280, 360])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -270,6 +270,6 @@ describe('AppFrame — unmount with an in-flight resize frame', () => {
|
||||
const { frame } = mountFrame()
|
||||
frameWidth = 1250
|
||||
act(() => { fireResize?.(); fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
expect(tracks(frame)).toEqual([300, 310])
|
||||
expect(tracks(frame)).toEqual([280, 330])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('clampWidth', () => {
|
||||
describe('computeColumns', () => {
|
||||
it('step 1: everything fits at preferred widths', () => {
|
||||
const cols = computeColumns(1920, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(cols).toEqual({ sidebar: 300, center: 1920 - 300 - 360, details: 360 })
|
||||
expect(cols).toEqual({ sidebar: 280, center: 1920 - 280 - 360, details: 360 })
|
||||
})
|
||||
|
||||
it('closed sidebar keeps its compact rail while closed details contribute zero width', () => {
|
||||
@@ -31,12 +31,13 @@ describe('computeColumns', () => {
|
||||
const cols = computeColumns(1920, open(9999), open(1))
|
||||
expect(cols.sidebar).toBe(420)
|
||||
expect(cols.details).toBe(300)
|
||||
expect(computeColumns(1920, open(1), open(DETAILS_DEFAULT)).sidebar).toBe(SIDEBAR_MIN)
|
||||
})
|
||||
|
||||
it('step 2: details shrinks first, center pinned at min', () => {
|
||||
// 300 + 360 + 640 = 1300 > 1250; details concedes to 1250-300-640 = 310.
|
||||
// 280 + 360 + 640 = 1280 > 1250; details concedes to 1250-280-640 = 330.
|
||||
const cols = computeColumns(1250, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(cols).toEqual({ sidebar: 300, center: CENTER_MIN, details: 310 })
|
||||
expect(cols).toEqual({ sidebar: 280, center: CENTER_MIN, details: 330 })
|
||||
})
|
||||
|
||||
it('boundary: exactly at the step-1/step-2 seam', () => {
|
||||
@@ -46,28 +47,16 @@ describe('computeColumns', () => {
|
||||
expect(one).toEqual({ sidebar: 300, center: CENTER_MIN, details: 359 })
|
||||
})
|
||||
|
||||
it('step 3: sidebar concedes after details hits its min', () => {
|
||||
// details floor 300: sidebar = 1220-300-640 = 280.
|
||||
const cols = computeColumns(1220, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(cols).toEqual({ sidebar: 280, center: CENTER_MIN, details: DETAILS_MIN })
|
||||
it('step 3: details auto-closes when its min still starves center — sidebar holds its preference', () => {
|
||||
// 280 + 300 + 640 = 1220 > 1210 → details 0; sidebar untouched: center = 1210-280 = 930.
|
||||
const cols = computeColumns(1210, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(cols).toEqual({ sidebar: 280, center: 930, details: 0 })
|
||||
})
|
||||
|
||||
it('step 4: details auto-closes when both panels are at min and center still starves', () => {
|
||||
// 240 + 300 + 640 = 1180 > 1100 → details 0; sidebar preference (300) fits: 1100-300 = 800 center.
|
||||
const cols = computeColumns(1100, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(cols).toEqual({ sidebar: 300, center: 800, details: 0 })
|
||||
})
|
||||
|
||||
it('step 4 keeps squeezing sidebar when preference no longer fits', () => {
|
||||
// 900 < 300+640: sidebar = max(240, 900-640) = 260.
|
||||
const cols = computeColumns(900, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(cols).toEqual({ sidebar: 260, center: CENTER_MIN, details: 0 })
|
||||
})
|
||||
|
||||
it('step 5: center absorbs the deficit as last resort (details closed)', () => {
|
||||
// 700 < 240+640: sidebar floors at 240, center takes 460 < CENTER_MIN.
|
||||
it('the sidebar never concedes: center absorbs the deficit below CENTER_MIN', () => {
|
||||
// 700 < 280+640: sidebar keeps 280, center takes 420 < CENTER_MIN.
|
||||
const cols = computeColumns(700, open(SIDEBAR_DEFAULT), closed(DETAILS_DEFAULT))
|
||||
expect(cols).toEqual({ sidebar: SIDEBAR_MIN, center: 460, details: 0 })
|
||||
expect(cols).toEqual({ sidebar: SIDEBAR_DEFAULT, center: 420, details: 0 })
|
||||
})
|
||||
|
||||
it('sidebar-closed narrow window: details concedes then auto-closes', () => {
|
||||
@@ -81,11 +70,11 @@ describe('computeColumns', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('tiny viewport: both panels yield everything to center', () => {
|
||||
it('tiny viewport: details closes, sidebar holds, center takes the remainder', () => {
|
||||
const cols = computeColumns(400, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(cols.details).toBe(0)
|
||||
expect(cols.sidebar).toBe(SIDEBAR_MIN)
|
||||
expect(cols.center).toBe(Math.max(0, 400 - SIDEBAR_MIN))
|
||||
expect(cols.sidebar).toBe(SIDEBAR_DEFAULT)
|
||||
expect(cols.center).toBe(Math.max(0, 400 - SIDEBAR_DEFAULT))
|
||||
})
|
||||
|
||||
it('recovery is pure: re-widening restores preferred widths untouched', () => {
|
||||
@@ -99,7 +88,7 @@ describe('computeColumns', () => {
|
||||
|
||||
describe('computeColumns — degenerate viewports', () => {
|
||||
it('sidebar closed and viewport below CENTER_MIN: details auto-closes, center takes the rest', () => {
|
||||
// Reaches step 4's re-solve with the compact rail as the sidebar floor.
|
||||
// Reaches step 3's auto-close with the compact rail sidebar.
|
||||
expect(computeColumns(500, closed(300), open(DETAILS_DEFAULT)))
|
||||
.toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 500 - SIDEBAR_COLLAPSED, details: 0 })
|
||||
})
|
||||
|
||||
56
packages/client/ui-primitives/src/BrandWordmark.tsx
Normal file
56
packages/client/ui-primitives/src/BrandWordmark.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
// DeepSeek Harness brand wordmark (figma 356:14644, exact extract): whale +
|
||||
// "deepseek" letterforms + HARNESS badge plate in one svg. Native 182x24.
|
||||
// Ink rides currentColor; the badge text is knocked out in the inverted
|
||||
// label color so the plate stays legible in both themes.
|
||||
|
||||
import type { IconProps } from './icons/props.ts'
|
||||
|
||||
/**
|
||||
* Render the full brand wordmark.
|
||||
* @param props.size - height in px (default 24; width keeps the 182:24 ratio).
|
||||
* @param props.className - extra class for layout placement.
|
||||
* @returns the wordmark svg (aria-hidden decorative brand art).
|
||||
*/
|
||||
export function BrandWordmark({ size = 24, className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={(size * 182) / 24}
|
||||
height={size}
|
||||
className={className}
|
||||
viewBox="0 0 182 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M68.416 18.2447H67.0501V16.1272H68.416C69.2619 16.1272 70.1166 15.9163 70.6671 15.3304C71.2181 14.7444 71.426 13.8455 71.426 12.9471C71.426 12.0487 71.2268 11.1498 70.6671 10.5643C70.1083 9.97831 69.2619 9.76744 68.416 9.76744C67.5701 9.76744 66.7154 9.97831 66.1639 10.5643C65.6129 11.1503 65.4049 12.0487 65.4049 12.9471V21.6435H63.009V7.6582H65.4049V8.54883H65.8442C65.8918 8.49393 65.9394 8.44728 65.9875 8.40064C66.5871 7.85353 67.5049 7.6582 68.4072 7.6582C69.8212 7.6582 71.2341 8.00998 72.1607 8.98662C73.0868 9.96325 73.4143 11.4632 73.4143 12.9558C73.4143 14.4485 73.0785 15.9406 72.1607 16.925C71.2424 17.9094 69.8212 18.2457 68.416 18.2457V18.2447Z" fill="currentColor"/>
|
||||
<path d="M31.9551 8.03497H33.3204V10.1525H31.9551C31.1087 10.1525 30.2545 10.3633 29.7035 10.9493C29.1525 11.5353 28.945 12.4342 28.945 13.3326C28.945 14.231 29.1447 15.1294 29.7035 15.7154C30.2623 16.3014 31.1087 16.5122 31.9551 16.5122C32.8015 16.5122 33.6562 16.3014 34.2072 15.7154C34.7582 15.1294 34.9657 14.231 34.9657 13.3326V4.62842H37.3611V18.6219H34.9657V17.7313H34.5264C34.4783 17.7857 34.4307 17.8329 34.3826 17.8795C33.7835 18.4261 32.8652 18.6219 31.9629 18.6219C30.5494 18.6219 29.136 18.2707 28.2099 17.294C27.2838 16.3174 26.9563 14.817 26.9563 13.3248C26.9563 11.8327 27.2916 10.34 28.2099 9.35561C29.136 8.37898 30.5494 8.03497 31.9551 8.03497Z" fill="currentColor"/>
|
||||
<path d="M49.3786 13.1431V13.9948H42.9984V12.2996H47.2305C47.1348 11.6825 46.9113 11.1043 46.5119 10.682C45.9371 10.0727 45.0503 9.85409 44.1723 9.85409C43.2943 9.85409 42.4076 10.0727 41.8328 10.682C41.258 11.2913 41.05 12.2213 41.05 13.1435C41.05 14.0658 41.2575 15.003 41.8328 15.6046C42.4076 16.2061 43.2939 16.433 44.1723 16.433C45.0508 16.433 45.9371 16.2143 46.5119 15.6046C46.5916 15.5186 46.6635 15.4248 46.7354 15.331H49.0992C48.8918 16.0657 48.5643 16.7299 48.0691 17.2454C47.111 18.2531 45.6339 18.6205 44.1723 18.6205C42.7108 18.6205 41.2337 18.2609 40.2755 17.2454C39.3174 16.2299 38.9661 14.6828 38.9661 13.1435C38.9661 11.6043 39.3096 10.0494 40.2755 9.04168C41.242 8.03396 42.7108 7.66663 44.1723 7.66663C45.6339 7.66663 47.111 8.02618 48.0691 9.04168C49.0351 10.0572 49.3786 11.6043 49.3786 13.1435V13.1431Z" fill="currentColor"/>
|
||||
<path d="M61.4045 13.1431V13.9948H55.0243V12.2996H59.2564C59.1602 11.6825 58.9372 11.1043 58.5378 10.682C57.963 10.0727 57.0762 9.85409 56.1982 9.85409C55.3202 9.85409 54.4335 10.0727 53.8587 10.682C53.2839 11.2913 53.0759 12.2213 53.0759 13.1435C53.0759 14.0658 53.2834 15.003 53.8587 15.6046C54.4335 16.2061 55.3202 16.433 56.1982 16.433C57.0762 16.433 57.963 16.2143 58.5378 15.6046C58.6179 15.5186 58.6894 15.4248 58.7608 15.331H61.1251C60.9171 16.0657 60.5897 16.7299 60.0945 17.2454C59.1364 18.2531 57.6593 18.6205 56.1982 18.6205C54.7372 18.6205 53.2596 18.2609 52.3014 17.2454C51.3432 16.2299 50.9919 14.6828 50.9919 13.1435C50.9919 11.6043 51.3355 10.0494 52.3014 9.04168C53.2678 8.03396 54.7367 7.66663 56.1982 7.66663C57.6598 7.66663 59.1364 8.02618 60.0945 9.04168C61.061 10.0572 61.4045 11.6043 61.4045 13.1435V13.1431Z" fill="currentColor"/>
|
||||
<path d="M80.242 18.6214C81.7035 18.6214 83.1801 18.4105 84.1383 17.809C85.0965 17.2075 85.4482 16.2931 85.4482 15.3869C85.4482 14.4807 85.1042 13.5585 84.1383 12.9647C83.1801 12.371 81.703 12.1518 80.242 12.1518C79.6186 12.1518 79.0438 12.0658 78.6366 11.8394C78.2294 11.6047 78.0778 11.2534 78.0778 10.9017C78.0778 10.5499 78.2216 10.1908 78.6366 9.9639C79.0438 9.72921 79.6749 9.65147 80.2973 9.65147C80.9198 9.65147 81.5509 9.73747 81.9591 9.9639C82.3663 10.1986 82.5179 10.5499 82.5179 10.9017H84.9531C84.9531 9.99499 84.6421 9.07327 83.7719 8.47951C82.9017 7.88576 81.5679 7.66663 80.2424 7.66663C78.9169 7.66663 77.5837 7.8775 76.713 8.47951C75.8427 9.08104 75.5308 9.99499 75.5308 10.9017C75.5308 11.8083 75.8423 12.73 76.713 13.3238C77.5832 13.9176 78.9165 14.1367 80.2424 14.1367C80.929 14.1367 81.688 14.2227 82.1428 14.4491C82.5985 14.676 82.7579 15.0351 82.7579 15.3869C82.7579 15.7387 82.5985 16.0977 82.1428 16.3246C81.688 16.5511 80.9931 16.6371 80.3066 16.6371C79.62 16.6371 78.9169 16.5511 78.4694 16.3246C78.0224 16.0982 77.8543 15.7387 77.8543 15.3869H75.0435C75.0435 16.2935 75.3865 17.2153 76.3534 17.809C77.3194 18.4028 78.7809 18.6214 80.2424 18.6214H80.242Z" fill="currentColor"/>
|
||||
<path d="M97.4733 13.1431V13.9948H91.0932V12.2996H95.3252C95.23 11.6825 95.006 11.1043 94.6071 10.682C94.0313 10.0727 93.1456 9.85409 92.2666 9.85409C91.3876 9.85409 90.5018 10.0727 89.927 10.682C89.3522 11.2913 89.1452 12.2213 89.1452 13.1435C89.1452 14.0658 89.3522 15.003 89.927 15.6046C90.5018 16.2061 91.3886 16.433 92.2666 16.433C93.1446 16.433 94.0313 16.2143 94.6071 15.6046C94.6863 15.5186 94.7587 15.4248 94.8301 15.331H97.1935C96.9855 16.0657 96.6585 16.7299 96.1639 17.2454C95.2057 18.2531 93.7281 18.6205 92.2666 18.6205C90.805 18.6205 89.3284 18.2609 88.3703 17.2454C87.4121 16.2299 87.0613 14.6828 87.0613 13.1435C87.0613 11.6043 87.4043 10.0494 88.3703 9.04168C89.3367 8.03396 90.806 7.66663 92.2666 7.66663C93.7272 7.66663 95.2057 8.02618 96.1639 9.04168C97.1298 10.0572 97.4729 11.6043 97.4729 13.1435L97.4733 13.1431Z" fill="currentColor"/>
|
||||
<path d="M109.499 13.1431V13.9948H103.119V12.2996H107.351C107.256 11.6825 107.032 11.1043 106.632 10.682C106.057 10.0727 105.172 9.85409 104.293 9.85409C103.414 9.85409 102.528 10.0727 101.953 10.682C101.378 11.2913 101.17 12.2213 101.17 13.1435C101.17 14.0658 101.378 15.003 101.953 15.6046C102.528 16.2061 103.415 16.433 104.293 16.433C105.171 16.433 106.057 16.2143 106.632 15.6046C106.712 15.5186 106.784 15.4248 106.856 15.331H109.22C109.012 16.0657 108.685 16.7299 108.19 17.2454C107.231 18.2531 105.754 18.6205 104.293 18.6205C102.831 18.6205 101.355 18.2609 100.396 17.2454C99.4382 16.2299 99.0864 14.6828 99.0864 13.1435C99.0864 11.6043 99.4295 10.0494 100.396 9.04168C101.362 8.03396 102.832 7.66663 104.293 7.66663C105.754 7.66663 107.231 8.02618 108.19 9.04168C109.156 10.0572 109.499 11.6043 109.499 13.1435V13.1431Z" fill="currentColor"/>
|
||||
<path d="M113.5 4.62817H111.104V18.6217H113.5V4.62817Z" fill="currentColor"/>
|
||||
<path d="M117.589 12.8154L121.517 18.6208H118.554L114.625 12.8154L118.554 8.15088H121.517L117.589 12.8154Z" fill="currentColor"/>
|
||||
<g clipPath="url(#dsh-wordmark-whale-clip)">
|
||||
<path d="M23.0584 4.95203C22.8129 4.83203 22.7074 5.06103 22.5639 5.17704C22.5149 5.21454 22.4734 5.26354 22.4319 5.30854C22.0734 5.69155 21.6543 5.94306 21.1073 5.91306C20.3073 5.86806 19.6243 6.11957 19.0203 6.73158C18.8918 5.97706 18.4652 5.52655 17.8162 5.23754C17.4767 5.08753 17.1332 4.93703 16.8952 4.61052C16.7292 4.37801 16.6837 4.11901 16.6007 3.8635C16.5477 3.70949 16.4952 3.55199 16.3177 3.52549C16.1252 3.49549 16.0497 3.65699 15.9742 3.792C15.6722 4.34401 15.5552 4.95203 15.5667 5.56805C15.5932 6.95359 16.1782 8.05712 17.3407 8.84215C17.4727 8.93215 17.5067 9.02215 17.4652 9.15366C17.3857 9.42416 17.2917 9.68667 17.2087 9.95718C17.1557 10.1297 17.0767 10.1677 16.8917 10.0922C16.2537 9.82568 15.7027 9.43117 15.2156 8.95465C14.3891 8.15513 13.6416 7.2726 12.7096 6.58158C12.4906 6.42007 12.2716 6.27007 12.045 6.12707C11.094 5.20354 12.1696 4.44502 12.4186 4.35501C12.6791 4.26101 12.5091 3.938 11.6675 3.942C10.826 3.9455 10.056 4.22751 9.07446 4.60302C8.93096 4.65952 8.77995 4.70052 8.62545 4.73452C7.73492 4.56552 6.80989 4.52802 5.84386 4.63702C4.02481 4.83953 2.57177 5.69955 1.50373 7.1676C0.220694 8.93215 -0.0813148 10.9372 0.288196 13.0283C0.676708 15.2323 1.80174 17.0569 3.53029 18.4834C5.32285 19.9625 7.38741 20.6875 9.74298 20.5485C11.1735 20.466 12.7661 20.2745 14.5626 18.7539C15.0156 18.9795 15.4912 19.0695 16.2797 19.137C16.8872 19.1935 17.4722 19.107 17.9252 19.013C18.6347 18.8629 18.5857 18.2059 18.3292 18.0854C16.2497 17.1169 16.7062 17.5109 16.2912 17.1919C17.3477 15.9419 18.9618 13.7198 19.4598 10.6942C19.5088 10.3602 19.5713 9.88968 19.5638 9.61917C19.5598 9.45417 19.5978 9.39016 19.7863 9.37116C20.3073 9.31116 20.8128 9.16866 21.2773 8.91315C22.6249 8.17713 23.1684 6.96809 23.2964 5.51905C23.3154 5.29754 23.2924 5.06853 23.0584 4.95203ZM11.3165 17.9954C9.30097 16.4109 8.32344 15.8894 7.91992 15.9119C7.54241 15.9344 7.61042 16.3664 7.69342 16.6479C7.78042 16.9259 7.89342 17.1174 8.05193 17.3614C8.16143 17.5229 8.23694 17.7629 7.94243 17.9434C7.29341 18.3449 6.16487 17.8084 6.11187 17.7819C4.79833 17.0084 3.7003 15.9874 2.92628 14.5908C2.17875 13.2468 1.74474 11.8047 1.67324 10.2657C1.65424 9.89418 1.76374 9.76267 2.13375 9.69517C2.62077 9.60517 3.12278 9.58617 3.6093 9.65767C5.66636 9.95818 7.41741 10.8777 8.88545 12.3348C9.72348 13.1643 10.3575 14.1558 11.0105 15.1243C11.705 16.1529 12.4521 17.1329 13.4036 17.9364C13.7396 18.2179 14.0076 18.4319 14.2641 18.5899C13.4906 18.6764 12.1996 18.6949 11.3165 17.9964V17.9954ZM12.2826 11.7817C12.2826 11.6167 12.4146 11.4852 12.5806 11.4852C12.6181 11.4852 12.6521 11.4927 12.6826 11.5037C12.7241 11.5187 12.7621 11.5412 12.7921 11.5752C12.8451 11.6277 12.8751 11.7027 12.8751 11.7817C12.8751 11.9467 12.7431 12.0782 12.5771 12.0782C12.4111 12.0782 12.2826 11.9467 12.2826 11.7817ZM15.2831 13.3208C15.0906 13.3998 14.8981 13.4673 14.7131 13.4748C14.4261 13.4898 14.1131 13.3733 13.9431 13.2308C13.6791 13.0093 13.4901 12.8853 13.4111 12.4988C13.3771 12.3338 13.3961 12.0782 13.4261 11.9317C13.4941 11.6162 13.4186 11.4137 13.1961 11.2297C13.0151 11.0797 12.7846 11.0382 12.5316 11.0382C12.4371 11.0382 12.3506 10.9967 12.2861 10.9632C12.1806 10.9107 12.0936 10.7792 12.1766 10.6177C12.2031 10.5652 12.3316 10.4377 12.3616 10.4152C12.7051 10.2197 13.1011 10.2837 13.4676 10.4302C13.8071 10.5692 14.0641 10.8242 14.4336 11.1847C14.8111 11.6202 14.8791 11.7402 15.0941 12.0672C15.2641 12.3228 15.4186 12.5853 15.5247 12.8858C15.5887 13.0733 15.5057 13.2268 15.2831 13.3208Z" fill="currentColor"/>
|
||||
</g>
|
||||
<rect x="129.348" y="5.5" width="52" height="14" rx="2" fill="currentColor"/>
|
||||
<g clipPath="url(#dsh-wordmark-badge-clip)">
|
||||
<path d="M132.848 8.93205H134.08V16.137H132.848V8.93205ZM136.5 8.93205H137.732V16.137H136.5V8.93205ZM133.365 13.024V11.99H137.193V13.024H133.365Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
<path d="M140.397 14.432L140.672 13.453H143.202L143.532 14.432H140.397ZM140.287 16.137H139.055L141.277 8.93205H142.201L142.146 9.74605L140.947 13.915H140.969L140.287 16.137ZM145.039 16.137H143.741L143.07 13.948L143.081 13.937L141.871 9.74605L141.926 8.93205H142.817L145.039 16.137Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
<path d="M146.846 8.93205H149.068C149.852 8.93205 150.443 9.11538 150.839 9.48205C151.235 9.84138 151.433 10.3327 151.433 10.956C151.433 11.22 151.396 11.4657 151.323 11.693C151.249 11.9204 151.125 12.1257 150.949 12.309C150.773 12.4924 150.531 12.65 150.223 12.782C149.922 12.9067 149.541 13.0057 149.079 13.079V13.321H146.846V12.639L148.023 12.485C148.631 12.4044 149.09 12.298 149.398 12.166C149.706 12.034 149.915 11.8764 150.025 11.693C150.135 11.5024 150.19 11.2934 150.19 11.066C150.19 10.6994 150.083 10.417 149.871 10.219C149.658 10.021 149.324 9.92205 148.87 9.92205H146.846V8.93205ZM146.395 8.93205H147.627V16.137H146.395V8.93205ZM151.917 16.093V16.137H150.366L149.024 14.322C148.87 14.1094 148.73 13.9407 148.606 13.816C148.481 13.684 148.345 13.5887 148.199 13.53C148.052 13.464 147.872 13.42 147.66 13.398C147.447 13.3687 147.176 13.3504 146.846 13.343V13.145H149.079C149.233 13.211 149.368 13.2844 149.486 13.365C149.61 13.4457 149.735 13.5447 149.86 13.662C149.992 13.7794 150.138 13.937 150.3 14.135L151.917 16.093Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
<path d="M153.58 9.57005L153.591 8.93205H154.46L157.584 15.51V16.137H156.704L153.58 9.57005ZM158.024 16.137H156.968L156.88 8.93205H158.024V16.137ZM154.24 16.137H153.096V8.93205H154.152L154.24 16.137Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
<path d="M159.963 8.93205H161.206V16.137H159.963V8.93205ZM160.095 9.96605V8.93205H164.858V9.96605H160.095ZM160.095 16.137V15.103H164.902V16.137H160.095ZM160.095 13.013V11.99H164.374V13.013H160.095Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
<path d="M169.052 15.257C169.543 15.257 169.895 15.1654 170.108 14.982C170.328 14.7987 170.438 14.5457 170.438 14.223C170.438 14.047 170.405 13.8967 170.339 13.772C170.273 13.6474 170.152 13.5337 169.976 13.431C169.807 13.321 169.558 13.2147 169.228 13.112L168.491 12.881C167.846 12.6757 167.38 12.4044 167.094 12.067C166.808 11.7297 166.665 11.3007 166.665 10.78C166.665 10.428 166.76 10.1017 166.951 9.80105C167.142 9.50038 167.428 9.25838 167.809 9.07505C168.19 8.89172 168.663 8.80005 169.228 8.80005C169.631 8.80005 169.998 8.82938 170.328 8.88805C170.665 8.93938 171.039 9.01638 171.45 9.11905L171.274 10.175C170.834 10.0504 170.442 9.96238 170.097 9.91105C169.76 9.85238 169.463 9.82305 169.206 9.82305C168.737 9.82305 168.403 9.90738 168.205 10.076C168.007 10.2374 167.908 10.439 167.908 10.681C167.908 10.857 167.941 11.0147 168.007 11.154C168.073 11.286 168.19 11.407 168.359 11.517C168.535 11.627 168.784 11.7334 169.107 11.836L169.866 12.078C170.526 12.276 170.995 12.5327 171.274 12.848C171.553 13.156 171.692 13.585 171.692 14.135C171.692 14.5604 171.589 14.9344 171.384 15.257C171.179 15.5797 170.878 15.8327 170.482 16.016C170.093 16.1994 169.609 16.291 169.03 16.291C168.627 16.291 168.212 16.247 167.787 16.159C167.362 16.071 166.9 15.9427 166.401 15.774L166.665 14.718C167.156 14.894 167.6 15.0297 167.996 15.125C168.399 15.213 168.751 15.257 169.052 15.257Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
<path d="M175.809 15.257C176.3 15.257 176.652 15.1654 176.865 14.982C177.085 14.7987 177.195 14.5457 177.195 14.223C177.195 14.047 177.162 13.8967 177.096 13.772C177.03 13.6474 176.909 13.5337 176.733 13.431C176.564 13.321 176.315 13.2147 175.985 13.112L175.248 12.881C174.603 12.6757 174.137 12.4044 173.851 12.067C173.565 11.7297 173.422 11.3007 173.422 10.78C173.422 10.428 173.517 10.1017 173.708 9.80105C173.899 9.50038 174.185 9.25838 174.566 9.07505C174.947 8.89172 175.42 8.80005 175.985 8.80005C176.388 8.80005 176.755 8.82938 177.085 8.88805C177.422 8.93938 177.796 9.01638 178.207 9.11905L178.031 10.175C177.591 10.0504 177.199 9.96238 176.854 9.91105C176.517 9.85238 176.22 9.82305 175.963 9.82305C175.494 9.82305 175.16 9.90738 174.962 10.076C174.764 10.2374 174.665 10.439 174.665 10.681C174.665 10.857 174.698 11.0147 174.764 11.154C174.83 11.286 174.947 11.407 175.116 11.517C175.292 11.627 175.541 11.7334 175.864 11.836L176.623 12.078C177.283 12.276 177.752 12.5327 178.031 12.848C178.31 13.156 178.449 13.585 178.449 14.135C178.449 14.5604 178.346 14.9344 178.141 15.257C177.936 15.5797 177.635 15.8327 177.239 16.016C176.85 16.1994 176.366 16.291 175.787 16.291C175.384 16.291 174.969 16.247 174.544 16.159C174.119 16.071 173.657 15.9427 173.158 15.774L173.422 14.718C173.913 14.894 174.357 15.0297 174.753 15.125C175.156 15.213 175.508 15.257 175.809 15.257Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="dsh-wordmark-whale-clip">
|
||||
<rect width="23.16" height="17.0435" fill="white" transform="translate(0.141602 3.52185)"/>
|
||||
</clipPath>
|
||||
<clipPath id="dsh-wordmark-badge-clip">
|
||||
<rect width="46" height="14" fill="white" transform="translate(132.348 5.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
38
packages/client/ui-primitives/src/Tooltip.module.css
Normal file
38
packages/client/ui-primitives/src/Tooltip.module.css
Normal file
@@ -0,0 +1,38 @@
|
||||
/* Visual spec mirrors deepsuite @deepseek/ui Tooltip.css (size m, no arrow),
|
||||
except padding tightened 6/12 -> 4/8 and radius 10 -> 8 by product ruling:
|
||||
tooltip-bg plate,
|
||||
one text color across both themes (the plate stays dark in light and dark
|
||||
mode). Behavior (fixed positioning off the anchor rect) is local — the
|
||||
upstream Floating stack is intentionally not vendored. */
|
||||
|
||||
.bubble {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-tooltip-bg);
|
||||
color: var(--dsw-static-neutral-bluish-00);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
animation: tooltip-in 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.bubble[data-side='right'] {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.bubble[data-side='bottom'] {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
@keyframes tooltip-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bubble {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
77
packages/client/ui-primitives/src/Tooltip.tsx
Normal file
77
packages/client/ui-primitives/src/Tooltip.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
// Hover/focus label bubble (figma tooltip pill: dark plate, white text).
|
||||
// TODO: interaction is a placeholder (no show delay, no flip on viewport
|
||||
// collision, no arrow) — visuals and behavior get a proper pass later.
|
||||
// The anchor is the child element itself (cloneElement, no wrapper node), so
|
||||
// attaching a tooltip never changes the anchor's layout context. The bubble is
|
||||
// position:fixed and coordinates come from the anchor's rect at show time, so
|
||||
// it escapes ancestor overflow clipping (the sidebar rail clips its column)
|
||||
// without a portal.
|
||||
|
||||
import { cloneElement, useEffect, useRef, useState } from 'react'
|
||||
import type { FocusEventHandler, MouseEventHandler, ReactElement, Ref } from 'react'
|
||||
import css from './Tooltip.module.css'
|
||||
|
||||
/** Bubble placement relative to the anchor. */
|
||||
export type TooltipSide = 'right' | 'bottom'
|
||||
|
||||
/** Props Tooltip injects into its anchor child; the child's own handlers are chained ahead of the tooltip's. */
|
||||
interface AnchorProps {
|
||||
ref?: Ref<HTMLElement> | undefined
|
||||
onMouseEnter?: MouseEventHandler | undefined
|
||||
onMouseLeave?: MouseEventHandler | undefined
|
||||
onFocus?: FocusEventHandler | undefined
|
||||
onBlur?: FocusEventHandler | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a hover/focus tooltip to an anchor element.
|
||||
* @param props.label - bubble text.
|
||||
* @param props.side - placement relative to the anchor (default 'right').
|
||||
* @param props.disabled - suppress the bubble while true; the anchor renders identically so toggling never remounts it (which would cut its CSS transitions).
|
||||
* @param props.children - a single anchor element. Tooltip owns its ref (no current consumer passes one).
|
||||
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
|
||||
*/
|
||||
export function Tooltip({ label, side = 'right', disabled = false, children }: { label: string; side?: TooltipSide; disabled?: boolean; children: ReactElement<AnchorProps> }) {
|
||||
const anchor = useRef<HTMLElement | null>(null)
|
||||
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
|
||||
// Hover and focus are independent triggers: the bubble hides only after
|
||||
// BOTH clear (hovering away from a focused anchor must not drop it).
|
||||
const triggers = useRef({ hover: false, focus: false })
|
||||
|
||||
// Disabling mid-hover (e.g. clicking a rail control expands the sidebar)
|
||||
// must drop an already-visible bubble: no mouseleave fires.
|
||||
useEffect(() => {
|
||||
if (disabled) { triggers.current = { hover: false, focus: false }; setPos(null) }
|
||||
}, [disabled])
|
||||
|
||||
const show = () => {
|
||||
if (disabled) return
|
||||
const el = anchor.current
|
||||
/* v8 ignore next -- the ref is attached by event time: events fire on the cloned anchor. */
|
||||
if (el === null) return
|
||||
const r = el.getBoundingClientRect()
|
||||
setPos(side === 'right'
|
||||
? { x: r.right + 10, y: r.top + r.height / 2 }
|
||||
: { x: r.left + r.width / 2, y: r.bottom + 8 })
|
||||
}
|
||||
const hide = () => {
|
||||
if (!triggers.current.hover && !triggers.current.focus) setPos(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{cloneElement(children, {
|
||||
ref: anchor,
|
||||
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() },
|
||||
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; hide() },
|
||||
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() },
|
||||
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
|
||||
})}
|
||||
{pos !== null && (
|
||||
<span className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -165,6 +165,16 @@ export const IconChevronRightOutline14 = ({ size = 14, className }: IconProps) =
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_triangle_right_fill_14 — tree expand arrow; points right, consumers rotate it 90° for the open state. */
|
||||
export const IconTriangleRightFill14 = ({ size = 14, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M4.25 2.82782L4.25 11.1722C4.25 11.6622 4.84243 11.9076 5.18891 11.5611L9.36109 7.38891C9.57588 7.17412 9.57588 6.82588 9.36109 6.61109L5.18891 2.43891C4.84243 2.09243 4.25 2.33782 4.25 2.82782Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_chevron_up_outline_14 */
|
||||
export const IconChevronUpOutline14 = ({ size = 14, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -552,11 +562,11 @@ export const IconProjectAddOutline16 = ({ size = 16, className }: IconProps) =>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** folder_open_16 (figma extract) */
|
||||
/** folder_open_16 (figma extract): outline at full ink + 20%-opacity inner fill riding the same currentColor. */
|
||||
export const IconFolderOpen16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
||||
<path transform="translate(0.5996 1.645)" d="M4.69624 0C5.3113 0.000140941 5.88623 0.307626 6.22749 0.819336L6.69917 1.52734C6.78449 1.65523 6.92823 1.7324 7.08198 1.73242L11.6699 1.73242C13.0038 1.73257 14.0859 2.81452 14.0859 4.14844L14.0859 5.05566C14.7693 5.4559 15.1595 6.2791 14.9374 7.11621L13.8837 11.0869C13.6026 12.1454 12.644 12.8818 11.5488 12.8818L2.41596 12.8818C1.01395 12.8816 -0.0511855 11.7074 0.00190073 10.376L0.00190073 2.41602C0.00190073 1.08201 1.08391 0 2.41792 0L4.69624 0ZM3.27827 6.18457C2.80902 6.18474 2.39772 6.50054 2.27729 6.9541L1.41499 10.2012C1.2407 10.8579 1.73653 11.5017 2.41596 11.502L11.5488 11.502C12.0182 11.502 12.4293 11.1861 12.5498 10.7324L13.6035 6.7627C13.681 6.47081 13.4611 6.18474 13.1591 6.18457L3.27827 6.18457ZM2.41792 1.38086C1.8462 1.38086 1.38276 1.8443 1.38276 2.41602L1.38276 5.72266C1.83056 5.15603 2.52166 4.80383 3.27827 4.80371L12.705 4.80371L12.705 4.14844C12.705 3.57681 12.2415 3.11342 11.6699 3.11328L7.08198 3.11328C6.46674 3.11326 5.89205 2.80484 5.55073 2.29297L5.07905 1.58496C4.99378 1.45723 4.84981 1.381 4.69624 1.38086L2.41792 1.38086Z" fill="currentColor"/>
|
||||
<path transform="translate(1.979 3.026)" d="M11.7793 4.80371C12.0811 4.80388 12.3008 5.09009 12.2236 5.38184L11.1699 9.35156C11.0494 9.80525 10.6383 10.1211 10.1689 10.1211L1.03612 10.1211C0.356864 10.1206 -0.139141 9.47695 0.0351403 8.82031L0.897445 5.57324C1.01797 5.12 1.42946 4.80406 1.89842 4.80371L11.7793 4.80371ZM3.31639 0C3.46985 0.000107244 3.61388 0.0765707 3.6992 0.204102L4.17088 0.912109C4.51213 1.42391 5.08701 1.73228 5.70213 1.73242L10.29 1.73242C10.8616 1.73251 11.325 2.19605 11.3252 2.76758L11.3252 3.42285L1.89842 3.42285C1.14203 3.42309 0.450638 3.77535 0.00291371 4.3418L0.00291371 1.03516C0.00307753 0.463694 0.466614 0.000188756 1.03807 0L3.31639 0Z" fill="currentColor"/>
|
||||
<path d="M5.19629 1.57104C5.81144 1.5711 6.38623 1.8786 6.72754 2.39038L7.19922 3.09839C7.28454 3.22635 7.42824 3.30344 7.58203 3.30347H12.1699C13.5039 3.30348 14.5859 4.38548 14.5859 5.71948V6.62671C15.2694 7.02689 15.6605 7.85012 15.4385 8.68726L14.3848 12.658C14.1037 13.7164 13.1449 14.4527 12.0498 14.4529H2.91699C1.51651 14.4529 0.451662 13.2814 0.501954 11.9519V3.98706C0.501954 2.65305 1.58396 1.57104 2.91797 1.57104H5.19629ZM3.7793 7.75562C3.30994 7.75562 2.89883 8.07153 2.77832 8.52515L1.91602 11.7722C1.74167 12.4291 2.23734 13.073 2.91699 13.073H12.0498C12.5191 13.0728 12.9304 12.757 13.0508 12.3035L14.1045 8.33374C14.1819 8.04202 13.9619 7.756 13.6602 7.75562H3.7793ZM2.91797 2.9519C2.34625 2.9519 1.88281 3.41534 1.88281 3.98706V7.2937C2.33068 6.7269 3.02249 6.37476 3.7793 6.37476H13.2051V5.71948C13.2051 5.14777 12.7416 4.68434 12.1699 4.68433H7.58203C6.96675 4.6843 6.39209 4.37595 6.05078 3.86401L5.5791 3.15601C5.49379 3.02821 5.34995 2.95196 5.19629 2.9519H2.91797Z" fill="currentColor"/>
|
||||
<path opacity="0.2" d="M13.6602 7.75525C13.9618 7.7556 14.1815 8.04179 14.1045 8.33337L13.0508 12.3031C12.9304 12.7567 12.5191 13.0725 12.0498 13.0726H2.91701C2.23744 13.0725 1.7417 12.4287 1.91603 11.7719L2.77834 8.52478C2.89898 8.07146 3.31018 7.75532 3.77931 7.75525H13.6602ZM5.1963 2.95154C5.34985 2.95159 5.49377 3.02803 5.57912 3.15564L6.0508 3.86365C6.39205 4.37553 6.96685 4.68385 7.58205 4.68396H12.1699C12.7416 4.68396 13.2049 5.14754 13.2051 5.71912V6.37439H3.77931C3.02267 6.37444 2.33067 6.72671 1.88283 7.29333V3.98669C1.88299 3.4152 2.34649 2.95168 2.91798 2.95154H5.1963Z" fill="currentColor"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ export { Menu } from './Menu.tsx'
|
||||
export type { MenuItem } from './Menu.tsx'
|
||||
export { ConnectionBanner } from './ConnectionBanner.tsx'
|
||||
export { FishLogo } from './FishLogo.tsx'
|
||||
export { BrandWordmark } from './BrandWordmark.tsx'
|
||||
export { Tooltip } from './Tooltip.tsx'
|
||||
export type { TooltipSide } from './Tooltip.tsx'
|
||||
export { JsonBlock } from './markdown/JsonBlock.tsx'
|
||||
export { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
export { MessageText } from './markdown/MessageText.tsx'
|
||||
|
||||
@@ -14,8 +14,8 @@ const icons = Object.fromEntries(
|
||||
const iconNames = Object.keys(icons)
|
||||
|
||||
describe('ic_ds_ icon set', () => {
|
||||
it('exports the full P-I set (43 deepsuite + 6 figma extracts)', () => {
|
||||
expect(iconNames.length).toBe(49)
|
||||
it('exports the full P-I set (43 deepsuite + 7 figma extracts)', () => {
|
||||
expect(iconNames.length).toBe(50)
|
||||
})
|
||||
|
||||
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', name => {
|
||||
|
||||
123
packages/client/ui-primitives/tests/tooltip.spec.tsx
Normal file
123
packages/client/ui-primitives/tests/tooltip.spec.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('Tooltip', () => {
|
||||
it('shows the bubble to the right on hover and hides it on leave', () => {
|
||||
render(
|
||||
<Tooltip label="Open sidebar">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
const anchor = screen.getByText('anchor')
|
||||
fireEvent.mouseEnter(anchor)
|
||||
const bubble = screen.getByRole('tooltip')
|
||||
expect(bubble.textContent).toBe('Open sidebar')
|
||||
expect(bubble.getAttribute('data-side')).toBe('right')
|
||||
// jsdom rects are all-zero: right placement lands at the +10 gutter.
|
||||
expect(bubble.style.left).toBe('10px')
|
||||
expect(bubble.style.top).toBe('0px')
|
||||
fireEvent.mouseLeave(anchor)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
it('supports bottom placement and the focus/blur channel', () => {
|
||||
render(
|
||||
<Tooltip label="Below" side="bottom">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
const anchor = screen.getByText('anchor')
|
||||
fireEvent.focus(anchor)
|
||||
const bubble = screen.getByRole('tooltip')
|
||||
expect(bubble.getAttribute('data-side')).toBe('bottom')
|
||||
expect(bubble.style.left).toBe('0px')
|
||||
expect(bubble.style.top).toBe('8px')
|
||||
fireEvent.blur(anchor)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => {
|
||||
const onMouseEnter = vi.fn()
|
||||
const onMouseLeave = vi.fn()
|
||||
const onFocus = vi.fn()
|
||||
const onBlur = vi.fn()
|
||||
render(
|
||||
<Tooltip label="Chained">
|
||||
<button type="button" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onFocus={onFocus} onBlur={onBlur}>anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
const anchor = screen.getByText('anchor')
|
||||
fireEvent.mouseEnter(anchor)
|
||||
fireEvent.mouseLeave(anchor)
|
||||
fireEvent.focus(anchor)
|
||||
fireEvent.blur(anchor)
|
||||
expect(onMouseEnter).toHaveBeenCalledOnce()
|
||||
expect(onMouseLeave).toHaveBeenCalledOnce()
|
||||
expect(onFocus).toHaveBeenCalledOnce()
|
||||
expect(onBlur).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('suppresses the bubble while disabled without remounting the anchor', () => {
|
||||
const { rerender } = render(
|
||||
<Tooltip label="Rail" disabled>
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
const anchor = screen.getByText('anchor')
|
||||
fireEvent.mouseEnter(anchor)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
rerender(
|
||||
<Tooltip label="Rail">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
// Same DOM node: toggling disabled never remounted the anchor.
|
||||
expect(screen.getByText('anchor')).toBe(anchor)
|
||||
fireEvent.mouseEnter(anchor)
|
||||
expect(screen.getByRole('tooltip')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the bubble while either hover or focus is still active', () => {
|
||||
render(
|
||||
<Tooltip label="Sticky">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
const anchor = screen.getByText('anchor')
|
||||
// Focused AND hovered: leaving with the mouse must not drop the bubble.
|
||||
fireEvent.focus(anchor)
|
||||
fireEvent.mouseEnter(anchor)
|
||||
fireEvent.mouseLeave(anchor)
|
||||
expect(screen.getByRole('tooltip')).toBeTruthy()
|
||||
fireEvent.blur(anchor)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
// Symmetric: blurring while still hovered keeps it, mouseleave ends it.
|
||||
fireEvent.mouseEnter(anchor)
|
||||
fireEvent.focus(anchor)
|
||||
fireEvent.blur(anchor)
|
||||
expect(screen.getByRole('tooltip')).toBeTruthy()
|
||||
fireEvent.mouseLeave(anchor)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
it('drops an already-visible bubble when disabled flips mid-hover', () => {
|
||||
const { rerender } = render(
|
||||
<Tooltip label="Rail">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
expect(screen.getByRole('tooltip')).toBeTruthy()
|
||||
// e.g. clicking a rail control expands the sidebar: no mouseleave fires.
|
||||
rerender(
|
||||
<Tooltip label="Rail" disabled>
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -14,8 +14,13 @@ import { QuestionComposer } from './QuestionComposer.tsx'
|
||||
export { PendingQuestion } from './contract/slots.ts'
|
||||
export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
|
||||
|
||||
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
|
||||
export const inject = ['slots']
|
||||
/**
|
||||
* Required services (cordis fiber inject). 'conversation' is an ordering
|
||||
* edge, not a call dependency: the 'conversation.composer' chain slot is
|
||||
* declared by ui-conversation's apply, and register() into an undeclared
|
||||
* slot throws — service waiting orders this apply after the declaring one.
|
||||
*/
|
||||
export const inject = ['slots', 'conversation']
|
||||
|
||||
/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
|
||||
function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null {
|
||||
|
||||
@@ -23,17 +23,23 @@ async function bench() {
|
||||
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
|
||||
() => null,
|
||||
)
|
||||
// 'conversation' inject is an ordering edge (the declaring plugin provides
|
||||
// it after declaring the chain); the bench declares the chain itself.
|
||||
ctx.provide('conversation', {})
|
||||
return { ctx, slots }
|
||||
}
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slots'])
|
||||
expect(inject).toEqual(['slots', 'conversation'])
|
||||
})
|
||||
|
||||
it('fails loud when no live entry has declared the composer slot', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
// Satisfy the ordering inject without declaring the chain: apply must
|
||||
// then hit the undeclared-slot throw, not sit waiting on the service.
|
||||
ctx.provide('conversation', {})
|
||||
await expect(ctx.plugin({ inject: [...inject], apply }))
|
||||
.rejects.toThrow(/slot "conversation.composer" is not declared/)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-client-ui-sidebar
|
||||
|
||||
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Collapse morphs the four control rows into the layout-owned 56px rail (expand / new session / new workspace / search — search expands and focuses the search box) plus the settings foot: geometry animates on the deepsuite curve while wide-only content cross-fades and unmounts at settle. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
|
||||
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Collapse is a slide + crossfade into the layout-owned 56px rail (open / new session / new workspace / search — search expands and focuses the search box — plus the settings foot): the expanded content freezes at its width and fades in place while the column slides over it, then the rail — whale mark resting, panel icon on hover, tooltips on every control — crossfades in at settle as the wide content unmounts. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
|
||||
|
||||
`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx.
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
],
|
||||
"platform": "web"
|
||||
@@ -34,21 +35,25 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"clsx": "^2.0.0",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -24,12 +24,38 @@
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
/* Two-line row: the leading slot (folder/chevron), title, and trailing
|
||||
actions all top-align on the 20px first text line (figma cell) — content
|
||||
is 42px (20 + 2 + 20), so 6px vertical padding centers the block. */
|
||||
.projectRow {
|
||||
height: 54px;
|
||||
align-items: flex-start;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.projectRow .rowActions {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Session cell (figma): pad 8, adjacent 16px twist + status slots, then a 4px
|
||||
gap to the title — the slots butt together, so the row gap is zeroed and
|
||||
the title carries its own margins. */
|
||||
.sessionRow {
|
||||
height: 34px;
|
||||
gap: 0;
|
||||
/* Mount fade: session rows appear by unfolding a group (or the tree
|
||||
mounting). Stable row keys keep already-visible rows from replaying it. */
|
||||
animation: row-in 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.sessionRow .title {
|
||||
margin: 0 6px 0 4px;
|
||||
}
|
||||
|
||||
@keyframes row-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
.slot {
|
||||
@@ -47,11 +73,20 @@
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
/* Project leading slot: folder by default, chevron on row hover. */
|
||||
/* Project leading slot: folder by default, expand arrow on row hover. */
|
||||
.projectRow .chevron { display: none; }
|
||||
.projectRow:hover .chevron { display: inline-flex; }
|
||||
.projectRow:hover .folder { display: none; }
|
||||
|
||||
/* Expand arrow (filled triangle): points right closed, rotates to point down open. */
|
||||
.arrow {
|
||||
transition: transform 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.arrowOpen {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.projectText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -131,22 +166,25 @@
|
||||
}
|
||||
|
||||
/* Session expand twist occupies the leading 16px slot; keep a spacer when absent
|
||||
so titles align across sibling rows. */
|
||||
so titles align across sibling rows. Duplicates the .iconButton reset instead
|
||||
of `composes:` — the tsdown CSS-modules pipeline drops composes mappings, which
|
||||
left the raw UA button box showing. */
|
||||
.twist {
|
||||
composes: iconButton;
|
||||
width: 16px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* "L" connector slot (figma arrow 14:3071): 16x16, glyph right-aligned. */
|
||||
.cornerSlot {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.twist:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Chevrons and tree twists ride the caption grey (#ADB2B8); the folder glyph
|
||||
@@ -156,3 +194,11 @@
|
||||
.twist {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sessionRow,
|
||||
.arrow {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,15 @@
|
||||
*/
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconChevronDownOutline14, IconChevronRightOutline14,
|
||||
IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
|
||||
IconTreeCorner8x10, StateDot,
|
||||
IconTriangleRightFill14, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ProjectRow, SessionRow } from './tree.ts'
|
||||
import { formatRelativeTime } from './tree.ts'
|
||||
import css from './Rows.module.css'
|
||||
|
||||
/** Indent step per tree level: 16px slot + 6px gap (figma). */
|
||||
const INDENT_STEP = 22
|
||||
/** Indent step per tree level: one 16px slot (figma session cell). */
|
||||
const INDENT_STEP = 16
|
||||
|
||||
/**
|
||||
* Project (workspace) row: 54px, folder + title + session count; hover
|
||||
@@ -38,7 +37,7 @@ export function ProjectRowItem({ row, active, onToggle, onCreate }: {
|
||||
{row.expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />}
|
||||
</span>
|
||||
<span className={clsx(css.slot, css.chevron)}>
|
||||
{row.expanded ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
|
||||
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
|
||||
</span>
|
||||
<span className={css.projectText}>
|
||||
<span className={css.title}>{row.label}</span>
|
||||
@@ -79,17 +78,16 @@ export function SessionRowItem({ row, selected, now, onOpen, onToggle }: {
|
||||
onOpen: () => void
|
||||
onToggle: () => void
|
||||
}) {
|
||||
// Rail (figma sub-cell slot sequence): twist slot, always-reserved state
|
||||
// slot (opacity-0 slots keep their 22px in figma, so titles align whether
|
||||
// or not the dot is lit), then the L connector on child rows. Extra depth
|
||||
// rides the left padding: indent spacers = depth - 1.
|
||||
// Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to
|
||||
// the title): both slots are always reserved so titles align whether or not
|
||||
// the twist/dot is lit. Extra depth rides the left padding.
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.sessionRow, selected && css.selected)}
|
||||
role="treeitem"
|
||||
aria-selected={selected}
|
||||
{...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})}
|
||||
style={{ paddingLeft: 8 + Math.max(0, row.depth - 1) * INDENT_STEP }}
|
||||
style={{ paddingLeft: 8 + row.depth * INDENT_STEP }}
|
||||
onClick={onOpen}
|
||||
>
|
||||
{row.hasChildren
|
||||
@@ -100,16 +98,11 @@ export function SessionRowItem({ row, selected, now, onOpen, onToggle }: {
|
||||
aria-label={row.expanded ? 'Collapse' : 'Expand'}
|
||||
onClick={(e) => { e.stopPropagation(); onToggle() }}
|
||||
>
|
||||
{row.expanded ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
|
||||
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
|
||||
</button>
|
||||
)
|
||||
: <span className={css.slot} />}
|
||||
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
|
||||
{row.depth > 0 && (
|
||||
<span className={css.cornerSlot} data-tree-corner="">
|
||||
<IconTreeCorner8x10 />
|
||||
</span>
|
||||
)}
|
||||
<span className={css.title}>{row.title}</span>
|
||||
<span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
|
||||
<span className={css.rowActions}>
|
||||
|
||||
@@ -1,41 +1,62 @@
|
||||
/* Sidebar column (figma 133:7629): vertical stack, padding 16/6, sidebar
|
||||
fill + 1px right border painted by the layout column. Collapse morphs in
|
||||
place: the four control rows persist into the 56px rail (one icon each,
|
||||
x-converged by the shrinking column), geometry rides the deepsuite curve
|
||||
while wide-only content cross-fades 200ms; explicit margins own the
|
||||
vertical rhythm in both states so every gap can transition. */
|
||||
/* Sidebar column (figma 133:7629): vertical stack, padding 12/6, sidebar
|
||||
fill + 1px right border painted by the layout column. Collapse is a
|
||||
slide + crossfade, not a morph: the content holds its frozen expanded
|
||||
layout (inline width set by the component) and fades in place (.fading)
|
||||
while the sliding column (AppFrame grid tracks) clips it; the rail layout
|
||||
(.collapsed) only applies after the fade settles, so nothing reflows
|
||||
mid-slide. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 6px 16px;
|
||||
padding: 6px 12px;
|
||||
box-sizing: border-box;
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 14px;
|
||||
transition: padding var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* Rail geometry (figma rail spec): 36x36 control boxes centered in the 56px
|
||||
rail (10px side padding), 12px vertical rhythm, 18px from the rail top to
|
||||
the whale's box (24px to the 24-wide whale glyph itself). */
|
||||
.root.collapsed {
|
||||
padding-top: 14px;
|
||||
padding: 18px 10px 6px;
|
||||
}
|
||||
|
||||
/* Wide-only content: fades ahead of the geometry (200ms vs 300ms) and
|
||||
unmounts once the collapse settles; remounts fade back in. */
|
||||
/* Collapse phase 1: the whole frozen-width content fades out in place over
|
||||
150ms; at settle the children unmount/snap to the rail layout. */
|
||||
.fading > * {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* Wide-only content fades back in on expand remount. */
|
||||
.wide {
|
||||
animation: wide-in 200ms var(--ds-ease-in-out);
|
||||
transition: opacity 200ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.collapsed .wide {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes wide-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Rail controls hold hidden while the column slides shut, then fade in over
|
||||
the slide's tail: .railIn applies at settle (150ms into the 0.3s AppFrame
|
||||
track transition), so a 100ms delay + 150ms fade starts just before the
|
||||
slide ends (250ms) and finishes at 400ms; `backwards` keeps them at
|
||||
opacity 0 through the delay. Only a live collapse gets .railIn — a
|
||||
refresh straight into the collapsed state renders statically. */
|
||||
.railIn .iconButton,
|
||||
.railIn .newSession,
|
||||
.railIn .searchButton,
|
||||
.railIn .foot {
|
||||
animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards;
|
||||
}
|
||||
|
||||
@keyframes rail-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Logo row (figma pad (4,8,4,8)): brand left, panel toggle right-anchored —
|
||||
the toggle is the rail's expand control and slides in with the right edge. */
|
||||
.logoRow {
|
||||
@@ -45,23 +66,19 @@
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
height: 60px;
|
||||
padding: 8px 4px;
|
||||
padding: 8px 0 8px 4px;
|
||||
margin-bottom: 16px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.collapsed .logoRow {
|
||||
height: 24px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Brand group (figma I133:7632): fish + wordmark ride the text ink
|
||||
/* Brand group (figma I133:7632): the full wordmark rides the text ink
|
||||
(figma-flows ruling: main-screen instance is black; blue is brand
|
||||
emphasis only). */
|
||||
.brand {
|
||||
@@ -69,28 +86,9 @@
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* HARNESS badge (figma 34:10358): 14px tall, mono 11/500 on primary fill. */
|
||||
.badge {
|
||||
flex: none;
|
||||
padding: 0 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-label-primary-inverted);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
@@ -104,9 +102,6 @@
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
transition:
|
||||
width var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
height var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.iconButton:hover {
|
||||
@@ -114,12 +109,33 @@
|
||||
}
|
||||
|
||||
.collapsed .iconButton {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
/* New Session: 38px capsule (figma 133:7634) morphing into the rail's plain
|
||||
icon control — border and fill fade with the label. */
|
||||
/* Rail logo swap: collapsed, the toggle rests as the whale mark (brand ink,
|
||||
no hover circle) and hovering reveals the panel icon — the expand
|
||||
affordance (figma sidebar-hover flow). Expanded it is a plain panel icon. */
|
||||
.collapsed .toggle .panelIcon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.collapsed .toggle:hover .panelIcon {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.collapsed .toggle:hover .railFish {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Rail icons ride the primary ink (figma rail spec); expanded keeps the
|
||||
secondary icon-button ink. */
|
||||
.collapsed .iconButton {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* New Session: 38px capsule (figma 133:7634); collapsed it renders as the
|
||||
rail's plain icon control. */
|
||||
.newSession {
|
||||
flex: none;
|
||||
display: flex;
|
||||
@@ -128,24 +144,17 @@
|
||||
gap: 6px;
|
||||
height: 38px;
|
||||
padding: 8px 16px;
|
||||
margin-bottom: 20px; /* former headerBlock padBottom 12 + root gap 8 */
|
||||
margin: 0 2px 20px; /* bottom: former headerBlock padBottom 12 + root gap 8 */
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 24px;
|
||||
background: var(--dsw-alias-button-elevated-fill);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 510;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
gap var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
background-color 200ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.newSession:hover {
|
||||
@@ -153,9 +162,9 @@
|
||||
}
|
||||
|
||||
.collapsed .newSession {
|
||||
height: 24px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin-bottom: 8px;
|
||||
margin: 0 0 12px;
|
||||
gap: 0;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
@@ -169,7 +178,6 @@
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.collapsed .newSessionLabel {
|
||||
@@ -191,16 +199,12 @@
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
transition:
|
||||
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.collapsed .sectionHeader {
|
||||
height: 24px;
|
||||
height: 36px;
|
||||
padding-left: 0;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
@@ -211,8 +215,8 @@
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
/* Search input: 38px capsule (figma 133:7649) morphing into the rail's
|
||||
search control. Upstream binds a dedicated design-system variable (light
|
||||
/* Search input: 38px capsule (figma 133:7649); collapsed it renders as the
|
||||
rail's search control. Upstream binds a dedicated design-system variable (light
|
||||
#F1F3F5 / dark #1B1B1C) matching no shipped alias — a component token
|
||||
pinned to the static scale mirrors it (ruled compliant: indirect via
|
||||
custom property, upstream-variable equivalent). */
|
||||
@@ -223,7 +227,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
margin-bottom: 12px; /* former listArea gap 4 + own 8 (spec padB12 to the first cell) */
|
||||
margin: 0 2px 12px; /* bottom: former listArea gap 4 + own 8 (spec padB12 to the first cell) */
|
||||
padding: 0 14px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
@@ -231,13 +235,6 @@
|
||||
background: var(--dsh-search-input-fill);
|
||||
color: var(--dsw-alias-label-caption);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
gap var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
background-color 200ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
:global(body[data-ds-dark-theme]) .search {
|
||||
@@ -245,9 +242,9 @@
|
||||
}
|
||||
|
||||
.collapsed .search {
|
||||
height: 24px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin-bottom: 8px;
|
||||
margin: 0 0 12px;
|
||||
gap: 0;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
@@ -261,8 +258,6 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
@@ -272,9 +267,11 @@
|
||||
}
|
||||
|
||||
.collapsed .searchButton {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.collapsed .searchButton:hover {
|
||||
@@ -366,39 +363,41 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Foot: settings entry (figma 133:7668). Left padding lands the 14px glyph
|
||||
on the rail's icon axis when collapsed. */
|
||||
/* Foot: settings entry (figma 133:7668, 49 hug): the former 18/10 vertical
|
||||
margins fold into the row so the hover pill spans the full 49px. */
|
||||
.foot {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 29px;
|
||||
margin: 18px 0 10px; /* former root gap 8 + own 10 above; root padBottom 6 below */
|
||||
height: 49px;
|
||||
margin: 8px 0 0; /* + 49px row + root padBottom 6 keeps the old 57px band */
|
||||
padding: 0 2px 0 6px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
transition:
|
||||
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
gap var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.foot:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Rail settings: the same 36x36 circle box as the other rail controls. */
|
||||
.collapsed .foot {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 18px 0 10px;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding: 0 0 0 5px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.footLabel {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.collapsed .footLabel {
|
||||
@@ -406,16 +405,12 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.root,
|
||||
.wide,
|
||||
.logoRow,
|
||||
.iconButton,
|
||||
.newSession,
|
||||
.newSessionLabel,
|
||||
.sectionHeader,
|
||||
.search,
|
||||
.foot,
|
||||
.footLabel {
|
||||
.fading > *,
|
||||
.railIn .iconButton,
|
||||
.railIn .newSession,
|
||||
.railIn .searchButton,
|
||||
.railIn .foot {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@@ -6,28 +6,32 @@
|
||||
* state, and rows are derived in render via useMemo (slot design section 6:
|
||||
* derived data is a pure function, no materializing store).
|
||||
*
|
||||
* Collapse is a morph, not a swap: the four control rows persist into the
|
||||
* 56px rail (collapse/new session/new workspace/search, one icon each, same
|
||||
* top-down order as their expanded rows) and animate their geometry on the
|
||||
* deepsuite curve, while wide-only content (brand, labels, input, tree)
|
||||
* cross-fades out and unmounts once the collapse settles — dropping the
|
||||
* sessions subscription. Rail search expands and focuses the search box.
|
||||
* Collapse is a slide + crossfade: the content freezes at its expanded
|
||||
* width (inline style) and fades out in place while the sliding column
|
||||
* (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle
|
||||
* the wide-only content (brand, labels, input, tree) unmounts, dropping
|
||||
* the sessions subscription, and the control rows snap to the 56px rail
|
||||
* (one icon each, same top-down order) fading in as the slide ends. Rail
|
||||
* search expands and focuses the search box.
|
||||
*/
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
FishLogo,
|
||||
BrandWordmark, FishLogo,
|
||||
IconCloseFill14, IconNewChatOutline16, IconPanelLeftOutline16, IconPersonalizationOutline16,
|
||||
IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14,
|
||||
Menu,
|
||||
Menu, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarRootComponentProps } from './contract/slots.ts'
|
||||
import { deriveRows } from './tree.ts'
|
||||
import { ProjectRowItem, SessionRowItem } from './Rows.tsx'
|
||||
import css from './SidebarRoot.module.css'
|
||||
|
||||
/** Wide-content unmount delay; matches --ds-transition-duration-slow (0.3s). */
|
||||
const COLLAPSE_SETTLE_MS = 300
|
||||
/** Wide-content unmount delay; matches the 150ms wide-content fade-out. */
|
||||
const COLLAPSE_SETTLE_MS = 150
|
||||
|
||||
/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */
|
||||
const EXPAND_SLIDE_MS = 300
|
||||
|
||||
const GROUP_BY_ITEMS = [
|
||||
{ id: 'workspace', label: 'WorkSpace' },
|
||||
@@ -134,7 +138,7 @@ function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps)
|
||||
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
|
||||
* @returns the sidebar element tree.
|
||||
*/
|
||||
export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) {
|
||||
export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) {
|
||||
// The query outlives the tree and the input (both wide-only) so collapsing
|
||||
// does not silently drop an in-progress filter.
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -150,72 +154,98 @@ export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggle
|
||||
}, [collapsed])
|
||||
const wide = !collapsed || !settled
|
||||
|
||||
// Freeze the content at its expanded width while it fades out (collapsed
|
||||
// && wide): the sliding column then clips it instead of reflowing it. The
|
||||
// rail layout (.collapsed styles) only applies once the fade settles.
|
||||
const lastWideWidth = useRef(width)
|
||||
if (!collapsed) lastWideWidth.current = width
|
||||
|
||||
// Rail-in only crossfades a live collapse: a refresh straight into the
|
||||
// collapsed state renders the rail statically (no delay-hidden icons).
|
||||
const everWide = useRef(!collapsed)
|
||||
if (!collapsed) everWide.current = true
|
||||
|
||||
// Rail search = expand + land in the search box: the flag arms before the
|
||||
// expand toggle; once expanded the input is mounted and takes focus.
|
||||
const [searchOnExpand, setSearchOnExpand] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!collapsed && searchOnExpand) {
|
||||
searchInput.current?.focus()
|
||||
setSearchOnExpand(false)
|
||||
const timer = window.setTimeout(() => {
|
||||
searchInput.current?.focus({ preventScroll: true })
|
||||
setSearchOnExpand(false)
|
||||
}, EXPAND_SLIDE_MS)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}
|
||||
}, [collapsed, searchOnExpand])
|
||||
|
||||
return (
|
||||
<div className={clsx(css.root, collapsed && css.collapsed)}>
|
||||
<div
|
||||
className={clsx(css.root, !wide && css.collapsed, !wide && everWide.current && css.railIn, collapsed && wide && css.fading)}
|
||||
style={wide ? { width: collapsed ? lastWideWidth.current : width } : undefined}
|
||||
>
|
||||
<div className={css.logoRow}>
|
||||
{wide && (
|
||||
<span className={clsx(css.brand, css.wide)}>
|
||||
{/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */}
|
||||
<FishLogo size={23} />
|
||||
<span className={css.wordmark}>deepseek</span>
|
||||
<span className={css.badge}>HARNESS</span>
|
||||
<BrandWordmark />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
onClick={() => { onToggleSidebar() }}
|
||||
>
|
||||
<IconPanelLeftOutline16 />
|
||||
</button>
|
||||
{/* Rail resting state is the whale mark; hovering swaps in the panel
|
||||
icon (the expand affordance, figma sidebar-hover flow). */}
|
||||
<Tooltip label="Open sidebar" disabled={wide}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.iconButton, css.toggle)}
|
||||
aria-label={collapsed ? 'Open sidebar' : 'Collapse sidebar'}
|
||||
onClick={() => { onToggleSidebar() }}
|
||||
>
|
||||
{!wide && <FishLogo className={css.railFish} size={24} />}
|
||||
{/* Rail icons render at 18 (figma rail spec); expanded keeps the glyph-native sizes. */}
|
||||
<IconPanelLeftOutline16 className={css.panelIcon} size={wide ? 16 : 18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={css.newSession}
|
||||
aria-label="New session"
|
||||
onClick={() => { onCreate() }}
|
||||
>
|
||||
<IconNewChatOutline16 size={14} />
|
||||
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
|
||||
</button>
|
||||
<Tooltip label="New session" disabled={wide}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.newSession}
|
||||
aria-label="New session"
|
||||
onClick={() => { onCreate() }}
|
||||
>
|
||||
<IconNewChatOutline16 size={wide ? 14 : 18} />
|
||||
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<div className={css.sectionHeader}>
|
||||
{wide && <span className={clsx(css.sectionLabel, css.wide)}>WorkSpace</span>}
|
||||
{wide && <GroupByMenu />}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label="New workspace"
|
||||
onClick={() => { onCreate() }}
|
||||
>
|
||||
<IconProjectAddOutline16 />
|
||||
</button>
|
||||
<Tooltip label="New Workspace" disabled={wide}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label="New workspace"
|
||||
onClick={() => { onCreate() }}
|
||||
>
|
||||
<IconProjectAddOutline16 size={wide ? 16 : 18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Expanded: the row is a click-to-focus field (the leading icon is
|
||||
decorative). Collapsed: the icon is the rail's search control. */}
|
||||
<div className={css.search} onClick={() => { if (!collapsed) searchInput.current?.focus() }}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.searchButton}
|
||||
aria-label="Search sessions"
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onClick={() => { if (collapsed) { setSearchOnExpand(true); onToggleSidebar() } }}
|
||||
>
|
||||
<IconSearchOutline16 size={14} />
|
||||
</button>
|
||||
<Tooltip label="Search" disabled={wide}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.searchButton}
|
||||
aria-label="Search sessions"
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onClick={() => { if (collapsed) { setSearchOnExpand(true); onToggleSidebar() } }}
|
||||
>
|
||||
<IconSearchOutline16 size={wide ? 14 : 18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{wide && (
|
||||
<input
|
||||
ref={searchInput}
|
||||
@@ -245,7 +275,7 @@ export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggle
|
||||
</div>
|
||||
|
||||
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
|
||||
<IconSettingsOutline14 />
|
||||
<IconSettingsOutline14 size={wide ? 14 : 18} />
|
||||
{wide && <span className={clsx(css.footLabel, css.wide)}>Settings</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,10 +91,13 @@ const projectData = () => [
|
||||
/** Flush the store's microtask-batched notification into React. */
|
||||
const flush = async () => { await act(async () => { await Promise.resolve() }) }
|
||||
|
||||
/** The brand wordmark is decorative svg (aria-hidden, no text); locate it by its native viewBox. */
|
||||
const wordmark = () => document.querySelector('svg[viewBox="0 0 182 24"]')
|
||||
|
||||
describe('SidebarRoot', () => {
|
||||
it('renders chrome and collapsed project rows', () => {
|
||||
mount(...projectData())
|
||||
expect(screen.getByText('HARNESS')).toBeTruthy()
|
||||
expect(wordmark()).not.toBeNull()
|
||||
expect(screen.getByText('New Session')).toBeTruthy()
|
||||
expect(screen.getByText('proj')).toBeTruthy()
|
||||
expect(screen.getByText('2 sessions')).toBeTruthy()
|
||||
@@ -166,15 +169,15 @@ describe('SidebarRoot', () => {
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
expect(onToggleSidebar).toHaveBeenCalledOnce()
|
||||
// Fade window: the wide chrome is still mounted while it fades.
|
||||
expect(screen.getByText('HARNESS')).toBeTruthy()
|
||||
expect(wordmark()).not.toBeNull()
|
||||
expect(screen.getByRole('tree')).toBeTruthy()
|
||||
// Settle: wide content unmounts, the rail controls remain.
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
expect(screen.queryByText('HARNESS')).toBeNull()
|
||||
expect(wordmark()).toBeNull()
|
||||
expect(screen.queryByText('New Session')).toBeNull()
|
||||
expect(screen.queryByRole('tree')).toBeNull()
|
||||
// Rail order mirrors the expanded rows: expand, new session, new workspace, search.
|
||||
const rail = ['Expand sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']
|
||||
// Rail order mirrors the expanded rows: open, new session, new workspace, search.
|
||||
const rail = ['Open sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']
|
||||
.map((label) => screen.getByLabelText(label))
|
||||
for (let i = 1; i < rail.length; i++) {
|
||||
expect(rail[i - 1]!.compareDocumentPosition(rail[i]!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
@@ -182,7 +185,7 @@ describe('SidebarRoot', () => {
|
||||
// Rail creation entries route like their expanded counterparts.
|
||||
act(() => { fireEvent.click(screen.getByLabelText('New session')) })
|
||||
expect(onCreate).toHaveBeenLastCalledWith()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Open sidebar')) })
|
||||
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy()
|
||||
expect(screen.getByText('New Session')).toBeTruthy()
|
||||
@@ -195,10 +198,15 @@ describe('SidebarRoot', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { onToggleSidebar } = mount(...projectData())
|
||||
// While expanded the search control is inert (the row click focuses instead).
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
|
||||
expect(onToggleSidebar).not.toHaveBeenCalled()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
|
||||
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
|
||||
// Focus waits out the 300ms column slide (EXPAND_SLIDE_MS).
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
expect(document.activeElement).toBe(input)
|
||||
} finally {
|
||||
@@ -222,7 +230,7 @@ describe('SidebarRoot', () => {
|
||||
act(() => { fireEvent.change(input, { target: { value: 'forked' } }) })
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Open sidebar')) })
|
||||
const restored = screen.getByPlaceholderText('Search name, keywords...') as HTMLInputElement
|
||||
expect(restored.value).toBe('forked')
|
||||
expect(screen.getByText('forked child')).toBeTruthy()
|
||||
|
||||
@@ -28,10 +28,6 @@
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
|
||||
@@ -33,19 +33,19 @@
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -12,8 +12,14 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { TrajectoryView } from './TrajectoryView.tsx'
|
||||
import { WaterfallView } from './WaterfallView.tsx'
|
||||
|
||||
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
|
||||
export const inject = ['slots']
|
||||
/**
|
||||
* Required services (cordis fiber inject). 'conversation' is an ordering
|
||||
* edge, not a call dependency: the 'conversation.view' slot is declared by
|
||||
* ui-conversation's apply (which then provides the service), and register()
|
||||
* into an undeclared slot throws — service waiting is what orders this
|
||||
* apply after the declaring one.
|
||||
*/
|
||||
export const inject = ['slots', 'conversation']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the trajectory and waterfall view tabs. The
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Real tsdown artifact shape: lib/client.js hands off through
|
||||
* window.DSHClientProxy.loadPlugin, resolves externals through the injected
|
||||
* window.__ModuleLoader__.load, resolves externals through the injected
|
||||
* require, returns the export surface (apply + inject), and a mounted apply
|
||||
* registers both view tabs into a real SlotsService ring. Skips when dist/ is
|
||||
* not built (`pnpm --filter @deepseek-ai/dsh-client-ui-trajectory bundle`).
|
||||
@@ -15,7 +15,7 @@ import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-trajectory'
|
||||
|
||||
interface Handoff { id: string; factory: (require: (spec: string) => unknown) => Record<string, unknown> }
|
||||
type Win = { DSHClientProxy?: { loadPlugin(h: Handoff): void } }
|
||||
type Win = { __ModuleLoader__?: { load(h: Handoff): void } }
|
||||
|
||||
function readBundle(): string | undefined {
|
||||
try {
|
||||
@@ -28,7 +28,7 @@ function readBundle(): string | undefined {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as Win).DSHClientProxy
|
||||
delete (window as Win).__ModuleLoader__
|
||||
for (const el of document.querySelectorAll('style')) el.remove()
|
||||
})
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('tsdown client artifact', () => {
|
||||
|
||||
async function loadArtifact() {
|
||||
let handoff: Handoff | undefined
|
||||
;(window as Win).DSHClientProxy = { loadPlugin: (h) => { handoff = h } }
|
||||
;(window as Win).__ModuleLoader__ = { load: (h) => { handoff = h } }
|
||||
// Same execution form the loader uses (inline script eval, window scope) —
|
||||
// the implied-eval ban targets accidental string execution, not this
|
||||
// deliberate bundle-execution fixture.
|
||||
@@ -59,7 +59,7 @@ describe('tsdown client artifact', () => {
|
||||
const { handoff, surface } = await loadArtifact()
|
||||
expect(handoff.id).toBe(PLUGIN_ID)
|
||||
expect(surface.apply).toBeTypeOf('function')
|
||||
expect(surface.inject).toEqual(['slots'])
|
||||
expect(surface.inject).toEqual(['slots', 'conversation'])
|
||||
})
|
||||
|
||||
it.skipIf(code === undefined)('mounted as an object plugin, apply registers both view tabs on the real ring', async () => {
|
||||
@@ -71,6 +71,10 @@ describe('tsdown client artifact', () => {
|
||||
name: 'root',
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
// The plugin injects 'conversation' as an ordering edge (the declaring
|
||||
// plugin provides it after declaring the ring); the bench declares the
|
||||
// ring itself, so a stub satisfies the wait.
|
||||
ctx.provide('conversation', {})
|
||||
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
|
||||
await fiber.await()
|
||||
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory', 'waterfall'])
|
||||
|
||||
@@ -83,6 +83,9 @@ async function bench() {
|
||||
const chatBody = vi.fn(() => <div data-testid="chat-body" />)
|
||||
slots.register(
|
||||
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
|
||||
// 'conversation' inject is an ordering edge; the bench declares the ring
|
||||
// itself, so a stub satisfies the wait.
|
||||
ctx.provide('conversation', {})
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber }
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# @deepseek-ai/dsh-client-web
|
||||
|
||||
Web shell library: `bootWebShell(el, seams?)` mounts the whole client — loader machinery (statically held; a loader cannot load itself), pure-library module-table seeding, AppRoot (boot loading page → settled → full UI in one switch), and the SessionProvider/scopedSlots assembly closure. The vite application entry lives in apps/web and only calls `bootWebShell`. Contract: api-contracts v3 §9.3.
|
||||
Web shell kernel: `bootWebShell(el, seams?)` mounts the whole client through the two-stage boot (web2). Stage one (module face): build the client module system (`@deepseek-ai/dsh-client-modules`) over the host-pushed entry graph (`window.__DSH_BOOT__`) and prefetch the `immediately` tier in parallel — bundle execution registers factories only. Stage two (plugin face): mount the vendored cordis Loader with the module system injected as its `internal` seam, create one loader entry per graph row plus the shell-own app-shell assembly entry (tree.import materializes each module), and gate AppRoot on the settle (loader quiesced + every entry fiber ACTIVE → full UI in one switch). Composition is entirely the host graph's: the roster and the immediately tier live in the composing app; the shell makes zero composition decisions.
|
||||
|
||||
The optional `seams` parameter forwards the client loader's `fetchBundle`/`executeBundle` transport overrides (`BootSeams`); production callers omit it — it exists for test environments where `<script>` execution cannot reach the page context (jsdom).
|
||||
Shell self-sufficiency (web2 hard rule): the kernel value-imports no plugin package — the boot status store and signals are hand-rolled here (`loader-status.ts`), so the loading page works while (and especially when) plugins fail. The app-shell assembly (`@deepseek-ai/dsh-client-app-shell`, a shell-owned pseudo entry with no npm package behind it) is the only module registered through `registerStatic`; it inject-waits on slots/sessions/layout like any plugin.
|
||||
|
||||
`PLATFORM_MODULES` (src/platform.ts) is the single source of truth for the shared module surface: seed-table keys, tsdown client externals, and the vite alias set are its projections.
|
||||
|
||||
The optional `seams` parameter forwards the module system's `fetchBundle`/`executeBundle` transport overrides (`BootSeams`); production callers omit it — it exists for test environments where `<script>` execution cannot reach the page context (jsdom).
|
||||
|
||||
The shell owns browser-title projection. With a selected session carrying a durable title, it renders `<session title> — <existing HTML title>` and reacts to later title revisions; no selection or a selected untitled session preserves the existing title, and shell unmount restores it. The existing HTML title remains the configurable product suffix.
|
||||
|
||||
@@ -16,6 +20,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One-shot rendering by design** — the UI waits for `loader.settled()`; a single plugin failure keeps the loading page with a loud error, no partial availability (progressive rendering returns with its own project).
|
||||
- **No HMR** — the dev loop is tsdown watch + manual refresh for plugins; vite serves only the shell.
|
||||
- **One-shot rendering by design** — the UI waits for the boot settle; a single entry failure keeps the loading page with a loud per-entry report, no partial availability (progressive rendering returns with its own project).
|
||||
- **Narrow-window acceptance is deferred** — the concession chain is implemented in ui-layout but the shell-level narrow-viewport walkthrough is a P-II acceptance item.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-web",
|
||||
"description": "Web shell library: bootWebShell (loader holding + module-table seeding + AppRoot gate + plugin assembly), consumed by the apps/web vite entry",
|
||||
"description": "Web shell kernel: bootWebShell (module system holding + seed table + two-stage boot + AppRoot gate + app-shell assembly entry), consumed by the apps/web vite entry",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -20,8 +20,7 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
@@ -30,6 +29,8 @@
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
@@ -37,6 +38,7 @@
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
|
||||
@@ -1,39 +1,46 @@
|
||||
/**
|
||||
* Shell root: boot loading page → (loader settled) → real UI in one switch.
|
||||
* Pure shell component with zero plugin dependencies — before settled it may
|
||||
* only rely on itself; the real UI is produced by the boot assembly closure
|
||||
* (renderApp) once every plugin is active. A failed plugin keeps the loading
|
||||
* page and lists the failures (fail loud, no partial UI).
|
||||
* Shell root: boot loading page → (boot settled) → real UI in one switch.
|
||||
* Pure kernel component with zero plugin dependencies — before settled it may
|
||||
* only rely on itself (the fail-loud presentation must not depend on the
|
||||
* system whose failure it reports; the status/signal stores are kernel-own,
|
||||
* web2 shell self-sufficiency rule); the real UI is produced by the
|
||||
* app-shell entry once every entry is active. A failed boot keeps the
|
||||
* loading page, lists the per-entry fiber states and the sweep report (fail
|
||||
* loud, no partial UI).
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { LoaderStatus } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { KernelSignal, LoaderStatus } from './loader-status.ts'
|
||||
import css from './AppRoot.module.css'
|
||||
|
||||
/** AppRoot props: settled signal, loader status feed, deferred real-UI factory. */
|
||||
/** AppRoot props: settled signal, fiber-state projection feed, boot failure report, deferred real-UI factory. */
|
||||
export interface AppRootProps {
|
||||
/** True once loader.settled() resolved (the boot closure flips it; status-derived guesses race an incrementally filled table). */
|
||||
settled: ObservableSnapshot<boolean>
|
||||
/** Loader per-plugin status store (drives loading/failed rendering). */
|
||||
status: SnapshotStore<LoaderStatus>
|
||||
/** True once the boot chain settled (loader quiesced + all entries ACTIVE); the boot closure flips it. */
|
||||
settled: KernelSignal<boolean>
|
||||
/** Per-entry fiber-state projection store (drives loading/failed rendering). */
|
||||
status: KernelSignal<LoaderStatus>
|
||||
/** Boot failure report (the settle rejection message); undefined while loading or after success. */
|
||||
error: KernelSignal<string | undefined>
|
||||
/** Builds the real UI; called only after settled. */
|
||||
renderApp: () => ReactNode
|
||||
}
|
||||
|
||||
/** Boot gate: loading page until the loader settles; failures stay here. */
|
||||
/** Boot gate: loading page until the boot settles; failures stay here. */
|
||||
export function AppRoot(props: AppRootProps) {
|
||||
const settled = useSyncExternalStore(props.settled.subscribe, props.settled.getSnapshot)
|
||||
const status = useSyncExternalStore(props.status.subscribe, props.status.getSnapshot)
|
||||
const error = useSyncExternalStore(props.error.subscribe, props.error.getSnapshot)
|
||||
const failed = Object.entries(status).filter(([, s]) => s === 'failed')
|
||||
|
||||
if (settled) return <>{props.renderApp()}</>
|
||||
|
||||
const loud = error !== undefined || failed.length > 0
|
||||
|
||||
return (
|
||||
<div className={css.boot}>
|
||||
<div className={css.card}>
|
||||
<div className={css.wordmark}>HARNESS</div>
|
||||
{failed.length === 0
|
||||
{!loud
|
||||
? (
|
||||
<>
|
||||
<div className={css.spinner} />
|
||||
@@ -44,6 +51,7 @@ export function AppRoot(props: AppRootProps) {
|
||||
<div className={css.failed}>
|
||||
<div className={css.failedTitle}>Failed to load plugins</div>
|
||||
{failed.map(([id]) => <div key={id} className={css.failedItem}>{id}</div>)}
|
||||
{error !== undefined && <div className={css.failedItem}>{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
59
packages/client/web/src/app-shell.ts
Normal file
59
packages/client/web/src/app-shell.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* App-shell assembly plugin (design §3.4): the shell's ONLY composition
|
||||
* responsibility, packaged as a normal static-arrival entry so the host graph
|
||||
* stays the single composition authority. It rides the same entry lifecycle
|
||||
* as every other plugin — the fiber waits on slots/sessions/layout, so by the
|
||||
* time apply runs the layout entry is mounted and its export surface is
|
||||
* readable from the governance side (module loadCache, design §2.6).
|
||||
*
|
||||
* The pseudo package id exists only in the host graph and the shell's static
|
||||
* registry; there is no npm package behind it.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { buildRenderApp } from './app.tsx'
|
||||
|
||||
/** Shell-owned pseudo entry id under which the host graph mounts this plugin. */
|
||||
export const APP_SHELL_ID = '@deepseek-ai/dsh-client-app-shell'
|
||||
|
||||
/** The assembled-UI face AppRoot renders once the boot settles. */
|
||||
export interface AppShellService {
|
||||
/** Build (once) and render the real UI tree. */
|
||||
renderApp: () => ReactNode
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** The shell assembly face, provided by the app-shell entry once its inject set is active. */
|
||||
appShell: AppShellService
|
||||
}
|
||||
}
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'app-shell'
|
||||
|
||||
/** Required services: the product services the assembly closes over (layout registers the 'root' slot entry). */
|
||||
export const inject = ['slots', 'sessions', 'layout']
|
||||
|
||||
/**
|
||||
* Plugin body: install the React renderer into the slot system and provide
|
||||
* the renderApp face (one ctx-level renderSlot('root') call).
|
||||
* @param ctx - plugin context (inject set active).
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
// The renderer install is shell territory (web-react is shell-bundled),
|
||||
// but ctx.slots exists only once the runtime entry is active — so it lands
|
||||
// here, on the entry whose inject set guarantees that ordering.
|
||||
ctx.slots.install(createSlotRenderer())
|
||||
|
||||
// Assemble once on first render: the closure must be identity-stable
|
||||
// across AppRoot re-renders.
|
||||
let renderApp: (() => ReactNode) | undefined
|
||||
ctx.reflect.provide('appShell', {
|
||||
renderApp: (): ReactNode => {
|
||||
renderApp ??= buildRenderApp({ ctx })
|
||||
return renderApp()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Real-UI assembly closure. Runs only after loader.settled(): the whole
|
||||
* layout tree hangs off the built-in 'root' slot (ui-layout registers
|
||||
* AppFrame there and renders the child slots internally) — the shell's
|
||||
* render is the one ctx-level renderSlot call in the program.
|
||||
* Real-UI assembly closure, invoked by the app-shell plugin once its inject
|
||||
* set is active: the whole layout tree hangs off the built-in 'root' slot
|
||||
* (ui-layout registers AppFrame there and renders the child slots
|
||||
* internally) — the shell's render is the one ctx-level renderSlot call in
|
||||
* the program.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
@@ -12,16 +13,14 @@ import { DocumentTitle } from './DocumentTitle.tsx'
|
||||
// Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program.
|
||||
import type {} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Assembly inputs: the settled root ctx plus the loader's module-table read surface. */
|
||||
/** Assembly inputs: the active app-shell plugin ctx (slots/sessions/layout services provided). */
|
||||
export interface AssemblyDeps {
|
||||
/** Client root context (all plugin services provided). */
|
||||
/** Client context with the assembly's inject set active. */
|
||||
ctx: Context
|
||||
/** Module-table resolver (the loader's require; missing spec = throw). Kept in the seam for future shell needs. */
|
||||
requireModule: (spec: string) => unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the renderApp factory handed to AppRoot.
|
||||
* Build the renderApp factory the app-shell plugin provides to AppRoot.
|
||||
* @param deps - assembly inputs.
|
||||
* @returns factory producing the real UI tree (called once per AppRoot render after settled).
|
||||
*/
|
||||
|
||||
@@ -17,3 +17,13 @@ body {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
/* Form controls don't inherit the body font (UA sheets pin their families —
|
||||
Chrome buttons fall back to Arial, textareas to monospace), so the app
|
||||
stack is re-applied to them explicitly, as upstream's global reset does. */
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@@ -1,73 +1,172 @@
|
||||
/**
|
||||
* Web shell boot — the library face consumed by the apps/web entry (api
|
||||
* contracts v3 §0.3/§9.3): root ctx → hold the loader machinery (statically
|
||||
* imported; the loader cannot load itself) → seed the module table → render
|
||||
* the AppRoot loading page → loader.start() → await settled() → flip the
|
||||
* settled signal so AppRoot switches to the real UI in one pass. Load
|
||||
* failures reject settled(); AppRoot stays on the loading page listing them
|
||||
* (fail loud).
|
||||
* Web shell boot — the kernel face consumed by the apps/web entry. Everything
|
||||
* here is machinery that cannot itself be an entry, and none of it
|
||||
* value-imports a plugin package (web2 shell self-sufficiency rule: the
|
||||
* loading page must work while — especially when — plugins fail).
|
||||
*
|
||||
* Two-stage boot (web2 §0):
|
||||
* Stage one (module face): build the module system over the host graph
|
||||
* (`window.__DSH_BOOT__`) and prefetch every `immediately` row in parallel
|
||||
* — fetch + execute registers factories only; module side effects wait for
|
||||
* materialization. Prefetch failures are non-fatal here: stage two's
|
||||
* import path retries the fetch and owns the loud failure.
|
||||
* Stage two (plugin face): mount the vendored cordis Loader, inject the
|
||||
* module system as its internal seam (BEFORE any entry exists — the
|
||||
* bare-import fallback in tree.import must never run in a browser), create
|
||||
* one loader entry per graph row (tree.import materializes each module),
|
||||
* let fibers activate on service availability, then loader.await() + a
|
||||
* full fiber sweep (all ACTIVE, else reject listing who/what/which
|
||||
* service) → flip the settled signal so AppRoot switches to the real UI in
|
||||
* one pass.
|
||||
*
|
||||
* Composition lives in the host graph; the shell makes zero composition
|
||||
* decisions (the app-shell assembly is itself a graph entry, the only
|
||||
* shell-own module registered with the module system).
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createClientLoader, type ClientLoaderOptions } from '@deepseek-ai/dsh-client-runtime/loader'
|
||||
import {
|
||||
createClientModuleLoader,
|
||||
type ClientModuleLoader, type ClientModuleLoaderOptions, type DshWindow, type WebBootGraph,
|
||||
} from '@deepseek-ai/dsh-client-modules'
|
||||
import * as AppShell from './app-shell.ts'
|
||||
import { APP_SHELL_ID } from './app-shell.ts'
|
||||
import { AppRoot } from './AppRoot.tsx'
|
||||
import { buildRenderApp } from './app.tsx'
|
||||
import { seedModules } from './seed.ts'
|
||||
import { getStaticModules } from './seed.ts'
|
||||
import {
|
||||
STATE_LABELS, createLoaderStatusStore, createSignal, type LoaderStatusStore,
|
||||
} from './loader-status.ts'
|
||||
import './base.css'
|
||||
|
||||
/** Manually flipped settled signal (AppRoot's gate; see AppRootProps.settled). */
|
||||
function settledSignal(): ObservableSnapshot<boolean> & { flip: () => void } {
|
||||
let value = false
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
|
||||
flip: () => {
|
||||
value = true
|
||||
for (const fn of [...listeners]) fn()
|
||||
},
|
||||
/** Module transport seams the shell passes through (jsdom tests replace the <script> path). */
|
||||
export type BootSeams = Pick<ClientModuleLoaderOptions, 'fetchBundle' | 'executeBundle'>
|
||||
|
||||
/**
|
||||
* Sweep every loader entry after the tree quiesced: an entry without a fiber
|
||||
* failed its import; a fiber not ACTIVE is FAILED (apply threw) or PENDING
|
||||
* (a required service never arrived — cordis inject waiting has no timeout,
|
||||
* so this sweep is the fail-loud compensation).
|
||||
*/
|
||||
function assertEntriesActive(ctx: Context): void {
|
||||
const failures: string[] = []
|
||||
for (const entry of ctx.loader.entries()) {
|
||||
const name = entry.options.name
|
||||
if (entry.fiber === undefined) {
|
||||
failures.push(`${name}: import failed (see console for the import error)`)
|
||||
continue
|
||||
}
|
||||
const state = STATE_LABELS[entry.fiber.state]
|
||||
if (state === 'active') continue
|
||||
if (state === 'pending') {
|
||||
const missing = Object.keys(entry.fiber.inject).filter((service) => ctx.get(service) === undefined)
|
||||
failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
|
||||
} else {
|
||||
failures.push(`${name}: ${state}`)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Loader transport seams the shell passes through (jsdom tests replace the <script> path). */
|
||||
export type BootSeams = Pick<ClientLoaderOptions, 'fetchBundle' | 'executeBundle'>
|
||||
/** Stage one: prefetch the immediately tier (factory registration only; failures defer to stage two's import). */
|
||||
async function prefetchImmediateTier(modules: ClientModuleLoader, graph: WebBootGraph): Promise<void> {
|
||||
await Promise.all(graph.entries
|
||||
.filter((row) => row.immediately === true)
|
||||
.map((row) => modules.prefetch(row.id).catch(() => {
|
||||
// Import (stage two) refetches and reports this loudly per entry;
|
||||
// swallowing here keeps one failing prefetch from masking the others.
|
||||
})))
|
||||
}
|
||||
|
||||
/** Stage two: mount the Loader, inject the internal seam, create the graph entries, settle, sweep. */
|
||||
async function runPluginBoot(
|
||||
ctx: Context, modules: ClientModuleLoader, graph: WebBootGraph, status: LoaderStatusStore,
|
||||
): Promise<void> {
|
||||
await ctx.plugin(Loader)
|
||||
const loader = ctx.loader
|
||||
// Inject the module system BEFORE any entry exists: tree.import falls back
|
||||
// to a bare dynamic import when internal is undefined, which in a browser
|
||||
// is a guaranteed loud failure — correct as a tripwire, never as a path.
|
||||
loader.internal = modules as never
|
||||
|
||||
// Status projection: AppRoot displays fiber truth. Every internal/status
|
||||
// transition under an entry re-projects that entry's row from its ROOT
|
||||
// fiber (child plugin fibers share the same entry).
|
||||
ctx.on('internal/status', (fiber) => {
|
||||
const entry = fiber.entry
|
||||
if (entry === undefined || entry.fiber === undefined) return
|
||||
status.set(entry.options.name, STATE_LABELS[entry.fiber.state])
|
||||
})
|
||||
|
||||
// Entry creation order carries no semantics (fiber inject waiting owns
|
||||
// activation order); creating concurrently lets non-prefetched bundle
|
||||
// fetches parallelize. The app-shell assembly entry is appended by the
|
||||
// kernel: it is shell-own code (host graph rows are all plugin bundles),
|
||||
// and mounting the assembly is not a composition decision — it rides the
|
||||
// same entry lifecycle so the sweep and status cover it uniformly.
|
||||
const rows = [...graph.entries.map((row) => row.id), APP_SHELL_ID]
|
||||
await Promise.all(rows.map(async (name) => {
|
||||
status.set(name, 'loading')
|
||||
const id = await loader.create({ name })
|
||||
// A failed import leaves the entry fiberless (Entry._init logs and
|
||||
// returns); project it as failed — no fiber means no status event.
|
||||
if (loader.resolve(id).fiber === undefined) {
|
||||
status.set(name, 'failed')
|
||||
}
|
||||
}))
|
||||
|
||||
await loader.await()
|
||||
assertEntriesActive(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the web shell into a DOM element and start the plugin load chain.
|
||||
* Mount the web shell into a DOM element and start the two-stage boot chain.
|
||||
* @param el - mount point (the app's #root).
|
||||
* @param seams - optional loader transport overrides (test environments).
|
||||
* @param seams - optional module transport overrides (test environments).
|
||||
* @returns unmount disposer.
|
||||
*/
|
||||
export function bootWebShell(el: HTMLElement, seams?: BootSeams): () => void {
|
||||
const ctx = new Context()
|
||||
const loader = createClientLoader({ ctx, modules: seedModules(), ...seams })
|
||||
ctx.reflect.provide('loader', loader)
|
||||
const graph = (globalThis as DshWindow).__DSH_BOOT__
|
||||
if (graph === undefined) throw new Error('web boot: no entry graph (window.__DSH_BOOT__ missing)')
|
||||
|
||||
const settled = settledSignal()
|
||||
// Assemble once on first post-settled render: SessionProvider and the slot
|
||||
// closures must be identity-stable across re-renders.
|
||||
let renderApp: (() => ReactNode) | undefined
|
||||
const renderAppOnce = (): ReactNode => {
|
||||
renderApp ??= buildRenderApp({ ctx, requireModule: (spec) => loader.requireModule(spec) })
|
||||
return renderApp()
|
||||
}
|
||||
const ctx = new Context()
|
||||
const modules = createClientModuleLoader({ graph, staticModules: getStaticModules(), ...seams })
|
||||
// The app-shell assembly is the only shell-own module: every other graph
|
||||
// row is a plugin bundle arriving through fetch (web2 single package form).
|
||||
modules.registerStatic(APP_SHELL_ID, AppShell)
|
||||
// Contract C5: the module system is a boot-owned kernel service (ctx.modules).
|
||||
ctx.reflect.provide('modules', modules)
|
||||
|
||||
const status = createLoaderStatusStore()
|
||||
const settled = createSignal(false)
|
||||
const error = createSignal<string | undefined>(undefined)
|
||||
|
||||
const root = createRoot(el)
|
||||
root.render(<AppRoot settled={settled} status={loader.status} renderApp={renderAppOnce} />)
|
||||
|
||||
loader.start()
|
||||
loader.settled().then(
|
||||
() => {
|
||||
// The renderer install is a shell-boot act, but ctx.slots exists only
|
||||
// once the runtime plugin loaded — so it lands here, after settled and
|
||||
// before the flip that lets renderApp call renderSlot('root').
|
||||
ctx.slots.install(createSlotRenderer())
|
||||
settled.flip()
|
||||
},
|
||||
() => { /* stay on the loading page; failures render from loader.status */ },
|
||||
root.render(
|
||||
<AppRoot
|
||||
settled={settled}
|
||||
status={status}
|
||||
error={error}
|
||||
renderApp={() => {
|
||||
const shell = ctx.get('appShell')
|
||||
// Unreachable after a clean settle (the app-shell entry is in every graph).
|
||||
if (shell === undefined) throw new Error('web boot: appShell service missing after settled')
|
||||
return shell.renderApp()
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
prefetchImmediateTier(modules, graph)
|
||||
.then(() => runPluginBoot(ctx, modules, graph, status))
|
||||
.then(
|
||||
() => { settled.set(true) },
|
||||
(reason: unknown) => {
|
||||
// Stay on the loading page; surface the sweep report (fail loud).
|
||||
console.error(reason)
|
||||
error.set(reason instanceof Error ? reason.message : String(reason))
|
||||
},
|
||||
)
|
||||
return () => { root.unmount() }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
/**
|
||||
* Web shell library entry. The shell's product is {@link bootWebShell} —
|
||||
* apps/web's vite entry calls it against #root; everything else (AppRoot
|
||||
* gate, assembly closure, module-table seed) is internal to the boot chain.
|
||||
* gate, app-shell assembly entry, module-table staticModules, platform constants) is
|
||||
* internal to the boot chain. PLATFORM_MODULES is re-exported as the C1
|
||||
* single source of truth for the tsdown client externals projection.
|
||||
* @module @deepseek-ai/dsh-client-web
|
||||
*/
|
||||
|
||||
export { bootWebShell } from './boot.tsx'
|
||||
export { bootWebShell, type BootSeams } from './boot.tsx'
|
||||
export { AppRoot, type AppRootProps } from './AppRoot.tsx'
|
||||
export { buildRenderApp, type AssemblyDeps } from './app.tsx'
|
||||
export { DocumentTitle, type DocumentTitleProps } from './DocumentTitle.tsx'
|
||||
export { seedModules } from './seed.ts'
|
||||
export { APP_SHELL_ID, type AppShellService } from './app-shell.ts'
|
||||
export { getStaticModules } from './seed.ts'
|
||||
export { PLATFORM_MODULES, type PlatformModule } from './platform.ts'
|
||||
export {
|
||||
STATE_LABELS, FIBER_STATE, createSignal, createLoaderStatusStore,
|
||||
type LoaderStatus, type LoaderEntryState, type KernelSignal, type KernelValueSignal, type LoaderStatusStore,
|
||||
} from './loader-status.ts'
|
||||
|
||||
111
packages/client/web/src/loader-status.ts
Normal file
111
packages/client/web/src/loader-status.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Fiber-state projection vocabulary and the kernel-owned status store for the
|
||||
* boot loading page. The status AppRoot renders is a projection of the real
|
||||
* cordis fiber states (display the truth, not a retelling) — the boot chain
|
||||
* subscribes `internal/status` and recomputes one row per loader entry.
|
||||
*
|
||||
* The store is hand-rolled here because of the shell self-sufficiency rule
|
||||
* (web2 §0): the snapshot-store machinery lives in the runtime PLUGIN
|
||||
* package, and the shell kernel must not value-import any plugin package —
|
||||
* the loading page has to work while (and especially when) plugins fail.
|
||||
* @module @deepseek-ai/dsh-client-web/src/loader-status
|
||||
*/
|
||||
import type { FiberState } from 'cordis'
|
||||
|
||||
/**
|
||||
* Value mirror of cordis's `FiberState` const enum: a const enum has no
|
||||
* runtime object to import (and esbuild-based pipelines cannot inline it
|
||||
* across modules), so these values mirror the pinned vendored definition
|
||||
* while retaining its type (same rationale as dsh-tool-cordis's mirror).
|
||||
*/
|
||||
export const FIBER_STATE = {
|
||||
PENDING: 0 as FiberState.PENDING,
|
||||
LOADING: 1 as FiberState.LOADING,
|
||||
ACTIVE: 2 as FiberState.ACTIVE,
|
||||
FAILED: 3 as FiberState.FAILED,
|
||||
DISPOSED: 4 as FiberState.DISPOSED,
|
||||
UNLOADING: 5 as FiberState.UNLOADING,
|
||||
} as const
|
||||
|
||||
/** One entry's projected state label (lower-case face of {@link FiberState}). */
|
||||
export type LoaderEntryState = 'pending' | 'loading' | 'active' | 'failed' | 'disposed' | 'unloading'
|
||||
|
||||
/** Label for each fiber state, keyed by member (inlining-safe — no reverse mapping). */
|
||||
export const STATE_LABELS: Record<FiberState, LoaderEntryState> = {
|
||||
[FIBER_STATE.PENDING]: 'pending',
|
||||
[FIBER_STATE.LOADING]: 'loading',
|
||||
[FIBER_STATE.ACTIVE]: 'active',
|
||||
[FIBER_STATE.FAILED]: 'failed',
|
||||
[FIBER_STATE.DISPOSED]: 'disposed',
|
||||
[FIBER_STATE.UNLOADING]: 'unloading',
|
||||
}
|
||||
|
||||
/** Per-entry state projection (AppRoot's status feed), keyed by entry name. */
|
||||
export type LoaderStatus = Record<string, LoaderEntryState>
|
||||
|
||||
/** Minimal observable snapshot the kernel components consume (useSyncExternalStore shape). */
|
||||
export interface KernelSignal<T> {
|
||||
/** Current value (stable reference between changes). */
|
||||
getSnapshot(): T
|
||||
/**
|
||||
* Subscribe to changes.
|
||||
* @param fn - change listener.
|
||||
* @returns the unsubscribe disposer.
|
||||
*/
|
||||
subscribe(fn: () => void): () => void
|
||||
}
|
||||
|
||||
/** Writable one-value signal (settled flag, boot failure report). */
|
||||
export interface KernelValueSignal<T> extends KernelSignal<T> {
|
||||
/**
|
||||
* Publish a new value and notify subscribers.
|
||||
* @param next - the new value.
|
||||
*/
|
||||
set(next: T): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a writable kernel signal.
|
||||
* @param init - initial value.
|
||||
* @returns the signal.
|
||||
*/
|
||||
export function createSignal<T>(init: T): KernelValueSignal<T> {
|
||||
let value = init
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
|
||||
set: (next) => {
|
||||
value = next
|
||||
for (const fn of [...listeners]) fn()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The boot status store: per-entry rows over a {@link KernelSignal} face. */
|
||||
export interface LoaderStatusStore extends KernelSignal<LoaderStatus> {
|
||||
/**
|
||||
* Project one entry's state (copy-on-write so getSnapshot references only
|
||||
* change on writes — useSyncExternalStore contract).
|
||||
* @param id - entry name.
|
||||
* @param state - projected fiber state.
|
||||
*/
|
||||
set(id: string, state: LoaderEntryState): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the boot status store.
|
||||
* @returns the store (empty until the boot chain projects rows).
|
||||
*/
|
||||
export function createLoaderStatusStore(): LoaderStatusStore {
|
||||
let value: LoaderStatus = {}
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
|
||||
set: (id, state) => {
|
||||
value = { ...value, [id]: state }
|
||||
for (const fn of [...listeners]) fn()
|
||||
},
|
||||
}
|
||||
}
|
||||
20
packages/client/web/src/platform.ts
Normal file
20
packages/client/web/src/platform.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Platform singletons the shell shares into the module table.
|
||||
* Single source of truth (design §3.3, contract C1): seed keys = tsdown
|
||||
* client externals = the shared surface. The three projections import this
|
||||
* module — the seed table ({@link ../seed.ts}), the tsdown client preset's
|
||||
* external judgement (packages/client/tsdown.client.ts), and the vite alias
|
||||
* check — so the list cannot drift between them.
|
||||
* @module @deepseek-ai/dsh-client-web/src/platform
|
||||
*/
|
||||
|
||||
/** The module specifiers the shell shares into the frozen module table. */
|
||||
export const PLATFORM_MODULES = [
|
||||
'react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', 'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-web-react',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
] as const
|
||||
|
||||
/** One platform module specifier (a seed-table key). */
|
||||
export type PlatformModule = (typeof PLATFORM_MODULES)[number]
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Pure-library module-table seed. These are the ONLY entities statically
|
||||
* built into the shell bundle besides the loader machinery — every plugin
|
||||
* (including the infrastructure four) arrives as a dynamic bundle and
|
||||
* resolves its externals against this table through the loader's require.
|
||||
* Keys must match the tsdown client preset's external specifiers
|
||||
* (packages/client/tsdown.client.ts CLIENT_EXTERNALS ∩ pure libraries).
|
||||
* Platform-singleton module-table. These are the ONLY entities the shell
|
||||
* shares into the frozen module table — fetch bundles resolve their externals
|
||||
* against exactly this set through the loader's require. Keys come from the
|
||||
* platform constant module ({@link ./platform.ts}, contract C1: single source
|
||||
* of truth with the tsdown client externals); values stay shell-static
|
||||
* imports so every bundle sees the same instance.
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import * as ReactJsxRuntime from 'react/jsx-runtime'
|
||||
@@ -14,12 +14,16 @@ import * as Cordis from 'cordis'
|
||||
import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import * as WebReact from '@deepseek-ai/dsh-client-web-react'
|
||||
import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PlatformModule } from './platform.ts'
|
||||
|
||||
/**
|
||||
* Build the seed table handed to the loader machinery at boot.
|
||||
* @returns module specifier → export-surface entity.
|
||||
* Build the static table handed to the module loader at boot.
|
||||
* @returns module specifier → export-surface entity (one entry per platform word).
|
||||
*/
|
||||
export function seedModules(): Record<string, unknown> {
|
||||
export function getStaticModules(): Record<string, unknown> {
|
||||
// The satisfies pin is the projection contract: a word added to
|
||||
// PLATFORM_MODULES without a static import here (or vice versa) fails to
|
||||
// compile instead of drifting into a runtime require miss.
|
||||
return {
|
||||
'react': React,
|
||||
'react/jsx-runtime': ReactJsxRuntime,
|
||||
@@ -29,5 +33,5 @@ export function seedModules(): Record<string, unknown> {
|
||||
'@deepseek-ai/dsh-client-ui-slots': UiSlots,
|
||||
'@deepseek-ai/dsh-client-web-react': WebReact,
|
||||
'@deepseek-ai/dsh-client-ui-primitives': UiPrimitives,
|
||||
}
|
||||
} satisfies Record<PlatformModule, unknown>
|
||||
}
|
||||
|
||||
@@ -1,42 +1,33 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AppRoot boot-gate smoke: loading page until the settled signal flips (status
|
||||
* alone never opens the gate), fail-loud plugin list, one-pass switch to the
|
||||
* real UI. The full browser chain (real loader + bundles) is the e2e's job;
|
||||
* this pins the shell-owned gate semantics.
|
||||
* alone never opens the gate), fail-loud entry list + boot failure report,
|
||||
* one-pass switch to the real UI. The full browser chain (real module system
|
||||
* + vendored Loader + bundles) is the e2e's job; this pins the shell-owned
|
||||
* gate semantics. Stores are the kernel-own signals production boot uses
|
||||
* (shell self-sufficiency: the loading page depends on no plugin package).
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
|
||||
afterEach(cleanup)
|
||||
// The snapshot-store engine lives with runtime now; the status-store stub
|
||||
// uses the same channel production code does.
|
||||
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { LoaderStatus } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AppRoot } from '@deepseek-ai/dsh-client-web/src/AppRoot.tsx'
|
||||
|
||||
function signal(): ObservableSnapshot<boolean> & { flip: () => void } {
|
||||
let value = false
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
|
||||
flip: () => { value = true; for (const fn of [...listeners]) fn() },
|
||||
}
|
||||
}
|
||||
import { createLoaderStatusStore, createSignal } from '@deepseek-ai/dsh-client-web/src/loader-status.ts'
|
||||
|
||||
function mount() {
|
||||
const settled = signal()
|
||||
const status = createSnapshotStore<LoaderStatus>({})
|
||||
const settled = createSignal(false)
|
||||
const error = createSignal<string | undefined>(undefined)
|
||||
const status = createLoaderStatusStore()
|
||||
let renders = 0
|
||||
const utils = render(
|
||||
<AppRoot
|
||||
settled={settled}
|
||||
status={status}
|
||||
error={error}
|
||||
renderApp={() => { renders += 1; return <div data-testid="real-ui" /> }}
|
||||
/>,
|
||||
)
|
||||
return { settled, status, counts: () => renders, ...utils }
|
||||
return { settled, status, error, counts: () => renders, ...utils }
|
||||
}
|
||||
|
||||
describe('AppRoot', () => {
|
||||
@@ -50,24 +41,34 @@ describe('AppRoot', () => {
|
||||
it('all-active status alone does not open the gate (settled signal is the only key)', () => {
|
||||
const { status, queryByTestId } = mount()
|
||||
act(() => {
|
||||
status.update((d) => { d['a'] = 'active'; d['b'] = 'active' })
|
||||
status.set('a', 'active')
|
||||
status.set('b', 'active')
|
||||
})
|
||||
expect(queryByTestId('real-ui')).toBeNull()
|
||||
})
|
||||
|
||||
it('lists failed plugins and stays on the loading page', () => {
|
||||
it('lists failed entries and stays on the loading page', () => {
|
||||
const { status, getByText, queryByTestId } = mount()
|
||||
act(() => {
|
||||
status.update((d) => { d['@deepseek-ai/dsh-client-ui-theme'] = 'failed'; d['ok'] = 'active' })
|
||||
status.set('@deepseek-ai/dsh-client-ui-layout', 'failed')
|
||||
status.set('ok', 'active')
|
||||
})
|
||||
expect(getByText('Failed to load plugins')).toBeTruthy()
|
||||
expect(getByText('@deepseek-ai/dsh-client-ui-theme')).toBeTruthy()
|
||||
expect(getByText('@deepseek-ai/dsh-client-ui-layout')).toBeTruthy()
|
||||
expect(queryByTestId('real-ui')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the boot failure report even when no entry projected failed', () => {
|
||||
const { error, getByText, queryByTestId } = mount()
|
||||
act(() => { error.set('web boot: 1 entry did not activate\nx: pending (waiting for service: y)') })
|
||||
expect(getByText('Failed to load plugins')).toBeTruthy()
|
||||
expect(getByText(/waiting for service/)).toBeTruthy()
|
||||
expect(queryByTestId('real-ui')).toBeNull()
|
||||
})
|
||||
|
||||
it('flipping settled switches to the real UI in one pass', () => {
|
||||
const { settled, getByTestId, queryByText, counts } = mount()
|
||||
act(() => { settled.flip() })
|
||||
act(() => { settled.set(true) })
|
||||
expect(getByTestId('real-ui')).toBeTruthy()
|
||||
expect(queryByText('HARNESS')).toBeNull()
|
||||
expect(counts()).toBe(1)
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* bootWebShell over the REAL client loader in jsdom (runScripts:dangerously —
|
||||
* the loader's <script> execute path runs for real): fetch is stubbed to
|
||||
* serve fake bundle text, everything else is production code — seeded module
|
||||
* table, DSHClientProxy handoff, inject topology, renderer install after
|
||||
* settled, the one-line renderSlot('root') shell, and the fail-loud paths —
|
||||
* through the loader's fetch/execute seams (jsdom's <script> vm context
|
||||
* cannot reach the test window, so execute is indirect eval). The fake
|
||||
* runtime is the REAL SlotsService mounted by the real runtime plugin shape;
|
||||
* full-fidelity plugin content belongs to the apps/web e2e.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { act } from '@testing-library/react'
|
||||
import { bootWebShell } from '@deepseek-ai/dsh-client-web'
|
||||
import { createSnapshotStore, defineStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
interface BootWindow extends Window {
|
||||
__DSH_BOOT__?: { plugins: { id: string; url: string; inject: string[]; immediately?: boolean }[] }
|
||||
DSHClientProxy?: unknown
|
||||
__TEST_SLOTS_SERVICE__?: unknown
|
||||
__TEST_RUNTIME_STORE__?: { createSnapshotStore: unknown; defineStore: unknown }
|
||||
}
|
||||
const win = window as unknown as BootWindow
|
||||
|
||||
/**
|
||||
* Fake runtime half: mounts the REAL SlotsService (built-in 'root', ledger,
|
||||
* install/renderSlot) plus a minimal sessions face for the renderer host.
|
||||
* The runtime package is not a seeded library (in production it arrives as a
|
||||
* bundle), so the spec hands the real class in through a window global — the
|
||||
* plugin body and everything downstream stay production code.
|
||||
*/
|
||||
const RUNTIME_STUB = `
|
||||
window.DSHClientProxy.loadPlugin({
|
||||
id: 'fake-runtime',
|
||||
factory: (require) => {
|
||||
const SlotsService = window.__TEST_SLOTS_SERVICE__
|
||||
const { createSnapshotStore } = window.__TEST_RUNTIME_STORE__
|
||||
return {
|
||||
apply: (ctx) => {
|
||||
ctx.plugin(SlotsService)
|
||||
const list = createSnapshotStore({ ids: ['s1'], byId: { s1: { id: 's1', title: 'S1', displayTitle: 'S1', running: false, updatedAt: 1 } }, current: 's1' })
|
||||
ctx.provide('sessions', {
|
||||
list,
|
||||
cell: (id) => (id === 's1' ? { sessionId: 's1', session: { getSnapshot: () => ({}), subscribe: () => () => {} } } : undefined),
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
})`
|
||||
|
||||
/** Fake layout half: ONE terminal register() call — occupy 'root', declare a
|
||||
* child, seat a store factory, expose the store round trip as a probe. */
|
||||
const LAYOUT_STUB = `
|
||||
window.DSHClientProxy.loadPlugin({
|
||||
id: 'fake-layout',
|
||||
factory: (require) => {
|
||||
const React = require('react')
|
||||
const { defineStore } = window.__TEST_RUNTIME_STORE__
|
||||
return {
|
||||
inject: ['slots'],
|
||||
apply: (ctx) => {
|
||||
const createProbeStore = () => defineStore({
|
||||
init: () => ({ sidebar: 300, details: 360 }),
|
||||
actions: {
|
||||
setSidebar: (d, px) => { d.sidebar = px },
|
||||
setDetails: (d, px) => { d.details = px },
|
||||
},
|
||||
})
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: { 'probe.child': { kind: 'single', scope: 'root' } },
|
||||
store: createProbeStore,
|
||||
}, (props) => {
|
||||
const sw = props.useStore((st) => st.sidebar)
|
||||
const dw = props.useStore((st) => st.details)
|
||||
return React.createElement('div', {
|
||||
'data-testid': 'fake-frame',
|
||||
'data-widths': sw + 'x' + dw,
|
||||
onClick: () => { props.actions.setSidebar(311); props.actions.setDetails(411) },
|
||||
}, props.renderSlot('probe.child', {}))
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
})`
|
||||
|
||||
// The shell assembly requires the layout surface under its production id.
|
||||
const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
|
||||
|
||||
/** Loader seams: serve fake bundle text and execute it via indirect eval (jsdom's <script> vm context cannot see the test window). */
|
||||
function seams(bundles: Record<string, string>) {
|
||||
return {
|
||||
fetchBundle: (url: string): Promise<string> => {
|
||||
const hit = Object.keys(bundles).find((b) => url.endsWith(b))
|
||||
if (hit === undefined) return Promise.reject(new Error(`bundle fetch ${url} answered 404`))
|
||||
return Promise.resolve(bundles[hit]!)
|
||||
},
|
||||
executeBundle: (code: string): void => {
|
||||
(0, eval)(code)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function mountPoint(): HTMLElement {
|
||||
const el = document.createElement('div')
|
||||
document.body.appendChild(el)
|
||||
return el
|
||||
}
|
||||
|
||||
async function flushLoader(): Promise<void> {
|
||||
// fetch + per-plugin apply chain across macrotask turns; a few settle it.
|
||||
for (let i = 0; i < 10; i++) await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
||||
}
|
||||
|
||||
function bootPlugins(): { id: string; url: string; inject: string[]; immediately?: boolean }[] {
|
||||
return [
|
||||
{ id: 'fake-runtime', url: '/plugins/fake-runtime.js', inject: [], immediately: true },
|
||||
{ id: LAYOUT_ID, url: '/plugins/fake-layout.js', inject: ['fake-runtime'] },
|
||||
]
|
||||
}
|
||||
|
||||
function fakeBundles(): Record<string, string> {
|
||||
return {
|
||||
'/plugins/fake-runtime.js': RUNTIME_STUB,
|
||||
'/plugins/fake-layout.js': LAYOUT_STUB.replace("id: 'fake-layout'", `id: '${LAYOUT_ID}'`),
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.DSHClientProxy
|
||||
delete win.__TEST_SLOTS_SERVICE__
|
||||
delete win.__TEST_RUNTIME_STORE__
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('script').forEach((s) => { s.remove() })
|
||||
document.title = ''
|
||||
})
|
||||
|
||||
/** Hand the real runtime surface to the stub bundle (runtime is not a seeded library). */
|
||||
function seedSlotsService(): void {
|
||||
win.__TEST_SLOTS_SERVICE__ = SlotsService
|
||||
win.__TEST_RUNTIME_STORE__ = { createSnapshotStore, defineStore }
|
||||
}
|
||||
|
||||
describe('bootWebShell (real loader + real script execution)', () => {
|
||||
it('loading page → settled → renderer installed → assembled UI in one pass; unmount clears the tree', async () => {
|
||||
win.__DSH_BOOT__ = { plugins: bootPlugins() }
|
||||
seedSlotsService()
|
||||
const el = mountPoint()
|
||||
document.title = 'DeepSeek Harness'
|
||||
let unmount: (() => void) | undefined
|
||||
act(() => { unmount = bootWebShell(el, seams(fakeBundles())) })
|
||||
expect(el.textContent).toContain('HARNESS')
|
||||
expect(el.querySelector('[data-testid="fake-frame"]')).toBeNull()
|
||||
|
||||
await flushLoader()
|
||||
expect(el.querySelector('[data-testid="fake-frame"]')).not.toBeNull()
|
||||
expect(el.textContent).not.toContain('HARNESS')
|
||||
expect(document.title).toBe('S1 — DeepSeek Harness')
|
||||
|
||||
act(() => { unmount!() })
|
||||
expect(el.childElementCount).toBe(0)
|
||||
expect(document.title).toBe('DeepSeek Harness')
|
||||
})
|
||||
|
||||
it('store seat round-trips through the entry props (useStore + actions)', async () => {
|
||||
win.__DSH_BOOT__ = { plugins: bootPlugins() }
|
||||
seedSlotsService()
|
||||
const el = mountPoint()
|
||||
act(() => { bootWebShell(el, seams(fakeBundles())) })
|
||||
await flushLoader()
|
||||
const frame = el.querySelector('[data-testid="fake-frame"]')
|
||||
expect(frame).not.toBeNull()
|
||||
// Width write/read round trip through the framework-delivered store share.
|
||||
expect((frame as HTMLElement).dataset['widths']).toBe('300x360')
|
||||
act(() => { (frame as HTMLElement).click() })
|
||||
expect((frame as HTMLElement).dataset['widths']).toBe('311x411')
|
||||
})
|
||||
|
||||
it('fail loud: a 404 bundle keeps the loading page and lists the plugin id', async () => {
|
||||
win.__DSH_BOOT__ = { plugins: [{ id: 'absent-plugin', url: '/plugins/absent.js', inject: [] }] }
|
||||
const el = mountPoint()
|
||||
act(() => { bootWebShell(el, seams({})) })
|
||||
await flushLoader()
|
||||
expect(el.textContent).toContain('Failed to load plugins')
|
||||
expect(el.textContent).toContain('absent-plugin')
|
||||
expect(el.querySelector('[data-testid="fake-frame"]')).toBeNull()
|
||||
})
|
||||
|
||||
it("fail loud: rendering with no 'root' registration throws through the shell error surface", async () => {
|
||||
// Runtime loads (slots service present, renderer installed) but no layout
|
||||
// entry ever registers into 'root' — the ctx-level renderSlot must throw.
|
||||
win.__DSH_BOOT__ = {
|
||||
plugins: [{ id: 'fake-runtime', url: '/plugins/fake-runtime.js', inject: [], immediately: true }],
|
||||
}
|
||||
seedSlotsService()
|
||||
const el = mountPoint()
|
||||
// React logs the render error before the boundary rethrow reaches us — keep the spec output clean.
|
||||
const consoleError = console.error
|
||||
console.error = () => {}
|
||||
try {
|
||||
act(() => { bootWebShell(el, seams({ '/plugins/fake-runtime.js': RUNTIME_STUB })) })
|
||||
let thrown: unknown
|
||||
try {
|
||||
await flushLoader()
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(String(thrown)).toMatch(/'root' has no registration/)
|
||||
} finally {
|
||||
console.error = consoleError
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRenderApp — assembly contract', () => {
|
||||
it('is exactly the ctx-level root render call (fail-loud before install)', async () => {
|
||||
const { buildRenderApp } = await import('@deepseek-ai/dsh-client-web')
|
||||
const { Context } = await import('cordis')
|
||||
const { SlotsService } = await import('@deepseek-ai/dsh-client-runtime/client')
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(SlotsService)
|
||||
await fiber.await()
|
||||
ctx.provide('sessions', {
|
||||
list: createSnapshotStore({ ids: [], byId: {}, current: undefined }),
|
||||
})
|
||||
const renderApp = buildRenderApp({ ctx, requireModule: () => undefined })
|
||||
expect(renderApp).toBeTypeOf('function')
|
||||
// No renderer installed: the one-line shell must surface the boot-order error.
|
||||
expect(() => renderApp()).toThrow(/renderer not installed/)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,12 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../modules"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
@@ -20,18 +26,9 @@
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-theme"
|
||||
},
|
||||
{
|
||||
"path": "../ui-layout"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user