Bläddra i källkod

feat(web): show plugin loading progress

imccyu 2 månader sedan
förälder
incheckning
2f974ca9b0

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.md
-2026-08-17-dynamic-client-render-and-attachment-ownership.md: 7dc465a878b4e087c1219011c330c9235e69791a
-2026-08-17-dynamic-client-render-and-attachment-ownership.zh.md: 2ccebfe705175b70425e0fcb7aa18e7213780b92
+2026-08-17-dynamic-client-render-and-attachment-ownership.md: 7298d8c520aeeb37a43c026cffc232d044b4914c
+2026-08-17-dynamic-client-render-and-attachment-ownership.zh.md: 78ca31962a322d15f1a2b79bb64f5a26b36fe0b9

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.md

@@ -12,7 +12,7 @@ The loading and failure page has the opposite requirement: it must remain usable
 
 ## Decision
 
-`@deepseek-ai/dsh-client-web` is a framework-free boot kernel. It draws its loading and failure page with DOM operations and local CSS fallbacks, constructs the client module system and Cordis Loader, creates the statically adopted modules bootstrap entry plus every host-graph entry, and waits until every fiber is ACTIVE. It then resolves `ctx.uiRenderer` and hands the existing container to `mount()`.
+`@deepseek-ai/dsh-client-web` is a framework-free boot kernel. It draws its loading and failure page with DOM operations and local CSS fallbacks, constructs the client module system and Cordis Loader, creates the statically adopted modules bootstrap entry plus every host-graph entry, and waits until every fiber is ACTIVE. Loader state changes retain one spinner node and update only its CSS arc when an entry first becomes active. The arc grows from one fifth to four fifths of the ring, preserving a visible gap throughout rotation. After the roster settles, the kernel resolves `ctx.uiRenderer` and hands the existing container to `mount()`.
 
 `@deepseek-ai/dsh-client-ui-renderer` is an `immediately` dynamic client plugin. It owns the React slot outlets, SessionProvider, and observable-to-uSES binding. After `slots`, `sessions`, and `layout` activate, it installs the slot renderer and provides `ctx.uiRenderer`. `mount()` hydrates the kernel-authored boot DOM, then replaces it with the assembled application in a layout effect before the browser can paint an intermediate frame. The hydrated spinner node retains its animation phase. The assembled tree projects the selected session title and performs the sole context-level `renderSlot('root')` call. The service, renderer installation, and React root all dispose with their owners.
 
@@ -24,7 +24,7 @@ React, React DOM, Cordis, ui-slots, and ui-primitives remain static platform mod
 
 ## Verification
 
-Component tests pin the boot page, hydration without boot-DOM mutation, document title, application tree, attachment entries, and disposal. The assembled built-bundle boot exercises the real module table and dynamic entries, while the client-bundle CSS tests prove global styles compile into watched plugin-owned injectors. The browser replay lane covers the complete handoff from the framework-free page to the rendered application.
+Component tests pin the persistent progress spinner, hydration without boot-DOM mutation, document title, application tree, attachment entries, and disposal. The assembled built-bundle boot exercises the real module table and dynamic entries, while the client-bundle CSS tests prove global styles compile into watched plugin-owned injectors. The browser replay lane covers the complete handoff from the framework-free page to the rendered application.
 
 ## Alternatives considered
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 ## 决定
 
