dsh-config-manager 0.1.76 → 0.1.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/lib/adapters/plugin-data-declared.d.ts +12 -0
  2. package/lib/adapters/plugin-data-declared.js +57 -0
  3. package/lib/adapters/plugin-data.d.ts +4 -6
  4. package/lib/adapters/plugin-data.js +8 -36
  5. package/lib/adapters/plugins.js +92 -12
  6. package/lib/client.d.ts +104 -9
  7. package/lib/client.js +1796 -714
  8. package/lib/core/messages.d.ts +1 -0
  9. package/lib/core/messages.js +2 -0
  10. package/lib/core/rollback-attribution.d.ts +81 -0
  11. package/lib/core/rollback-attribution.js +236 -0
  12. package/lib/core/rollback-merge.d.ts +22 -0
  13. package/lib/core/rollback-merge.js +203 -0
  14. package/lib/core/rollback-point.d.ts +114 -0
  15. package/lib/core/rollback-point.js +534 -17
  16. package/lib/core/types.d.ts +18 -0
  17. package/lib/index.d.ts +26 -3
  18. package/lib/index.js +129 -16
  19. package/lib/routes/recovery.js +11 -0
  20. package/lib/schema/section-registry.js +5 -5
  21. package/lib/ui/badge-tone.d.ts +16 -0
  22. package/lib/ui/badge-tone.js +20 -0
  23. package/lib/ui/i18n.d.ts +4 -4
  24. package/lib/ui/i18n.js +8 -8
  25. package/lib/ui/rollback-view.d.ts +95 -0
  26. package/lib/ui/rollback-view.js +55 -0
  27. package/lib/ui/selection-model.d.ts +140 -1
  28. package/lib/ui/selection-model.js +241 -9
  29. package/package.json +1 -1
  30. package/src/adapters/plugin-data-declared.ts +60 -0
  31. package/src/adapters/plugin-data.ts +7 -33
  32. package/src/adapters/plugins.ts +98 -13
  33. package/src/adapters/units.test.ts +26 -0
  34. package/src/client/chat/ChatEntryView.tsx +147 -46
  35. package/src/client/chat/chat-entry-view.test.ts +277 -1
  36. package/src/client/chat/chat-entry-view.ts +98 -4
  37. package/src/client/chat/chat-node-registration.ts +71 -0
  38. package/src/client/chat/chat-slots.ts +30 -12
  39. package/src/client/common/ContentPicker.tsx +581 -175
  40. package/src/client/common/host-primitives.ts +64 -1
  41. package/src/client/common/ui.tsx +17 -3
  42. package/src/client/config-manager.module.css +630 -397
  43. package/src/client/css-scale-guard.test.ts +41 -3
  44. package/src/client/css-token-guard.test.ts +10 -4
  45. package/src/client/export/ExportView.tsx +2 -0
  46. package/src/client/index.ts +15 -3
  47. package/src/client/locales.ts +54 -10
  48. package/src/client/recovery/RollbackCard.tsx +56 -2
  49. package/src/client/recovery/RollbackDiffPane.tsx +5 -0
  50. package/src/client/recovery/RollbackFileChanges.tsx +46 -2
  51. package/src/client/recovery/recovery-locales.ts +22 -0
  52. package/src/client/recovery/rollback-file-changes.test.ts +1 -1
  53. package/src/core/messages.ts +2 -0
  54. package/src/core/rollback-attribution.test.ts +120 -0
  55. package/src/core/rollback-attribution.ts +267 -0
  56. package/src/core/rollback-merge.test.ts +133 -0
  57. package/src/core/rollback-merge.ts +213 -0
  58. package/src/core/rollback-point.test.ts +412 -0
  59. package/src/core/rollback-point.ts +560 -20
  60. package/src/core/types.ts +19 -0
  61. package/src/index.facade.test.ts +60 -0
  62. package/src/index.ts +133 -19
  63. package/src/routes/recovery.ts +11 -0
  64. package/src/schema/section-registry.ts +5 -5
  65. package/src/ui/badge-tone.test.ts +23 -0
  66. package/src/ui/badge-tone.ts +27 -0
  67. package/src/ui/i18n.ts +8 -8
  68. package/src/ui/rollback-view.test.ts +27 -0
  69. package/src/ui/rollback-view.ts +139 -0
  70. package/src/ui/selection-model.test.ts +240 -2
  71. package/src/ui/selection-model.ts +349 -1
@@ -28,8 +28,11 @@ import { parsePnpmPatchedDependencies, sanitizePnpmWorkspacePatches } from './pn
28
28
  import type { LocalPluginTarball, PatchLine, PluginEntry, PluginsSection, PnpmPatchFile } from '../schema/types.ts';
29
29
  import type {
30
30
  ApplyResult, ConfigAdapter, ExportOptions, ExportSection, ExportUnit, HostContext,
31
- ImportContext, PlanItem, SectionPreview, ValidationResult,
31
+ ImportContext, PlanItem, PluginAreaRef, SectionPreview, ValidationResult,
32
32
  } from '../core/types.ts';
