dsh-plugin-core 0.33.3 → 0.35.0

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 (126) hide show
  1. package/dist/adv/session/SessionViewModal.d.ts +20 -2
  2. package/dist/adv/session/SessionViewModal.d.ts.map +1 -1
  3. package/dist/adv/session/SessionViewModal.js +186 -73
  4. package/dist/adv/session/SessionViewModal.js.map +1 -1
  5. package/dist/adv/session/faces.d.ts +30 -7
  6. package/dist/adv/session/faces.d.ts.map +1 -1
  7. package/dist/adv/session/index.d.ts +1 -0
  8. package/dist/adv/session/index.d.ts.map +1 -1
  9. package/dist/adv/session/index.js +1 -0
  10. package/dist/adv/session/index.js.map +1 -1
  11. package/dist/adv/session/mirror/BashRow.d.ts.map +1 -1
  12. package/dist/adv/session/mirror/BashRow.js +11 -2
  13. package/dist/adv/session/mirror/BashRow.js.map +1 -1
  14. package/dist/adv/session/mirror/ChatView.d.ts +8 -0
  15. package/dist/adv/session/mirror/ChatView.d.ts.map +1 -1
  16. package/dist/adv/session/mirror/ChatView.js +8 -0
  17. package/dist/adv/session/mirror/ChatView.js.map +1 -1
  18. package/dist/adv/session/mirror/ContextBody.d.ts +24 -0
  19. package/dist/adv/session/mirror/ContextBody.d.ts.map +1 -0
  20. package/dist/adv/session/mirror/ContextBody.js +249 -0
  21. package/dist/adv/session/mirror/ContextBody.js.map +1 -0
  22. package/dist/adv/session/mirror/ContextInjectionRow.d.ts +21 -1
  23. package/dist/adv/session/mirror/ContextInjectionRow.d.ts.map +1 -1
  24. package/dist/adv/session/mirror/ContextInjectionRow.js +57 -29
  25. package/dist/adv/session/mirror/ContextInjectionRow.js.map +1 -1
  26. package/dist/adv/session/mirror/GenericCommandCard.d.ts +20 -0
  27. package/dist/adv/session/mirror/GenericCommandCard.d.ts.map +1 -1
  28. package/dist/adv/session/mirror/GenericCommandCard.js +89 -27
  29. package/dist/adv/session/mirror/GenericCommandCard.js.map +1 -1
  30. package/dist/adv/session/mirror/MessageIconActions.js +1 -1
  31. package/dist/adv/session/mirror/MessageIconActions.js.map +1 -1
  32. package/dist/adv/session/mirror/MessageImage.d.ts +23 -0
  33. package/dist/adv/session/mirror/MessageImage.d.ts.map +1 -0
  34. package/dist/adv/session/mirror/MessageImage.js +107 -0
  35. package/dist/adv/session/mirror/MessageImage.js.map +1 -0
  36. package/dist/adv/session/mirror/MessageItem.d.ts +11 -0
  37. package/dist/adv/session/mirror/MessageItem.d.ts.map +1 -1
  38. package/dist/adv/session/mirror/MessageItem.js +12 -2
  39. package/dist/adv/session/mirror/MessageItem.js.map +1 -1
  40. package/dist/adv/session/official-classes.d.ts +2 -0
  41. package/dist/adv/session/official-classes.d.ts.map +1 -1
  42. package/dist/adv/session/official-classes.js +3 -0
  43. package/dist/adv/session/official-classes.js.map +1 -1
  44. package/dist/adv/session/open-session-view.d.ts +6 -1
  45. package/dist/adv/session/open-session-view.d.ts.map +1 -1
  46. package/dist/adv/session/open-session-view.js +8 -0
  47. package/dist/adv/session/open-session-view.js.map +1 -1
  48. package/dist/adv/session/session-css.generated.d.ts +1 -1
  49. package/dist/adv/session/session-css.generated.d.ts.map +1 -1
  50. package/dist/adv/session/session-css.generated.js +1 -1
  51. package/dist/adv/session/session-css.generated.js.map +1 -1
  52. package/dist/design/tokens-css.generated.d.ts +1 -1
  53. package/dist/design/tokens-css.generated.d.ts.map +1 -1
  54. package/dist/design/tokens-css.generated.js +1 -1
  55. package/dist/design/tokens-css.generated.js.map +1 -1
  56. package/dist/ui/feedback/Modal.d.ts +7 -0
  57. package/dist/ui/feedback/Modal.d.ts.map +1 -1
  58. package/dist/ui/feedback/Modal.js +2 -0
  59. package/dist/ui/feedback/Modal.js.map +1 -1
  60. package/dist/ui/index.d.ts +2 -2
  61. package/dist/ui/index.d.ts.map +1 -1
  62. package/dist/ui/index.js +2 -2
  63. package/dist/ui/index.js.map +1 -1
  64. package/dist/ui/infra/overlay-css.generated.d.ts +1 -1
  65. package/dist/ui/infra/overlay-css.generated.d.ts.map +1 -1
  66. package/dist/ui/infra/overlay-css.generated.js +1 -1
  67. package/dist/ui/infra/overlay-css.generated.js.map +1 -1
  68. package/dist/ui/infra/overlay.d.ts +7 -0
  69. package/dist/ui/infra/overlay.d.ts.map +1 -1
  70. package/dist/ui/infra/overlay.js +6 -1
  71. package/dist/ui/infra/overlay.js.map +1 -1
  72. package/dist/ui/infra/panel-css.generated.d.ts +1 -1
  73. package/dist/ui/infra/panel-css.generated.d.ts.map +1 -1
  74. package/dist/ui/infra/panel-css.generated.js +1 -1
  75. package/dist/ui/infra/panel-css.generated.js.map +1 -1
  76. package/dist/ui/inputs/DateTimePicker.d.ts +5 -1
  77. package/dist/ui/inputs/DateTimePicker.d.ts.map +1 -1
  78. package/dist/ui/inputs/DateTimePicker.js +55 -7
  79. package/dist/ui/inputs/DateTimePicker.js.map +1 -1
  80. package/dist/ui/inputs/Picker.d.ts +2 -0
  81. package/dist/ui/inputs/Picker.d.ts.map +1 -1
  82. package/dist/ui/inputs/Picker.js +25 -8
  83. package/dist/ui/inputs/Picker.js.map +1 -1
  84. package/dist/ui/inputs/datetime-css.generated.d.ts +1 -1
  85. package/dist/ui/inputs/datetime-css.generated.d.ts.map +1 -1
  86. package/dist/ui/inputs/datetime-css.generated.js +1 -1
  87. package/dist/ui/inputs/datetime-css.generated.js.map +1 -1
  88. package/dist/ui/inputs/datetime-logic.d.ts +13 -0
  89. package/dist/ui/inputs/datetime-logic.d.ts.map +1 -1
  90. package/dist/ui/inputs/datetime-logic.js +68 -0
  91. package/dist/ui/inputs/datetime-logic.js.map +1 -1
  92. package/dist/ui/inputs/field-css.generated.d.ts +1 -1
  93. package/dist/ui/inputs/field-css.generated.d.ts.map +1 -1
  94. package/dist/ui/inputs/field-css.generated.js +1 -1
  95. package/dist/ui/inputs/field-css.generated.js.map +1 -1
  96. package/dist/ui/inputs/input-group-css.generated.d.ts +1 -1
  97. package/dist/ui/inputs/input-group-css.generated.d.ts.map +1 -1
  98. package/dist/ui/inputs/input-group-css.generated.js +1 -1
  99. package/dist/ui/inputs/input-group-css.generated.js.map +1 -1
  100. package/dist/ui/inputs/picker-css.generated.d.ts +1 -1
  101. package/dist/ui/inputs/picker-css.generated.d.ts.map +1 -1
  102. package/dist/ui/inputs/picker-css.generated.js +1 -1
  103. package/dist/ui/inputs/picker-css.generated.js.map +1 -1
  104. package/dist/ui/inputs/picker-logic.d.ts +5 -1
  105. package/dist/ui/inputs/picker-logic.d.ts.map +1 -1
  106. package/dist/ui/inputs/picker-logic.js +16 -2
  107. package/dist/ui/inputs/picker-logic.js.map +1 -1
  108. package/dist/ui/layout/panel-dock-css.generated.d.ts +1 -1
  109. package/dist/ui/layout/panel-dock-css.generated.d.ts.map +1 -1
  110. package/dist/ui/layout/panel-dock-css.generated.js +1 -1
  111. package/dist/ui/layout/panel-dock-css.generated.js.map +1 -1
  112. package/dist/ui/layout/split-group-css.generated.d.ts +1 -1
  113. package/dist/ui/layout/split-group-css.generated.d.ts.map +1 -1
  114. package/dist/ui/layout/split-group-css.generated.js +1 -1
  115. package/dist/ui/layout/split-group-css.generated.js.map +1 -1
  116. package/dist/utils/i18n.d.ts +2 -2
  117. package/dist/utils/i18n.d.ts.map +1 -1
  118. package/dist/utils/i18n.js +57 -1
  119. package/dist/utils/i18n.js.map +1 -1
  120. package/docs/design/external/dsh-host.md +22 -0
  121. package/docs/design/style.md +38 -10
  122. package/docs/help/session.doc.ts +19 -2
  123. package/docs/help/session.md +19 -2
  124. package/docs/help/ui.doc.ts +16 -0
  125. package/docs/help/ui.md +16 -0
  126. package/package.json +1 -1