-`@deepseek-ai/dsh-client-web` 是不依赖框架的启动内核。它通过 DOM 操作与本地 CSS 回退绘制加载和失败页面,构造客户端模块系统与 Cordis Loader,创建静态接纳的 modules 启动 entry 和宿主图中的每个 entry,并等待所有 fiber 进入 ACTIVE。随后它解析 `ctx.uiRenderer`,把现有容器交给 `mount()`。
+`@deepseek-ai/dsh-client-web` 是不依赖框架的启动内核。它通过 DOM 操作与本地 CSS 回退绘制加载和失败页面,构造客户端模块系统与 Cordis Loader,创建静态接纳的 modules 启动 entry 和宿主图中的每个 entry,并等待所有 fiber 进入 ACTIVE。Loader 状态变化会保留同一个 spinner 节点,只在 entry 首次进入 active 时更新其 CSS 圆弧。圆弧从圆环的五分之一增长至五分之四,在旋转期间始终保留可见缺口。名册稳定后,内核解析 `ctx.uiRenderer`,把现有容器交给 `mount()`。
 
 `@deepseek-ai/dsh-client-ui-renderer` 是带 `immediately` 标记的动态客户端插件。它持有 React slot outlet、SessionProvider 与 observable 到 uSES 的绑定。`slots`、`sessions` 与 `layout` 激活后,它安装 slot 渲染器并提供 `ctx.uiRenderer`。`mount()` hydrate 内核生成的启动 DOM,再通过 layout effect 在浏览器绘制中间帧前将其替换为组装完成的应用。hydrate 后的 spinner 节点会保持动画相位。组装后的树投影当前会话标题,并执行唯一一次上下文级 `renderSlot('root')` 调用。服务、渲染器安装和 React 根都随各自持有方 dispose。
 
@@ -24,7 +24,7 @@ React、React DOM、Cordis、ui-slots 与 ui-primitives 仍是保持单一浏览
 
 ## 验证
 
-组件测试固定启动页、hydrate 不改变启动 DOM、文档标题、应用树、附件 entry 与 dispose 行为。组装后的构建 bundle 启动测试会运行真实模块表与动态 entry,客户端 bundle CSS 测试则证明全局样式会编译成受监视、由插件持有的注入器。浏览器回放测试覆盖从不依赖框架的页面到渲染应用的完整交接。
+组件测试固定持久进度 spinner、hydrate 不改变启动 DOM、文档标题、应用树、附件 entry 与 dispose 行为。组装后的构建 bundle 启动测试会运行真实模块表与动态 entry,客户端 bundle CSS 测试则证明全局样式会编译成受监视、由插件持有的注入器。浏览器回放测试覆盖从不依赖框架的页面到渲染应用的完整交接。
 
 ## 备选方案
 

+ 1 - 1
packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx

@@ -55,7 +55,7 @@ describe('UI renderer plugin', () => {
     const { ctx, slots } = await bench()
     slots.register({ name: 'root' }, () => <div data-testid="root-probe" />)
     const el = container()
-    el.innerHTML = '<div class="boot" data-dsh-boot=""><div><div class="spinner"></div><div>Loading plugins…</div></div></div>'
+    el.innerHTML = '<div class="boot" data-dsh-boot=""><div><div class="spinner" data-dsh-boot-spinner="" style="--dsh-boot-arc: 180deg"></div><div>Loading plugins…</div></div></div>'
     const boot = el.firstElementChild
     const observer = new MutationObserver(() => {})
     observer.observe(el, { childList: true, subtree: true })

+ 2 - 2
packages/client/web/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/web/README.md
-README.md: 82d4c38d4b66818e406038e8fbaf7dbdc07a07a0
-README.zh.md: 047249e8607a6b38ff74f09515dc27eb3e7fe31d
+README.md: 434091ab5deb10c6f900a9b16208cf061bd92b17
+README.zh.md: 3ec28efc9b291be4df84f2d5f32ca65804b52115

+ 1 - 1
packages/client/web/README.md

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 Web boot kernel: `new AppWebEntry(el, seams?).run()` mounts the client through two stages. The module stage builds `@deepseek-ai/dsh-client-modules` over the host-provided `window.__DSH_BOOT__` graph and prefetches the `immediately` tier. The plugin stage mounts the vendored Cordis Loader, injects that module system through the Loader's `internal` interface, creates every graph entry, and waits for every fiber to become ACTIVE. It then hands the marked boot DOM to the dynamic UI renderer's `ctx.uiRenderer.mount(el)` operation; the renderer hydrates that DOM before switching to the complete UI. The host graph owns the roster and prefetch marks; this package adds only the statically adopted modules bootstrap entry.
 
-The boot page uses plain DOM and local CSS, so client-bundle and plugin-activation failures remain visible. Its fallback fonts and colors match the theme tokens that arrive during loading, and hydration keeps the spinner DOM and animation phase until the application commit. React mounting, slot rendering, application assembly, and browser-title projection live in [`ui-renderer`](../ui-renderer/README.md). The modules package is the only plugin package registered through `registerStatic`, because the module system cannot load itself.
+The boot page uses plain DOM and local CSS, so client-bundle and plugin-activation failures remain visible. Its fallback fonts and colors match the theme tokens that arrive during loading. Fiber updates retain one spinner node and grow its CSS arc as entries first become active; hydration preserves that node and its animation phase until the application commit. React mounting, slot rendering, application assembly, and browser-title projection live in [`ui-renderer`](../ui-renderer/README.md). The modules package is the only plugin package registered through `registerStatic`, because the module system cannot load itself.
 
 `PLATFORM_MODULES` (src/platform.ts) is the single source of truth for shared modules: seed-table keys, tsdown client externals, and the Vite alias set are its projections.
 

+ 1 - 1
packages/client/web/README.zh.md

@@ -4,7 +4,7 @@
 
 Web 启动内核:`new AppWebEntry(el, seams?).run()` 分两个阶段挂载客户端。模块阶段基于宿主提供的 `window.__DSH_BOOT__` 图构建 `@deepseek-ai/dsh-client-modules`,并预取 `immediately` 层级。插件阶段挂载仓库内置的 Cordis Loader,通过 Loader 的 `internal` 接口注入该模块系统,创建全部图 entry,并等待每个 fiber 进入 ACTIVE。随后它把带标记的启动 DOM 交给动态 UI 渲染器的 `ctx.uiRenderer.mount(el)` 操作;渲染器先 hydrate 该 DOM,再切换到完整 UI。名册与预取标记归宿主图所有;本包只额外加入静态接纳的 modules 启动 entry。
 
-启动页只使用原生 DOM 与本地 CSS,因此客户端 bundle 或插件激活失败时仍能显示。其回退字体和颜色与加载期间到达的主题 token 一致,hydrate 会保留 spinner DOM 及其动画相位,直到应用提交。React 挂载、slot 渲染、应用组装和浏览器标题投影位于 [`ui-renderer`](../ui-renderer/README.md)。modules 包是唯一通过 `registerStatic` 注册的插件包,因为模块系统无法加载自身。
+启动页只使用原生 DOM 与本地 CSS,因此客户端 bundle 或插件激活失败时仍能显示。其回退字体和颜色与加载期间到达的主题 token 一致。fiber 更新会保留同一个 spinner 节点,并在 entry 首次进入 active 时增长其 CSS 圆弧;hydrate 会继续保留该节点及其动画相位,直到应用提交。React 挂载、slot 渲染、应用组装和浏览器标题投影位于 [`ui-renderer`](../ui-renderer/README.md)。modules 包是唯一通过 `registerStatic` 注册的插件包,因为模块系统无法加载自身。
 
 `PLATFORM_MODULES`(src/platform.ts)是共享模块接口的唯一真源:种子表 key、tsdown 客户端 external 和 vite alias 集都是它的投影。
 

+ 14 - 1
packages/client/web/src/boot-page.module.css

@@ -45,14 +45,27 @@
 }
 
 .spinner {
+  position: relative;
   width: 20px;
   height: 20px;
   border-radius: 50%;
   border: 2px solid var(--dsw-alias-border-l2, var(--dsh-boot-border));
-  border-top-color: var(--dsw-alias-brand-primary, var(--dsh-boot-brand));
   animation: spin 0.8s linear infinite;
 }
 
+.spinner::after {
+  content: '';
+  position: absolute;
+  inset: -2px;
+  border-radius: inherit;
+  background: conic-gradient(
+    var(--dsw-alias-brand-primary, var(--dsh-boot-brand)) var(--dsh-boot-arc, 72deg),
+    transparent 0
+  );
+  -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 0);
+  mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 0);
+}
+
 @keyframes spin {
   to { transform: rotate(360deg); }
 }

+ 32 - 5
packages/client/web/src/boot-page.ts