33
+ import { BUILTIN_PLUGIN_DATA_SOURCES, areasForSection } from '../core/plugin-data-registry.ts';
34
+ import type { PluginDataSource } from '../core/plugin-data-registry.ts';
35
+ import { declaredProfileName, scanDeclaredDataSources } from './plugin-data-declared.ts';
33
36
  import { sectionMeta } from '../schema/section-registry.ts';
34
37
  import { validateJsonSection } from './json-section.ts';
35
38
 
@@ -156,6 +159,68 @@ function entriesOfRaw(raw: unknown): { config?: unknown }[] {
156
159
  */
157
160
  const previewTarballBytes = new WeakMap<object, Map<string, number>>();
158
161
 
162
+ /**
163
+ * 「插件包名 → 它声明的数据区域」映射(只有 \`plugin:<包名>\` 单元用;导出选择器「按插件看」的树骨架)。
164
+ *
165
+ * 为什么挂 WeakMap 而不是给 PluginsSection 加字段:这是**展示用旁路信息** —— 既不是备份格式的
166
+ * 一部分(schema 校验与市场防线都不该看见它),也与「导出真的带什么」无关;挂在载荷对象上随对象
167
+ * 一起回收,载荷本身逐字节不变(与 previewTarballBytes 同一套理由)。
168
+ */
169
+ const relatedAreasByData = new WeakMap<object, Map<string, PluginAreaRef[]>>();
170
+
171
+ /** 内置来源 + 自述声明的扫描缓存(TTL 内的同一 home/profile 只扫一次;/export-preview 一趟会问两次) */
172
+ const declaredSourcesMemo: { key: string; at: number; sources: readonly PluginDataSource[] } =
173
+ { key: '', at: 0, sources: [] };
174
+ const DECLARED_MEMO_TTL_MS = 5_000;
175
+
176
+ /**
177
+ * 本机可归属的插件数据来源 = 内置来源表 + 已安装插件自述声明。
178
+ *
179
+ * 缓存键必须带 homeDir + profile:同一台机器换 profile 就是另一批已安装插件,拿旧声明画树
180
+ * 会让用户看到不属于当前 profile 的数据区域。
181
+ */
182
+ async function pluginDataSources(ctx: HostContext): Promise<readonly PluginDataSource[]> {
183
+ const key = ctx.homeDir + '|' + declaredProfileName(ctx.profile);
184
+ const now = Date.now();
185
+ if (declaredSourcesMemo.key === key && now - declaredSourcesMemo.at < DECLARED_MEMO_TTL_MS) {
186
+ return declaredSourcesMemo.sources;
187
+ }
188
+ const sources = [...BUILTIN_PLUGIN_DATA_SOURCES, ...await scanDeclaredDataSources(ctx)];
189
+ declaredSourcesMemo.key = key;
190
+ declaredSourcesMemo.at = now;
191
+ declaredSourcesMemo.sources = sources;
192
+ return sources;
193
+ }
194
+
195
+ /**
196
+ * 包名 → 该插件的数据区域引用。
197
+ *
198
+ * 口径与 pluginData / pluginAssets 的收集面**同源**(同一个 areasForSection):role=config 进
199
+ * pluginData、role=assets 进 pluginAssets;role=cache 的区域不属于任何分区,因此**绝不进树**
200
+ * (缓存是可再生产物,列出来只会诱导用户把它带走)。
201
+ *
202
+ * 只产出「注册表说它应该在哪儿」,不代表磁盘上真有 —— 界面必须把两者分开渲染。
203
+ */
204
+ async function relatedAreasOf(ctx: HostContext): Promise<Map<string, PluginAreaRef[]>> {
205
+ const sources = await pluginDataSources(ctx);
206
+ const map = new Map<string, PluginAreaRef[]>();
207
+ for (const source of sources) {
208
+ const refs: PluginAreaRef[] = [];
209
+ for (const section of ['pluginData', 'pluginAssets'] as const) {
210
+ for (const entry of areasForSection([source], section)) {
211
+ refs.push({ section, relPath: entry.area.relPath, role: entry.area.role === 'assets' ? 'assets' : 'config' });
212
+ }
213
+ }
214
+ if (refs.length === 0) continue;
215
+ for (const pkg of source.packages) {
216
+ const cur = map.get(pkg);
217
+ if (cur === undefined) map.set(pkg, [...refs]);
218
+ else cur.push(...refs);
219
+ }
220
+ }
221
+ return map;
222
+ }
223
+
159
224
  /**
160
225
  * 零 spawn 推导本地源插件的体积(预览专用)。
161
226
  *
@@ -208,7 +273,11 @@ async function measureLocalSources(plugins: PluginEntry[], ctx: HostContext): Pr
208
273
  * `tarballBytes` = 预览路径登记到的本地源体积(见 measureLocalSources,0 = 体积未知但仍随包携带);
209
274
  * 缺省时体积与文件数完全按导出产物里的 base64 计算 —— 与改造前逐字一致。
210
275
  */
211
- function unitsOf(data: PluginsSection, tarballBytes?: Map<string, number>): ExportUnit[] {
276
+ function unitsOf(
277
+ data: PluginsSection,
278
+ tarballBytes?: Map<string, number>,
279
+ related?: Map<string, PluginAreaRef[]>,
280
+ ): ExportUnit[] {
212
281
  const wsId = 'plugins:pnpm-workspace';
213
282
  const patchFileIds = (data.patchFiles ?? []).map((pf) => `plugins:patch:${normalizePath(pf.relativePath)}`);
214
283
  const group = [wsId, ...patchFileIds];
@@ -216,6 +285,7 @@ function unitsOf(data: PluginsSection, tarballBytes?: Map<string, number>): Expo
216
285
  for (const pl of data.plugins) {
217
286
  const tarball = tarballOf(data, pl.name);
218
287
  const measured = tarballBytes?.get(pl.name);
288
+ const areas = related?.get(pl.name);
219
289
  units.push({
220
290
  id: `plugin:${pl.name}`,
221
291
  label: pl.name,
@@ -223,6 +293,8 @@ function unitsOf(data: PluginsSection, tarballBytes?: Map<string, number>): Expo
223
293
  // base64 长度 → 原始字节数(仅用于展示体积,不求精确);预览路径没有字节,用 stat 量到的源体积
224
294
  sizeBytes: tarball !== undefined ? Math.floor((tarball.base64.length * 3) / 4) : measured ?? 0,
225
295
  ...(tarball !== undefined || measured !== undefined ? { fileCount: 1 } : {}),
296
+ // 「按插件看」的树骨架:该插件声明的数据区域(零数据目录 I/O)
297
+ ...(areas !== undefined && areas.length > 0 ? { relatedAreas: areas } : {}),
226
298
  });
227
299
  }
228
300
  for (const line of data.patch) {
@@ -291,7 +363,7 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
291
363
  section: collected,
292
364
  // 与 src/index.ts 对 JSON 分区现算体积的口径一致(utf8 字节数)
293
365
  sizeBytes: new TextEncoder().encode(stringifyJsonSafe(data)).length,
294
- items: unitsOf(data, tarballBytes),
366
+ items: unitsOf(data, tarballBytes, relatedAreasByData.get(data)),
295
367
  };
296
368
  }
297
369
 
@@ -432,16 +504,28 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
432
504
  const alive = new Set(keptPlugins.map((pl) => pl.name));
433
505
  keptTarballs = localTarballs?.filter((t) => alive.has(t.packageName));
434
506
  }
507
+ const data: PluginsSection = {
508
+ version: 1,
509
+ plugins: keptPlugins,
510
+ patch: keptPatch,
511
+ pnpmWorkspace: keptWorkspace,
512
+ ...(keptTarballs !== undefined ? { localTarballs: keptTarballs } : {}),
513
+ ...(keptPatchFiles !== undefined ? { patchFiles: keptPatchFiles } : {}),
514
+ };
515
+ /**
516
+ * 「按插件看」的树骨架(导出选择器):登记每个插件声明的数据区域。
517
+ * 只登记「至少有一个区域」的插件 —— 其余插件单元保持无该字段,界面据此不显示空壳节点。
518
+ * 归属信息是**附加**信息:扫描失败不得影响导出/预览本身(与 pluginData 同一纪律)。
519
+ */
520
+ try {
521
+ const related = await relatedAreasOf(ctx);
522
+ if (related.size > 0) relatedAreasByData.set(data, related);
523
+ } catch {
524
+ // 读不到声明就不登记(界面按「没有已知关联」渲染,绝不猜)
525
+ }
435
526
  return {
436
527
  sectionId: 'plugins',
437
- data: {
438
- version: 1,
439
- plugins: keptPlugins,
440
- patch: keptPatch,
441
- pnpmWorkspace: keptWorkspace,
442
- ...(keptTarballs !== undefined ? { localTarballs: keptTarballs } : {}),
443
- ...(keptPatchFiles !== undefined ? { patchFiles: keptPatchFiles } : {}),
444
- },
528
+ data,
445
529
  counts: {
446
530
  plugins: keptPlugins.length,
447
531
  patchLines: keptPatch.length,
@@ -461,8 +545,9 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
461
545
  * 由选择模型保证勾选/取消同步。
462
546
  */
463
547
  listUnits(section: ExportSection<PluginsSection>): ExportUnit[] {
464
- // 预览产物没有 tarball 字节,体积来自 measureLocalSources 挂上的统计(导出产物走 base64 分支)
465
- return unitsOf(section.data, previewTarballBytes.get(section.data));
548
+ // 预览产物没有 tarball 字节,体积来自 measureLocalSources 挂上的统计(导出产物走 base64 分支);
549
+ // 「按插件看」的数据区域骨架同样由 collect() 登记在载荷对象上(两条路径逐字一致,units.test 钉住)
550
+ return unitsOf(section.data, previewTarballBytes.get(section.data), relatedAreasByData.get(section.data));
466
551
  }
467
552
 
468
553
  /**
@@ -147,6 +147,32 @@ test('plugins:按包名过滤,cordis 补丁行独立成单元', async () =>
147
147
  assert.deepEqual(filtered.data.patch, [], '未勾选的补丁行不得被带出');
148
148
  });
149
149
 
150
+ test('plugins:「按插件看」的树骨架 —— 单元带上它声明的数据区域(内置来源表,缓存永不进树)', async () => {
151
+ const ctx = makeContext('win32', HOME);
152
+ // 内置来源表:dsh-mnemon → runtime(config) + data/documents(assets);
153
+ // dsh-plugin-wallpaper-engine → 数据目录(config) + uploads(assets) + cache(**不属于任何分区**)
154
+ ctx.plugins.installed.set('dsh-mnemon', { name: 'dsh-mnemon', version: '1.0.0', enabled: true });
155
+ ctx.plugins.installed.set('dsh-plugin-wallpaper-engine', {
156
+ name: 'dsh-plugin-wallpaper-engine', version: '1.0.0', enabled: true,
157
+ });
158
+ ctx.plugins.installed.set('pkg-a', { name: 'pkg-a', version: '1.0.0', enabled: true });
159
+ const adapter = new PluginsAdapter();
160
+
161
+ const previewed = await adapter.preview(ctx, { includeSecrets: false });
162
+ assert.deepEqual(previewed.items.find((u) => u.id === 'plugin:dsh-mnemon')?.relatedAreas, [
163
+ { section: 'pluginData', relPath: '.mnemon/runtime', role: 'config' },
164
+ { section: 'pluginAssets', relPath: '.mnemon/data', role: 'assets' },
165
+ { section: 'pluginAssets', relPath: '.mnemon/documents', role: 'assets' },
166
+ ]);
167
+ const we = previewed.items.find((u) => u.id === 'plugin:dsh-plugin-wallpaper-engine')?.relatedAreas ?? [];
168
+ assert.deepEqual(we.map((r) => r.relPath), ['.dsh-wallpaper-engine', '.dsh-wallpaper-engine/uploads'],
169
+ 'cache 区域可再生产物、不属于任何分区,绝不进树');
170
+ // 没有声明的插件不得凭空带上区域(不猜)
171
+ assert.equal(previewed.items.find((u) => u.id === 'plugin:pkg-a')?.relatedAreas, undefined);
172
+ // 预览与导出两条路径的单元清单必须逐项相同(本文件既有纪律)
173
+ assert.deepEqual(previewed.items, adapter.listUnits(previewed.section));
174
+ });
175
+
150
176
  test('plugins:pnpm-workspace.yaml ↔ patch 文件是原子组(lockedWith 双向声明)', () => {
151
177
  const adapter = new PluginsAdapter();
152
178
  const section: ExportSection<PluginsSection> = {
@@ -3,9 +3,15 @@
3
3
  *
4
4
  * 为什么必须接管(而不是追加):该 key 没有 children 表(宿主只对 turn-tail 声明了子位),
5
5
  * framework 没有「往用户消息里加 UI」的位子;唯一合法做法是 priority -1 遮蔽宿主的
6
- * `UserMessageNodeView`,然后在自己的渲染里把那条气泡**复刻**一遍(宿主组件 / CSS Modules
7
- * 全是 ui-chat 私有、不可导入 —— 外观靠本仓库 CSS 逼近,图片**必须**走 owner 给的
8
- * `renderMessageImages`)。
6
+ * `UserMessageNodeView`,然后在自己的渲染里产出那条气泡。
7
+ *
8
+ * 正文**谁来画**在 0.2.1-alpha.3 换过一代(issue #92):宿主把图片位搬进
9
+ * `conversation.chat.user.content` factory 的 children,并删掉了节点 props 上的 `renderMessageImages`。
10
+ * 因此现在的口径是 **优先委托宿主的 content factory**(`renderFactorySlot(CHAT_USER_CONTENT_FACTORY, …)`),
11
+ * 只有在宿主**没有**注册那条 factory 时(≤0.2.0)才回落到自绘气泡 —— 自绘气泡同时是那次调用的
12
+ * `options.fallback`,所以三格(老宿主 / 新宿主 / 宿主中途撤掉 factory)都有确定行为,
13
+ * 不会出现空白气泡。自绘那一格图片只能走 owner 的 `renderMessageImages`(宿主的 CSS Modules 与组件
14
+ * 全是 ui-chat 私有、不可导入,外观靠本仓库 CSS 逼近)。
9
15
  *
10
16
  * 三件硬约束(本文件是它们的落点):
11
17
  * ① **用户消息绝不消失**:整个子树包在 `ChatEntryErrorBoundary` 里,抛错就降级成
@@ -36,13 +42,14 @@ import {
36
42
  lookupPointForTurn,
37
43
  rawBlockText,
38
44
  CHAT_ENTRY_WARN_PREFIX,
45
+ CHAT_USER_CONTENT_FACTORY,
39
46
  type ChatEntryBubbleView,
40
47
  type ChatEntryFallbackRow,
41
48
  type ChatEntryFile,
42
49
  type ChatPointLookup,
43
50
  } from './chat-entry-view.ts'
44
51
  import { ChatPointWindowController, type ChatPointWindowState } from './chat-point-window.ts'
45
- import { hostFileTypeIcon, hostUserTextProjector } from '../common/host-primitives.ts'
52
+ import { hostFileTypeIcon, hostIcon, hostUserTextProjector } from '../common/host-primitives.ts'
46
53
  import type { ArchiveOutcome } from './rollback-entry-view.ts'
47
54
  import type { ChildOpenState } from './rollback-entry-view.ts'
48
55
  import type { RollbackFileIconRenderer } from './RollbackFileIcon.tsx'
@@ -62,6 +69,23 @@ const NEWLINE = String.fromCharCode(10)
62
69
  */
63
70
  export type MessageImagesRenderer = (owner: unknown) => ReactNode
64
71
 
72
+ /**
73
+ * 宿主 content factory 渲染位(0.2.1-alpha.3 起节点渲染器 props 上的 `renderFactorySlot`,
74
+ * 由 ui-slots 的 `PropsRenderFactories` 挂在**每一个**渲染组件上)。
75
+ *
76
+ * 为什么签名这么宽:真实类型是 `RenderFactorySlot`(按 SlotFactoryMap 泛型收窄),本仓库没有
77
+ * ui-chat 的类型;我们只承诺「按名字原样转发入参 + 收 `options.fallback`」,返回值当不透明 ReactNode。
78
+ *
79
+ * 注意它**在新旧宿主上都存在**(ui-renderer 的 standardKit 无条件发放):所以「走不走委托」不能只判
80
+ * 它是不是函数 —— 真正的判据是宿主到底注册没注册那条 factory,由 `FactoryOutlet` 在定义缺失时
81
+ * 回落我们给的 fallback 来裁决(见 ChatEntryBody)。
82
+ */
83
+ export type HostContentFactoryRenderer = (
84
+ name: string,
85
+ props: unknown,
86
+ options?: { fallback?: ReactNode },
87
+ ) => ReactNode
88
+
65
89
  /** 本条目经 inject 面拿到的业务能力(与回合尾入口同一个 inject 形状)。 */
66
90
  export interface ChatEntryViewInjected {
67
91
  /** 回退端口(T2 的 RecoveryApi;组件**不直接 fetch**)。 */
@@ -105,13 +129,22 @@ export type ChatEntryViewProps = PropsRuntime<'conversation.chat.node', 'user'>
105
129
  */
106
130
  function FileCard({ file, size }: { file: ChatEntryFile; size: string | null }) {
107
131
  const DshIcon = hostFileTypeIcon()
132
+ /**
133
+ * 元信息 = 宿主 `.fileMeta` 的**同一组合**:大写的扩展名(≤8 字符)+ 体积文本,空格连接。
134
+ * 宿主那行是「EXT SIZE」一行的次级小字(不是我们此前那个「文件名旁边挂一个体积」的排法)。
135
+ */
136
+ const meta = [file.ext === null ? '' : file.ext.toUpperCase().slice(0, 8), size ?? '']
137
+ .filter((part) => part !== '')
138
+ .join(' ')
108
139
  return (
109
140
  <span className={css.chatFileCard} title={file.name !== '' ? file.name : undefined}>
110
141
  {DshIcon !== null && file.name !== ''
111
- ? <DshIcon path={file.name} size={14} />
142
+ ? <DshIcon path={file.name} size={28} className={css.chatFileIcon} />
112
143
  : file.ext !== null && <span className={css.chatFileExt}>{file.ext}</span>}
113
- <span className={css.chatFileName}>{file.name !== '' ? file.name : '?'}</span>
114
- {size !== null && <span className={css.chatFileMeta}>{size}</span>}
144
+ <span className={css.chatFileContent}>
145
+ <span className={css.chatFileName}>{file.name !== '' ? file.name : '?'}</span>
146
+ {meta !== '' && <span className={css.chatFileMeta}>{meta}</span>}
147
+ </span>
115
148
  </span>
116
149
  )
117
150
  }
@@ -141,6 +174,14 @@ function ChatEntryCopyAction({ text, t }: { text: string; t: TranslateNS<'config
141
174
  if (copied) return
142
175
  void copyTextToClipboard(text).then((ok) => { if (ok) setCopied(true) })
143
176
  }, [copied, text])
177
+ /**
178
+ * 图标 = **DSH 原生那两颗**(2026-10-17 用户要求「复制 icon 与动画都用 DSH 原生的」):
179
+ * 宿主 MessageIconActions 的复制反馈就是 `IconCopyOutlineRegular → IconCheckOutlineRegular` 的
180
+ * **瞬时切换**(宿主 `.action svg` 没有任何 transition/animation —— 它的「动画」就是这一次换图)。
181
+ * 本仓库的 `CopyStateIcon`(morphicons 形变)只在**拿不到宿主图标的老宿主**上兜底。
182
+ */
183
+ const HostCopyIcon = hostIcon('IconCopyOutlineRegular')
184
+ const HostCheckIcon = hostIcon('IconCheckOutlineRegular')
144
185
  return (
145
186
  <button
146
187
  type="button"
@@ -148,8 +189,11 @@ function ChatEntryCopyAction({ text, t }: { text: string; t: TranslateNS<'config
148
189
  aria-label={copied ? t('chat.entry.copied') : t('chat.entry.copy')}
149
190
  title={copied ? t('chat.entry.copied') : t('chat.entry.copy')}
150
191
  onClick={onCopy}
192
+ data-copy-state={copied ? 'copied' : 'idle'}
151
193
  >
152
- <CopyStateIcon done={copied} size={13} />
194
+ {HostCopyIcon !== null && HostCheckIcon !== null
195
+ ? (copied ? <HostCheckIcon size={15} /> : <HostCopyIcon size={15} />)
196
+ : <CopyStateIcon done={copied} size={15} />}
153
197
  </button>
154
198
  )
155
199
  }
@@ -162,6 +206,10 @@ interface ChatEntryBodyProps extends ChatEntryViewInjected {
162
206
  openSkill?: unknown
163
207
  /** 宿主图片渲染位(SlotMap 的 keyProps 把返回值声明成 unknown ⇒ 用它当 `MessageImagesRenderer` 必须过一层窄化)。 */
164
208
  renderMessageImages?: unknown
209
+ /** 宿主授权过的图片加载器(0.2.1+ 节点渲染器 props;原样转发给 content factory)。 */
210
+ loadImage?: unknown
211
+ /** 宿主 content factory 渲染位(0.2.1+;老宿主拿不到 ⇒ 直接走自绘气泡。见 HostContentFactoryRenderer)。 */
212
+ renderFactorySlot?: unknown
165
213
  t: TranslateNS<'config-manager-chat'>
166
214
  }
167
215
 
@@ -185,7 +233,10 @@ interface ChatEntryBodyProps extends ChatEntryViewInjected {
185
233
  * 订阅入口可见性信号(issue #88)。
186
234
  *
187
235
  * 为什么要在组件里订阅而不是把开关当 prop 往下传:入口是**遮蔽宿主渲染器**注册的(无常),
188
- * 开关变化时没有任何父级会重渲染它 —— DNS 状态只能靠这条信号推。订阅开销 = 一个 Set 里的回调。
236
+ * 开关变化时没有任何父级会重渲染它 —— 状态只能靠这条信号推。订阅开销 = 一个 Set 里的回调。
237
+ *
238
+ * 2026-10-17 起登记本身随开关撤销(关闭 ⇒ 让出 user cell、本组件直接卸载,宿主原生气泡接管回来),
239
+ * 所以这里是**兜底**:万一信号已翻转而本组件还活着,也绝不画出那颗按钮。
189
240
  */
190
241
  function useRollbackEntryEnabled(): boolean {
191
242
  const [value, setValue] = useState(() => isRollbackEntryEnabled())
@@ -193,7 +244,7 @@ function useRollbackEntryEnabled(): boolean {
193
244
  return value
194
245
  }
195
246
 
196
- function ChatEntryBody({ node, sessionId, openFile, openSkill, renderMessageImages, t, rt, port, openChildSession, archiveParentSession, startFreshSession, openDiff, fileIcon }: ChatEntryBodyProps) {
247
+ function ChatEntryBody({ node, sessionId, openFile, openSkill, renderMessageImages, loadImage, renderFactorySlot, t, rt, port, openChildSession, archiveParentSession, startFreshSession, openDiff, fileIcon }: ChatEntryBodyProps) {
197
248
  const view = useMemo(() => buildChatEntryView(node), [node])
198
249
  const [lookup, setLookup] = useState<ChatPointLookup | null>(null)
199
250
  /** 有界重试窗口是否还开着(**状态诚实**的唯一开关,R1)—— 由状态机的 `onState` 驱动。 */
@@ -231,6 +282,7 @@ function ChatEntryBody({ node, sessionId, openFile, openSkill, renderMessageImag
231
282
  * 「用户再次触发」= 重新挂载(切档 / 重进会话)或会话/回合变化 ⇒ 新实例;不需要刷新页面。
232
283
  */
233
284
  // issue #88:功能关闭时连**查询都不发**(不只是不画按钮)—— 入口收起 = 这一整个入口链路停摆。
285
+ // 关掉功能后登记会被撤销(本组件随之卸载),这两道门因此是兜底而非唯一机制。
234
286
  const entryEnabled = useRollbackEntryEnabled()
235
287
  const canLookup = entryEnabled && turn !== null && phase === 'closed' && sessionId !== ''
236
288
  useEffect(() => {
@@ -254,10 +306,16 @@ function ChatEntryBody({ node, sessionId, openFile, openSkill, renderMessageImag
254
306
  }, [button])
255
307
 
256
308
  const bubble: ChatEntryBubbleView = view.bubble
309
+ /** 回退图标同样走宿主(`IconRefreshOutlineRegular`):与复制图标同一套字形/描边;拿不到回落 RefreshIcon。 */
310
+ const HostRefreshIcon = hostIcon('IconRefreshOutlineRegular')
257
311
  /**
258
312
  * 附件按**content 原始顺序**输出(宿主 `attachments.map` 同构):图片走 owner 的
259
313
  * `renderMessageImages`(每张一次调用),文件走文件卡 —— 两类**共用同一条序列**,
260
314
  * 绝不重排成「先图片后文件」(t4 复核 D2)。
315
+ *
316
+ * 这条自绘路径**只在宿主没有 content factory 时**才会上屏(见下面的 `content`):
317
+ * 0.2.1 起 `renderMessageImages` 已不存在,此时图片位置如实显示提示文本 —— 但那一格
318
+ * 正常情况下走的是宿主自己的 factory,根本到不了这里。
261
319
  */
262
320
  const canRenderImages = typeof renderMessageImages === 'function'
263
321
  const attachmentNodes = planChatEntryAttachments(bubble).map((item) => item.kind === 'image'
@@ -283,43 +341,85 @@ function ChatEntryBody({ node, sessionId, openFile, openSkill, renderMessageImag
283
341
  })
284
342
  const reasonKey = button.kind === 'disabled' ? button.key : null
285
343
 
344
+ /**
345
+ * **自绘气泡**(= 0.2.1 之前那条路径,style 与宿主 .userStack/.bubble 逐条对齐见本文件头注释)。
346
+ *
347
+ * 两个身份:① 老宿主(≤0.2.0)没有 `conversation.chat.user.content` 这条 factory,正文由它承担;
348
+ * ② 新宿主上它是那次 factory 调用的 **fallback** —— 定义缺失(或宿主中途撤掉)时由
349
+ * `FactoryOutlet` 回落,用户消息因此**永远不会**变成空白。
350
+ *
351
+ * 图片:这条路径只能走 owner 的 `renderMessageImages`(0.2.1 起已不存在)⇒ 那一格如实显示提示文本,
352
+ * 而不是静默消失;新宿主正常情况下根本不会渲染到这里(走的是宿主自己的 content factory)。
353
+ */
354
+ const ownContent = (
355
+ <div className={css.chatEntryStack}>
356
+ {hasAttachments && (
357
+ <div className={css.chatAttachmentRow} data-message-attachments="">
358
+ {attachmentNodes}
359
+ </div>
360
+ )}
361
+ {bubble.showBubble && (
362
+ <div className={css.chatBubble}>
363
+ {bubble.text !== '' && (
364
+ <span className={css.chatBubbleText}>{hostText === null ? bubble.text : hostText}</span>
365
+ )}
366
+ {bubble.raw.map((block) => (
367
+ <RawBlock
368
+ key={'raw-' + block.id}
369
+ payload={block.payload}
370
+ label={t('chat.entry.extraBlock')}
371
+ truncatedLabel={(total) => t('chat.entry.truncated', { total })}
372
+ />
373
+ ))}
374
+ </div>
375
+ )}
376
+ {!canRenderImages && bubble.images.length > 0 && (
377
+ <div className={css.chatEntryNotice}>{t('chat.entry.noImageRenderer')}</div>
378
+ )}
379
+ {/* 引用会话汇总行:宿主在气泡下方渲染 referenceSummary(`引用会话 · {labels}`)。
380
+ 我们复刻这一行(文案在 chat 字典里,**不依赖 ui-primitives**);`data-ref-chip` 供探针定位。 */}
381
+ {view.referenceLabels.length > 0 && (
382
+ <div className={css.chatEntryReference} data-ref-chip="">
383
+ {t('chat.entry.referenceSummary', {
384
+ labels: view.referenceLabels.join(t('chat.entry.referenceSeparator')),
385
+ })}
386
+ </div>
387
+ )}
388
+ </div>
389
+ )
390
+
391
+ /**
392
+ * 正文 / 附件 / 引用(issue #92):**优先委托宿主自己的 content factory**。
393
+ *
394
+ * 为什么不能再自绘:0.2.1-alpha.3 起宿主把图片位搬进 `conversation.chat.user.content` 的 children
395
+ * (`conversation.chat.user.images`),并在节点 props 上删掉了 `renderMessageImages` —— 自己复刻气泡
396
+ * 就必然拿不到图片位。委托之后,图片由 ui-attachment 在该 factory 的渲染上下文里 renderSlot,
397
+ * 正文/文件卡/引用/未知块也全部回到宿主实现 ⇒ 与宿主原生气泡**同构**,且以后宿主再改图片实现也不用跟。
398
+ *
399
+ * 入参逐字对齐宿主 `UserStyleBubble`(`content` / `loadImage` / `facts` / `interactive: true`),
400
+ * 组装全在纯层 `buildHostUserContentInput`。**判据不是 `typeof renderFactorySlot === 'function'`** ——
401
+ * 该席位在新旧宿主上都存在(ui-renderer 的 standardKit 无条件发放),真正的裁决点是这条 factory
402
+ * 注册没注册:`FactoryOutlet` 在读不到定义时渲染我们给的 `fallback`(= 上面的自绘气泡)——
403
+ * 于是「老宿主 ≤0.2.0」「新宿主 ≥0.2.1」「宿主中途撤掉 factory」三格都有确定行为,不必开两条发布线。
404
+ * 调用本身不吃任何副作用,返回不透明 ReactNode(React 元素),空定义时是 fallback 的元素包装。
405
+ */
406
+ const content = typeof renderFactorySlot === 'function'
407
+ ? (renderFactorySlot as HostContentFactoryRenderer)(
408
+ CHAT_USER_CONTENT_FACTORY,
409
+ {
410
+ content: view.hostContent.content,
411
+ loadImage,
412
+ facts: view.hostContent.facts,
413
+ interactive: true,
414
+ },
415
+ { fallback: ownContent },
416
+ )
417
+ : ownContent
418
+
286
419
  return (
287
420
  <div className={css.chatEntryScope}>
288
421
  <div className={css.chatEntryRow}>
289
- <div className={css.chatEntryStack}>
290
- {hasAttachments && (
291
- <div className={css.chatAttachmentRow} data-message-attachments="">
292
- {attachmentNodes}
293
- </div>
294
- )}
295
- {bubble.showBubble && (
296
- <div className={css.chatBubble}>
297
- {bubble.text !== '' && (
298
- <span className={css.chatBubbleText}>{hostText === null ? bubble.text : hostText}</span>
299
- )}
300
- {bubble.raw.map((block) => (
301
- <RawBlock
302
- key={'raw-' + block.id}
303
- payload={block.payload}
304
- label={t('chat.entry.extraBlock')}
305
- truncatedLabel={(total) => t('chat.entry.truncated', { total })}
306
- />
307
- ))}
308
- </div>
309
- )}
310
- {!canRenderImages && bubble.images.length > 0 && (
311
- <div className={css.chatEntryNotice}>{t('chat.entry.noImageRenderer')}</div>
312
- )}
313
- {/* 引用会话汇总行:宿主在气泡下方渲染 referenceSummary(`引用会话 · {labels}`)。
314
- 我们复刻这一行(文案在 chat 字典里,**不依赖 ui-primitives**);`data-ref-chip` 供探针定位。 */}
315
- {view.referenceLabels.length > 0 && (
316
- <div className={css.chatEntryReference} data-ref-chip="">
317
- {t('chat.entry.referenceSummary', {
318
- labels: view.referenceLabels.join(t('chat.entry.referenceSeparator')),
319
- })}
320
- </div>
321
- )}
322
- </div>
422
+ {content}
323
423
  <div className={css.chatEntryActions}>
324
424
  {/* 时间在动作行**最前**(宿主把 MessageIconActions 的 clock 设为 "start",与它同构) */}
325
425
  {view.clock !== null && <span className={css.chatEntryTime}>{view.clock}</span>}
@@ -328,7 +428,8 @@ function ChatEntryBody({ node, sessionId, openFile, openSkill, renderMessageImag
328
428
  2026-10-10 用户第二条要求「回退按钮只有一个 icon 而不是一个按钮」。可见文案退到
329
429
  aria-label/title;禁用原因是原生 title 里的一句话(InfoHint 的 Portal 钉在插件设置页
330
430
  根节点,聊天区不存在)。按钮始终留在原地(禁用而不是消失):入口可见性不随判据漂移。 */}
331
- {/* 开关**关闭时整颗收起**(issue #88 明确要求「入口收起」,不是置灰)。
431
+ {/* 开关**关闭时整颗收起**(issue #88 明确要求「入口收起」,不是置灰;2026-10-17 起连登记
432
+ 都会被撤销,整条气泡交回宿主原生渲染,这里只是兜底的那一层)。
332
433
  与上面那条「入口可见性不随判据漂移」不冲突:那说的是**暂时读不到点**这类判据,
333
434
  而这里是「用户把这个功能关了」——此时入口本身就不该存在。 */}
334
435
  {entryEnabled && (
@@ -343,7 +444,7 @@ function ChatEntryBody({ node, sessionId, openFile, openSkill, renderMessageImag
343
444
  data-chat-entry-window-open={windowOpen ? '' : undefined}
344
445
  data-chat-entry-window-closed={windowOpen ? undefined : ''}
345
446
  >
346
- <RefreshIcon size={13} />
447
+ {HostRefreshIcon === null ? <RefreshIcon size={15} /> : <HostRefreshIcon size={15} />}
347
448
  </button>
348
449
  )}
349
450
  </div>