@@ -1,11 +1,29 @@
1
- import { createElement as h } from 'react';
1
+ import { createElement as h, type ReactNode } from 'react';
2
2
  import { type Translate } from '../../utils/i18n.js';
3
3
  import type { SessionViewTarget } from './open-session-view.js';
4
4
  import { type ChangedFileReviewLocator } from './mirror/ChangedFilesCard.js';
5
5
  export interface SessionViewModalProps {
6
6
  t?: Translate;
7
- /** 弹窗标题:**会话自己的标题**(宿主 sessions.list 的 displayTitle);缺省用「会话预览」。 */
7
+ /**
8
+ * 标题行(第一级):核心画在标题位,下方 sessionId 副标题行与归档徽标不受影响。
9
+ * 想在标题后跟标签 / 按钮 / 自己的一段节点,塞进这里。
10
+ * 缺省 = `heading`,再缺省 =「会话预览」。
11
+ */
12
+ title?: ReactNode;
13
+ /** @deprecated 0.34 的旧名(只收字符串),等价 `title`;两者都给时以 `title` 为准。 */
8
14
  heading?: string;
15
+ /**
16
+ * 整块替换左侧标题区(第二级:标题行 + sessionId 行 + 归档徽标)。传了则 `title` / `heading` 不生效;
17
+ * 归档徽标改挂到右侧动作区最前(归档是数据事实,不能因为自定义标题就消失)。
18
+ * 右侧「继续对话」与关闭 ✕ 不受影响。
19
+ */
20
+ header?: ReactNode;
21
+ /** 头部右侧追加动作:排在核心「继续对话」之后、关闭 ✕ 之前。 */
22
+ headerActions?: ReactNode;
23
+ /** 会话内容最顶端(滚动容器内、所有节点与「加载更早」之前);随会话一起滚。 */
24
+ bodyHeader?: ReactNode;
25
+ /** 会话内容最底端(滚动容器内、最后一条内容与运行中行之后);随会话一起滚。 */
26
+ bodyFooter?: ReactNode;
9
27
  sessionId: string;
10
28
  view: SessionViewTarget;
11
29
  onClose: () => void;
@@ -1 +1 @@
1
- {"version":3,"file":"SessionViewModal.d.ts","sourceRoot":"","sources":["../../../src/adv/session/SessionViewModal.ts"],"names":[],"mappings":"AAUA,OAAO,EAAY,aAAa,IAAI,CAAC,EAA4F,MAAM,OAAO,CAAA;AAI9I,OAAO,EAAc,KAAK,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAUhE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAgB/D,OAAO,EAAoB,KAAK,wBAAwB,EAAE,MAAM,8BAA8B,CAAA;AAgqB9F,MAAM,WAAW,qBAAqB;IACpC,CAAC,CAAC,EAAE,SAAS,CAAA;IACb,mEAAmE;IACnE,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,EAAE,iBAAiB,CAAA;IACvB,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,8EAA8E;IAC9E,WAAW,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;IACpE,uDAAuD;IACvD,eAAe,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;IAC7C;;;OAGG;IACH,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/C;;qDAEiD;IACjD,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;IACnF;mEAC+D;IAC/D,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,wBAAwB,KAAK,IAAI,CAAA;CAC3D;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,qBAAqB,GAAG,UAAU,CAAC,OAAO,CAAC,CAAC,CAgcnF"}
1
+ {"version":3,"file":"SessionViewModal.d.ts","sourceRoot":"","sources":["../../../src/adv/session/SessionViewModal.ts"],"names":[],"mappings":"AAUA,OAAO,EAAY,aAAa,IAAI,CAAC,EAA4F,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAI9J,OAAO,EAAc,KAAK,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAYhE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAiB/D,OAAO,EAAoB,KAAK,wBAAwB,EAAE,MAAM,8BAA8B,CAAA;AAguB9F,MAAM,WAAW,qBAAqB;IACpC,CAAC,CAAC,EAAE,SAAS,CAAA;IACb;;;;OAIG;IACH,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,gEAAgE;IAChE,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;;OAIG;IACH,MAAM,CAAC,EAAE,SAAS,CAAA;IAClB,qCAAqC;IACrC,aAAa,CAAC,EAAE,SAAS,CAAA;IACzB,2CAA2C;IAC3C,UAAU,CAAC,EAAE,SAAS,CAAA;IACtB,2CAA2C;IAC3C,UAAU,CAAC,EAAE,SAAS,CAAA;IACtB,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,EAAE,iBAAiB,CAAA;IACvB,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,8EAA8E;IAC9E,WAAW,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;IACpE,uDAAuD;IACvD,eAAe,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;IAC7C;;;OAGG;IACH,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/C;;qDAEiD;IACjD,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;IACnF;mEAC+D;IAC/D,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,wBAAwB,KAAK,IAAI,CAAA;CAC3D;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,qBAAqB,GAAG,UAAU,CAAC,OAAO,CAAC,CAAC,CA4enF"}
@@ -13,11 +13,12 @@ import { IconArchiveOutlineRegular, IconBranchOutlineRegular, IconNewChatOutline
13
13
  import { Modal } from '../../ui/feedback/Modal.js';
14
14
  import { Button } from '../../ui/actions/Button.js';
15
15
  import { t as coreT } from '../../utils/i18n.js';
16
- import { ChatHint, ChatNodeListMirror, ChatOlderButton, ChatViewFrame } from './mirror/ChatView.js';
17
- import { GenericCommandCard } from './mirror/GenericCommandCard.js';
16
+ import { ChatCallRow, ChatHint, ChatNodeListMirror, ChatOlderButton, ChatOpenError, ChatViewFrame } from './mirror/ChatView.js';
17
+ import { GenericCommandCard, imageCardFace } from './mirror/GenericCommandCard.js';
18
18
  import { CommandCard } from './mirror/CommandCard.js';
19
19
  import { MessageIconActionsMirror } from './mirror/MessageIconActions.js';
20
- import { AssistantMarkdown, CompactionItemMirror, ModelRetryItemMirror, TurnErrorItemMirror, TurnMaxTokensItemMirror, UserMessage } from './mirror/MessageItem.js';
20
+ import { AssistantMarkdown, AssistantStoppedBadge, CompactionItemMirror, ModelRetryItemMirror, TurnErrorItemMirror, TurnMaxTokensItemMirror, UserMessage } from './mirror/MessageItem.js';
21
+ import { ImageGalleryMirror } from './mirror/MessageImage.js';
21
22
  import { BashRowMirror } from './mirror/BashRow.js';
22
23
  import { SkillRowMirror } from './mirror/SkillRow.js';
23
24
  import { ContextInjectionRowMirror } from './mirror/ContextInjectionRow.js';
@@ -55,19 +56,39 @@ function safeJson(value) {
55
56
  return String(value);
56
57
  }
57
58
  }
58
- /** 提取内容块的可读文本:text 拼接、图片占位(官方 ContentBlock 是 merge-extensible map)。 */
59
- function contentText(blocks) {
59
+ /**
60
+ * 提取内容块的可读文本:text 拼接、图片占位(官方 ContentBlock 是 merge-extensible map)。
61
+ * @param imagePlaceholder 图片块是否退化成「[图片]」文本:图片能真渲染时传 false(官方文本里没有图片)。
62
+ */
63
+ function contentText(blocks, imagePlaceholder = true) {
60
64
  if (blocks === undefined)
61
65
  return '';
62
66
  const parts = blocks.map((block) => {
63
67
  if (block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
64
68
  return block.text;
65
- if (block !== null && typeof block === 'object' && block.type === 'image')
69
+ if (imagePlaceholder && block !== null && typeof block === 'object' && block.type === 'image')
66
70
  return coreT('sessionImage');
67
71
  return '';
68
72
  }).filter(part => part !== '');
69
73
  return parts.join('\n');
70
74
  }
75
+ /** 内容块里的官方 image 块(`{ type:'image', attachment: ImageAttachmentRef }`)→ 图片组。 */
76
+ function contentImages(blocks) {
77
+ if (blocks === undefined)
78
+ return [];
79
+ const out = [];
80
+ for (const block of blocks) {
81
+ if (block === null || typeof block !== 'object' || block.type !== 'image')
82
+ continue;
83
+ const attachment = block.attachment;
84
+ if (typeof attachment !== 'object' || attachment === null)
85
+ continue;
86
+ if (typeof attachment.attachmentId !== 'string' || attachment.attachmentId === '')
87
+ continue;
88
+ out.push({ attachment });
89
+ }
90
+ return out;
91
+ }
71
92
  /**
72
93
  * 提取内容块里的**官方 file 块**(2026-10-03 核实):随附文件以官方附件形式发进会话后,
73
94
  * 会话快照的 user 节点 content 里就是它 ⇒ 弹窗里照样渲染成附件卡(与官方页一致)。
@@ -119,24 +140,31 @@ function blocksOf(value) {
119
140
  * 官方 ToolCallBlock(uic contract/records.d.ts:140)→ 工具卡 props。
120
141
  * running 半截(phase: preparing/start)只有 name/argsRaw;settled(kind: tool-result)带输出与错误。
121
142
  */
122
- function toolCallCard(node, t, onOpenFile) {
143
+ function toolCallCard(node, t, onOpenFile, loadImage) {
123
144
  const root = dataOf(node).root;
124
145
  if (root === undefined || root === null)
125
146
  return null;
126
147
  const settled = root.kind === 'tool-result';
127
148
  const call = settled ? root.call : root;
128
149
  const error = root.error;
150
+ const name = typeof call?.name === 'string' ? call.name : 'tool';
151
+ const argsRaw = typeof call?.argsRaw === 'string' ? call.argsRaw : '';
152
+ const isError = root.isError === true;
153
+ const content = settled ? root.content : undefined;
129
154
  return {
130
- name: typeof call?.name === 'string' ? call.name : 'tool',
131
- argsRaw: typeof call?.argsRaw === 'string' ? call.argsRaw : '',
132
- output: settled ? contentText(root.content) : '',
133
- isError: root.isError === true,
155
+ name,
156
+ argsRaw,
157
+ output: settled ? contentText(content, false) : '',
158
+ isError,
134
159
  errorName: error?.name,
135
160
  meta: root.meta,
136
161
  settled,
137
162
  phase: settled ? undefined : root.phase === 'preparing' ? 'preparing' : 'start',
138
163
  interrupted: error?.code === 'interrupted',
139
164
  errorCode: error?.code,
165
+ // 官方 read_image 专属卡:图片来自结果内容块的附件引用,不是文本。
166
+ image: imageCardFace(name, argsRaw, content, root.meta, isError, settled) ?? undefined,
167
+ loadImage,
140
168
  onOpenFile,
141
169
  t,
142
170
  };
@@ -204,7 +232,13 @@ sessionId,
204
232
  /** 变更文件点击出口(官方 openReview 等价):带改动身份;undefined = 变更卡不可点。 */
205
233
  onOpenReview,
206
234
  /** 官方 endsWithResponse / hasLaterChatNode(尾部操作行常显与分支可用性)。 */
207
- tailFacts) {
235
+ tailFacts,
236
+ /** 图片 loader(view.loadImage);缺席 = 图片块退回「[图片]」文本。 */
237
+ loadImage,
238
+ /** 底端插槽:挂在最后一条 tail 之前(复制 / 分支 / 用量操作行之上)。 */
239
+ footerSlot,
240
+ /** 该挂到哪个节点的 key 上(最后一条带回复的 turn-tail;undefined = 不挂)。 */
241
+ footerKey) {
208
242
  switch (node.kind) {
209
243
  case 'turn-trigger':
210
244
  return h(TurnTriggerNodeViewMirror, { data: node.data, t });
@@ -230,13 +264,14 @@ tailFacts) {
230
264
  const changesCard = atLastTail && changesSeq !== undefined && sessionId !== undefined
231
265
  ? h(ChangedFilesCard, { sessionId, seq: changesSeq, onOpenReview, t })
232
266
  : null;
233
- const tailSlot = changesCard === null && !showGrid
267
+ // 底端插槽 = tailSlot 的最后一项 ⇒ 在交付文件 / 变更卡**下面**、复制/分支/用量操作行**上面**。
268
+ const atFooter = footerKey !== undefined && node.key === footerKey;
269
+ const tailSlot = changesCard === null && !showGrid && !atFooter
234
270
  ? null
235
- : h(Fragment, null, changesCard, showGrid ? h(DeliverablesGridMirror, { files: deliverFiles, onOpen: fileOpen?.open, t }) : null);
236
- const tail = data === undefined || data.closing === null || data.closing === undefined
271
+ : h(Fragment, null, changesCard, showGrid ? h(DeliverablesGridMirror, { files: deliverFiles, onOpen: fileOpen?.open, t }) : null, atFooter ? footerSlot : null);
272
+ return data === undefined || data.closing === null || data.closing === undefined
237
273
  ? null
238
274
  : h(TurnTailNodeViewMirror, { data, onBranchAt, tailSlot, facts: tailFacts, t });
239
- return tail;
240
275
  }
241
276
  case 'assistant-step': {
242
277
  // 官方块渲染器(lib/client.js:5818-5871):
@@ -256,7 +291,12 @@ tailFacts) {
256
291
  : groupPart === 'response'
257
292
  ? contentBlocks.filter(block => block.kind !== 'reasoning')
258
293
  : contentBlocks;
259
- const parts = assistantBlocks(visible, t, fileOpen?.mentions, streaming);
294
+ const parts = assistantBlocks(visible, t, fileOpen?.mentions, streaming, loadImage);
295
+ // 官方 AssistantMarkdown.stopped:被中断的整步末尾一枚「已停止」;只画思考块的组内不重复画。
296
+ const stopped = interrupted
297
+ && (groupPart !== 'reasoning' || !blocks.some(block => block.kind !== 'reasoning' && block.kind !== 'tool-call'));
298
+ if (stopped)
299
+ parts.push(h(AssistantStoppedBadge, { key: 'stopped', t }));
260
300
  return parts.length === 0 ? null : h('div', { className: 'dshpc-sv-assistant' }, parts);
261
301
  }
262
302
  case 'tool-call': {
@@ -265,7 +305,7 @@ tailFacts) {
265
305
  const root = dataOf(node).root;
266
306
  if (isPresentRoot(root))
267
307
  return h(PresentRowMirror, { block: root, t });
268
- const card = toolCallCard(node, t, fileOpen?.open);
308
+ const card = toolCallCard(node, t, fileOpen?.open, loadImage);
269
309
  if (card === null)
270
310
  return null;
271
311
  // 官方 tool.call.toolview 槽位按工具名分派:bash / pwsh 走专属 BashRow(独立卡片,无 DisclosureRow);
@@ -280,19 +320,23 @@ tailFacts) {
280
320
  // 官方 CommandNodeView → GenericCommandCard(ui-chat/chat/GenericCommandCard.tsx 原样复刻)。
281
321
  // 斜杠命令生命周期节点(records.d.ts:name/args/outcome);走 CommandCard:标题=裸命令名、
282
322
  // 摘要=outcome 落定 text、仅多行才展开。之前漏接 ⇒ 落 default 显示「未支持的节点类型: command」。
283
- return h(CommandCard, { node: dataOf(node), t });
323
+ return h(ChatCallRow, null, h(CommandCard, { node: dataOf(node), t }));
284
324
  }
285
325
  case 'user':
286
326
  case 'steering': {
287
327
  const blocks = dataOf(node).content;
288
- const text = contentText(blocks);
328
+ const images = loadImage === undefined ? [] : contentImages(blocks);
329
+ // 官方文本里没有图片:能真出图时不再塞「[图片]」。
330
+ const text = contentText(blocks, loadImage === undefined || images.length === 0);
289
331
  // 随附文件(官方 file 块):没有文本但有附件时**照样渲染**(不能整条消失)。
290
332
  const { files, hadFiles } = contentFiles(blocks);
291
- if (text === '' && !hadFiles)
333
+ if (text === '' && !hadFiles && images.length === 0)
292
334
  return null;
293
335
  return h(UserMessage, {
294
336
  text,
295
337
  files,
338
+ images,
339
+ loadImage,
296
340
  referenceLabels: dataOf(node).referenceLabels,
297
341
  skillNames: dataOf(node).skillNames,
298
342
  t,
@@ -318,15 +362,18 @@ tailFacts) {
318
362
  // 决策 28:context(系统注入)/ unknown 仍默认过滤;
319
363
  // 0.2.x compaction 节点走官方 CompactionItem(此前漏渲染 ⇒ 压缩段整段消失)。
320
364
  case 'compaction':
321
- case 'manual-compaction':
322
365
  return h(CompactionItemMirror, { node: dataOf(node), t });
366
+ case 'manual-compaction':
367
+ // 官方 ManualCompactionNodeView 同样套 callRow 外壳。
368
+ return h(ChatCallRow, null, h(CompactionItemMirror, { node: dataOf(node), t }));
323
369
  case 'context':
324
370
  return h(ContextInjectionRowMirror, { node: dataOf(node), t });
325
371
  case 'unknown':
326
372
  return null;
327
373
  default:
328
374
  // 决策 28:不认识的 kind 一律 fallback(折叠原文),升级不白屏。
329
- return h('details', { className: 'dshpc-sv-tool' }, h('summary', { className: 'dshpc-sv-notice' }, `${t('sessionUnknownKind')} ${node.kind}`), h('pre', null, safeJson(node.data)));
375
+ // 外壳 = 官方 MessageItem.contextRow(官方 UnknownNodeView 的同一个容器类)。
376
+ return h('div', { className: officialClass('MessageItem', 'contextRow') ?? '' }, h('details', { className: 'dshpc-sv-tool' }, h('summary', { className: 'dshpc-sv-notice' }, `${t('sessionUnknownKind')} ${node.kind}`), h('pre', null, safeJson(node.data))));
330
377
  }
331
378
  }
332
379
  /**
@@ -337,7 +384,9 @@ tailFacts) {
337
384
  */
338
385
  function assistantBlocks(blocks, t, fileMentions,
339
386
  /** 官方 streaming(节点 data.status === 'running'):正文按流式尾渲染、末块思考行走 shimmer。 */
340
- streaming = false) {
387
+ streaming = false,
388
+ /** 图片 loader;缺席 = 图片块退回「[图片]」文本占位。 */
389
+ loadImage) {
341
390
  if (blocks === undefined)
342
391
  return [];
343
392
  const parts = [];
@@ -355,9 +404,28 @@ streaming = false) {
355
404
  parts.push(h(ReasoningRowMirror, { key: `r${index}`, text: block.text, running: streaming && index === last, t }));
356
405
  }
357
406
  break;
358
- case 'image':
359
- parts.push(h('div', { key: `i${index}`, className: 'dshpc-sv-image' }, t('sessionImage')));
407
+ case 'image': {
408
+ // 官方:连续 image 块并成一组推一次(conversation.chat.message.images,align='start')。
409
+ const previous = blocks[index - 1];
410
+ if (previous !== undefined && previous.kind === 'image')
411
+ break;
412
+ const images = [];
413
+ for (const candidate of blocks.slice(index)) {
414
+ if (candidate.kind !== 'image')
415
+ break;
416
+ const attachment = candidate.attachment;
417
+ if (attachment !== null && typeof attachment === 'object' && typeof attachment.attachmentId === 'string') {
418
+ images.push({ attachment });
419
+ }
420
+ }
421
+ if (loadImage !== undefined && images.length > 0) {
422
+ parts.push(h(ImageGalleryMirror, { key: `i${index}`, images, load: loadImage, align: 'start', t }));
423
+ }
424
+ else {
425
+ parts.push(h('div', { key: `i${index}`, className: 'dshpc-sv-image' }, t('sessionImage')));
426
+ }
360
427
  break;
428
+ }
361
429
  case 'tool-call':
362
430
  break;
363
431
  default:
@@ -376,6 +444,7 @@ function renderLegacyNode(node, t, fileOpen) {
376
444
  switch (node.kind) {
377
445
  case 'user':
378
446
  case 'steering': {
447
+ // shortcut: legacy 兜底路径不带图片 loader(图片仍是「[图片]」占位);keyed 主路已出真图。
379
448
  const text = contentText(node.content);
380
449
  const { files, hadFiles } = contentFiles(node.content);
381
450
  if (text === '' && !hadFiles)
@@ -695,11 +764,13 @@ export function SessionViewModal(props) {
695
764
  const isArchived = useSyncExternalStore(workspaceList.subscribe, archivedNow, NOT_ARCHIVED);
696
765
  const subscribe = useMemo(() => (onChange) => view.target.subscribe(onChange), [view]);
697
766
  const getSnapshot = useMemo(() => () => view.target.getSnapshot(), [view]);
698
- const chat = useSyncExternalStore(subscribe, getSnapshot);
767
+ // 第三参 = 同一个快照函数:React 要求 SSR 必给,缺了直接抛错。这里的数据挂在 props 的 view 上
768
+ // (不是全局 store),服务端读同一个快照不会串数据;纯全局 store 才需要单独的空快照。
769
+ const chat = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
699
770
  // 会话生命周期(加载态 / 向前翻页按钮),同样只读订阅。
700
771
  const sessionSub = useMemo(() => (onChange) => view.session.subscribe(onChange), [view]);
701
772
  const sessionGet = useMemo(() => () => view.session.getSnapshot(), [view]);
702
- const sessionSnap = useSyncExternalStore(sessionSub, sessionGet);
773
+ const sessionSnap = useSyncExternalStore(sessionSub, sessionGet, sessionGet);
703
774
  // host `inbox` 投影(faceOf('inbox')):首轮 durable 事件落盘前用户消息的权威来源,
704
775
  // 冷读 / 新打开预览时也拿得到;本地回显被 retire 后靠它继续画气泡。
705
776
  const inboxSub = useMemo(() => (onChange) => view.inbox.subscribe(onChange), [view]);
@@ -729,6 +800,8 @@ export function SessionViewModal(props) {
729
800
  const [forkTarget, setForkTarget] = useState(null);
730
801
  const [forking, setForking] = useState(false);
731
802
  const [forkErr, setForkErr] = useState(null);
803
+ /** 官方 ChatView.modalAction(弹窗脚按钮 min-width 72px);官方样式未注入时为 undefined。 */
804
+ const modalActionClass = officialClass('ChatView', 'modalAction') ?? undefined;
732
805
  const aliveRef = useRef(true);
733
806
  useEffect(() => () => { aliveRef.current = false; }, []);
734
807
  // 取消归档时收掉「因为归档才弹的」fork 确认框(atSeq 缺省 = 头部按钮触发);
@@ -988,7 +1061,28 @@ export function SessionViewModal(props) {
988
1061
  const tailFacts = useMemo(() => (keyed && store !== undefined
989
1062
  ? buildTurnTailFacts({ order, store, lastTurn: lastTailTurn })
990
1063
  : undefined), [keyed, order, store, lastTailTurn]);
991
- const renderNode = useCallback((node, turnProcess, groupPart) => renderKeyedNode(node, turnProcess, tt, onBranchAt, fileOpen, groupPart, deliverFiles, lastTailTurn, lastTailChangesSeq, sessionId, props.onOpenReview, tailFacts), [tt, onBranchAt, fileOpen, deliverFiles, lastTailTurn, lastTailChangesSeq, sessionId, props.onOpenReview, tailFacts]);
1064
+ // 底端插槽挂点:末尾是**带回复的 turn-tail** 时挂在它之前(最后那条回复之后、复制 / 分支 /
1065
+ // 用量操作行之上);没有回复(closing 为空)或末尾不是 tail ⇒ 退回流的末尾。
1066
+ const footerAnchorKey = useMemo(() => {
1067
+ if (!keyed || store === undefined)
1068
+ return undefined;
1069
+ const lastKey = order[order.length - 1];
1070
+ const last = lastKey === undefined ? undefined : store.get(lastKey);
1071
+ if (last?.kind !== 'turn-tail')
1072
+ return undefined;
1073
+ const closing = last.data?.closing;
1074
+ return closing === null || closing === undefined ? undefined : lastKey;
1075
+ }, [keyed, order, store]);
1076
+ // 内容两端插槽(调用方自绘):不传 = 不渲染元素(少一层空容器多吃一个 gap);四态都渲染。
1077
+ // 顶端槽的**下边距由自己的类固定**(见 session.css):不依赖相邻项自己的 margin,
1078
+ // 否则相邻项换一种(turn-trigger / 用户气泡 / 提示条)间距就跟着变。
1079
+ const bodyHeaderRow = props.bodyHeader === undefined
1080
+ ? null
1081
+ : h('div', { key: 'bodyHeader', className: 'dshpc-sv-body-header' }, props.bodyHeader);
1082
+ const bodyFooterRow = props.bodyFooter === undefined
1083
+ ? null
1084
+ : h('div', { key: 'bodyFooter', className: 'dshpc-sv-body-footer' }, props.bodyFooter);
1085
+ const renderNode = useCallback((node, turnProcess, groupPart) => renderKeyedNode(node, turnProcess, tt, onBranchAt, fileOpen, groupPart, deliverFiles, lastTailTurn, lastTailChangesSeq, sessionId, props.onOpenReview, tailFacts, props.view.loadImage, bodyFooterRow, footerAnchorKey), [tt, onBranchAt, fileOpen, deliverFiles, lastTailTurn, lastTailChangesSeq, sessionId, props.onOpenReview, tailFacts, props.view.loadImage, bodyFooterRow, footerAnchorKey]);
992
1086
  // 官方 grouped('chat'):把 keyed 流切成「独立条目 + 过程分组」(二级收折)。
993
1087
  const isTurnClosed = useCallback((turn) => (turns?.get(turn) ?? turns?.get(String(turn)))?.status !== 'open', [turns]);
994
1088
  const groupedView = useMemo(() => keyed ? buildProcessGroups(order, key => store?.get(key), isTurnClosed) : undefined, [keyed, order, store, isTurnClosed]);
@@ -1036,64 +1130,82 @@ export function SessionViewModal(props) {
1036
1130
  : h(RunningStatusMirror, { key: 'running', startTime: runningStartTime, t: tt });
1037
1131
  const body = rendered.length === 0 && pendingRows.length === 0
1038
1132
  ? [
1039
- h(ChatHint, {
1040
- key: 'hint',
1041
- text: openState === 'error' ? tt('sessionFailed')
1042
- : openState === 'loading' || openState === 'cold' ? tt('sessionLoading')
1043
- : tt('sessionEmpty'),
1044
- }),
1133
+ bodyHeaderRow,
1134
+ // 官方三态各走各的类:error = openError(错误色),loading / 空 = hint。
1135
+ openState === 'error'
1136
+ ? h(ChatOpenError, { key: 'hint', text: tt('sessionFailed') })
1137
+ : h(ChatHint, {
1138
+ key: 'hint',
1139
+ text: openState === 'loading' || openState === 'cold' ? tt('sessionLoading') : tt('sessionEmpty'),
1140
+ }),
1045
1141
  runningRow,
1142
+ // 已挂在最后一条 tail 上的话这里不再画(否则同一个槽出现两次)。
1143
+ footerAnchorKey === undefined ? bodyFooterRow : null,
1046
1144
  ]
1047
1145
  : [
1146
+ bodyHeaderRow,
1048
1147
  showLoadOlder
1049
1148
  ? h(ChatOlderButton, { key: 'older', label: tt('sessionLoadOlder'), onClick: () => { view.loadOlder(); } })
1050
1149
  : null,
1051
1150
  ...rendered,
1052
1151
  ...pendingRows,
1053
1152
  runningRow,
1153
+ footerAnchorKey === undefined ? bodyFooterRow : null,
1054
1154
  ];
1055
1155
  const flowChildren = Array.isArray(body) ? body : [body];
1156
+ // 头部两槽:标题区整块替换(`header`)与右侧动作追加(`headerActions`)。
1157
+ // 自定义标题区时归档徽标挪到动作区最前 —— 归档是数据事实,不能因为自定义标题就消失。
1158
+ const customHeader = props.header !== undefined;
1159
+ const archivedBadge = isArchived
1160
+ ? h('span', { className: 'dshpc-sv-archived', title: tt('sessionArchived') }, h(IconArchiveOutlineRegular, { size: 12 }), tt('sessionArchived'))
1161
+ : undefined;
1162
+ // 「继续对话」:已归档 = 开分支图标 + 确认框;未归档 = 会话图标直跳官方会话页。
1163
+ const continueAction = showContinue
1164
+ ? h(Button, {
1165
+ variant: 'outline',
1166
+ size: 'sm',
1167
+ disabled: forking,
1168
+ title: tt('continueBranch'),
1169
+ icon: isArchived
1170
+ ? h(IconBranchOutlineRegular, { size: 14 })
1171
+ : h(IconNewChatOutlineRegular, { size: 14 }),
1172
+ onClick: () => {
1173
+ // 官方 openForkSource 判据:动作前读权威快照(client 内存,零远端 IO),
1174
+ // 避免渲染与快照之间的竞态把已归档会话直开。
1175
+ if (archivedNow()) {
1176
+ setForkErr(null);
1177
+ setForkTarget({});
1178
+ }
1179
+ else if (openHostSession !== undefined) {
1180
+ onClose();
1181
+ openHostSession(sessionId);
1182
+ }
1183
+ },
1184
+ }, tt('continueBranch'))
1185
+ : undefined;
1186
+ // 动作区顺序(都靠右,「继续对话」与 ✕ 恒在最右):归档徽标 → 调用方动作 → 继续对话 → ✕。
1187
+ const headActions = continueAction === undefined && props.headerActions === undefined && !(customHeader && archivedBadge !== undefined)
1188
+ ? undefined
1189
+ : h(Fragment, null, customHeader ? archivedBadge : null, props.headerActions, continueAction);
1056
1190
  // 外壳 = **核心 Modal**(size lg:min(1120px,100vw-32px) × calc(100%-80px),兄弟项目大弹窗同值);
1057
1191
  // 「继续对话」与确认框按钮一律**核心 Button**,不就地自绘(用户 2026-10-22 拍板)。
1058
1192
  // 会话中间部分 = mirror/ 官方 1:1 复刻(ChatViewFrame:官方类优先,缺失回退自绘)。
1059
1193
  return h(Fragment, null, h(Modal, {
1060
1194
  size: 'lg',
1061
- // 标题 = 调用方给的人话标题(可自定义);会话 id 是**标识**,放标题下方副标题行,不占正文
1062
- // (用户 2026-10-22:一串 MD5 不该出现在正文里)。
1063
- title: heading ?? tt('sessionHeading'),
1064
- subtitle: h(Fragment, null, h('span', null, sessionId),
1065
- // 可见探针:官方样式未命中时直接显示(省得翻控制台)。命中则不显示。
1066
- officialCount === 0
1067
- ? h('span', { className: 'dshpc-sv-probe' }, ' ⚠ ', tt('sessionProbeStyle'))
1068
- : null),
1069
- // 归档标识(官方归档图标 + 「已归档」)走头部右侧小块。
1070
- headerExtra: isArchived
1071
- ? h('span', { className: 'dshpc-sv-archived', title: tt('sessionArchived') }, h(IconArchiveOutlineRegular, { size: 12 }), tt('sessionArchived'))
1072
- : undefined,
1073
- // 「继续对话」:已归档 = 开分支图标 + 确认框;未归档 = 会话图标直跳官方会话页。
1074
- headerActions: showContinue
1075
- ? h(Button, {
1076
- variant: 'outline',
1077
- size: 'sm',
1078
- disabled: forking,
1079
- title: tt('continueBranch'),
1080
- icon: isArchived
1081
- ? h(IconBranchOutlineRegular, { size: 14 })
1082
- : h(IconNewChatOutlineRegular, { size: 14 }),
1083
- onClick: () => {
1084
- // 官方 openForkSource 判据:动作前读权威快照(client 内存,零远端 IO),
1085
- // 避免渲染与快照之间的竞态把已归档会话直开。
1086
- if (archivedNow()) {
1087
- setForkErr(null);
1088
- setForkTarget({});
1089
- }
1090
- else if (openHostSession !== undefined) {
1091
- onClose();
1092
- openHostSession(sessionId);
1093
- }
1094
- },
1095
- }, tt('continueBranch'))
1096
- : undefined,
1195
+ // 二级:整块标题区交给调用方(含 sessionId 行);一级:只换标题行,副标题仍由核心画。
1196
+ // 会话 id 是**标识**,放标题下方副标题行,不占正文(用户 2026-10-22:一串 MD5 不该出现在正文里)。
1197
+ header: props.header,
1198
+ title: customHeader ? undefined : (props.title ?? heading ?? tt('sessionHeading')),
1199
+ subtitle: customHeader
1200
+ ? undefined
1201
+ : h(Fragment, null, h('span', null, sessionId),
1202
+ // 可见探针:官方样式未命中时直接显示(省得翻控制台)。命中则不显示。
1203
+ officialCount === 0
1204
+ ? h('span', { className: 'dshpc-sv-probe' }, ' ⚠ ', tt('sessionProbeStyle'))
1205
+ : null),
1206
+ // 归档标识(官方归档图标 + 「已归档」)走头部右侧小块;自定义标题区时改挂动作区最前。
1207
+ headerExtra: customHeader ? undefined : archivedBadge,
1208
+ headerActions: headActions,
1097
1209
  onClose,
1098
1210
  translate: tt,
1099
1211
  // 底部暂存输入框走 footer(不随会话区滚动):打字 → 确定 → onContinue 带话开分支跳官方视图。
@@ -1127,9 +1239,10 @@ export function SessionViewModal(props) {
1127
1239
  setForkTarget(null); },
1128
1240
  title: tt('forkConfirmTitle'),
1129
1241
  translate: tt,
1242
+ // 弹窗脚按钮宽度与官方对话框对齐(官方 ChatView.modalAction:min-width 72px)。
1130
1243
  footer: [
1131
- h(Button, { key: 'cancel', variant: 'outline', disabled: forking, onClick: () => { setForkTarget(null); } }, tt('cancel')),
1132
- h(Button, { key: 'accept', variant: 'primary', disabled: forking || !canFork, onClick: onForkAccept }, forking ? tt('forkWorking') : tt('forkConfirmAccept')),
1244
+ h(Button, { key: 'cancel', variant: 'outline', className: modalActionClass, disabled: forking, onClick: () => { setForkTarget(null); } }, tt('cancel')),
1245
+ h(Button, { key: 'accept', variant: 'primary', className: modalActionClass, disabled: forking || !canFork, onClick: onForkAccept }, forking ? tt('forkWorking') : tt('forkConfirmAccept')),
1133
1246
  ],
1134
1247
  }, h('p', { className: 'dshpc-sv-forktext' }, tt('forkConfirmText')), canFork ? null : h('p', { className: 'dshpc-sv-forkerr' }, tt('forkUnavailable')), forkErr !== null ? h('p', { className: 'dshpc-sv-forkerr' }, tt('forkFailed', { error: forkErr })) : null));
1135
1248
  }