@@ -18,7 +18,12 @@ function div(className: string | undefined, text?: string): HTMLDivElement {
 export class BootPage {
   private readonly root: HTMLDivElement
   private readonly card: HTMLDivElement
+  private readonly wordmark: HTMLDivElement
+  private readonly spinner: HTMLDivElement
+  private readonly hint: HTMLDivElement
   private readonly states = new Map<string, LoaderEntryState>()
+  private readonly active = new Set<string>()
+  private total = 0
   private failure: string | undefined
 
   /**
@@ -29,10 +34,23 @@ export class BootPage {
     this.root = div(css.boot)
     this.root.dataset.dshBoot = ''
     this.card = div(css.card)
-    this.card.append(div(css.wordmark, 'HARNESS'))
+    this.wordmark = div(css.wordmark, 'HARNESS')
+    this.spinner = div(css.spinner)
+    this.spinner.dataset.dshBootSpinner = ''
+    this.hint = div(css.hint, 'Loading plugins…')
+    this.card.append(this.wordmark, this.spinner, this.hint)
     this.root.append(this.card)
     container.append(this.root)
-    this.render()
+    this.updateProgress()
+  }
+
+  /**
+   * Set the number of loader entries represented by the progress arc.
+   * @param total - Complete boot roster size.
+   */
+  setTotal(total: number): void {
+    this.total = total
+    this.updateProgress()
   }
 
   /**
@@ -42,6 +60,8 @@ export class BootPage {
    */
   setState(id: string, state: LoaderEntryState): void {
     this.states.set(id, state)
+    if (state === 'active') this.active.add(id)
+    this.updateProgress()
     this.render()
   }
 
@@ -61,16 +81,23 @@ export class BootPage {
 
   /** Redraw the state-dependent content below the wordmark. */
   private render(): void {
-    while (this.card.childNodes.length > 1) this.card.lastChild?.remove()
     const failed = [...this.states].filter(([, state]) => state === 'failed').map(([id]) => id)
     if (this.failure === undefined && failed.length === 0) {
-      this.card.append(div(css.spinner), div(css.hint, 'Loading plugins…'))
+      if (this.spinner.parentElement !== this.card) {
+        this.card.replaceChildren(this.wordmark, this.spinner, this.hint)
+      }
       return
     }
     const report = div(css.failed)
     report.append(div(css.failedTitle, 'Failed to load plugins'))
     for (const id of failed) report.append(div(css.failedItem, id))
     if (this.failure !== undefined) report.append(div(css.failedItem, this.failure))
-    this.card.append(report)
+    this.card.replaceChildren(this.wordmark, report)
+  }
+
+  /** Grow the rotating arc monotonically as loader entries activate. */
+  private updateProgress(): void {
+    const ratio = this.total === 0 ? 0 : Math.min(this.active.size / this.total, 1)
+    this.spinner.style.setProperty('--dsh-boot-arc', `${String(Math.round(72 + ratio * 216))}deg`)
   }
 }

+ 2 - 1
packages/client/web/src/boot.ts

@@ -107,8 +107,9 @@ export class AppWebEntry {
       this.page.setState(entry.options.name, STATE_LABELS[entry.fiber.state])
     })
 
-    await prefetching
     const rows = [MODULES_ID, ...this.manifest.plugins.map(row => row.id).filter(id => id !== MODULES_ID)]
+    this.page.setTotal(rows.length)
+    await prefetching
     await Promise.all(rows.map(async (name) => {
       this.page.setState(name, 'loading')
       const id = await loader.create({ name })

+ 7 - 0
packages/client/web/tests/boot-page.client.spec.ts

@@ -20,8 +20,15 @@ describe('BootPage', () => {
 
   it('keeps loading while entries are active or loading', () => {
     const { el, page } = mount()
+    page.setTotal(2)
+    const spinner = el.querySelector<HTMLElement>('[data-dsh-boot-spinner]')
+    expect(spinner?.style.getPropertyValue('--dsh-boot-arc')).toBe('72deg')
     page.setState('a', 'active')
+    expect(spinner?.style.getPropertyValue('--dsh-boot-arc')).toBe('180deg')
     page.setState('b', 'loading')
+    expect(el.querySelector('[data-dsh-boot-spinner]')).toBe(spinner)
+    page.setState('b', 'active')
+    expect(spinner?.style.getPropertyValue('--dsh-boot-arc')).toBe('288deg')
     expect(el.textContent).toContain('Loading plugins…')
     expect(el.textContent).not.toContain('Failed to load plugins')
   })