dsh-codinput 0.3.9 → 0.4.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 (37) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/NOTICE +37 -4
  3. package/README.en.md +26 -12
  4. package/README.md +26 -12
  5. package/TESTING.md +86 -6
  6. package/assets/donate-qr.jpg +0 -0
  7. package/lib/client.js +2875 -98
  8. package/package.json +5 -2
  9. package/src/client/cm.ts +19 -1
  10. package/src/client/command.ts +39 -8
  11. package/src/client/components/CodinputSurface.tsx +80 -27
  12. package/src/client/components/EditorPane.tsx +3 -1
  13. package/src/client/components/InputSlotSeat.tsx +56 -1
  14. package/src/client/components/SlashMenu.tsx +294 -41
  15. package/src/client/components/StatsRow.tsx +40 -23
  16. package/src/client/components/Toolbar.tsx +284 -48
  17. package/src/client/components/UserMessageMarkdown.tsx +158 -115
  18. package/src/client/components/VoiceActivity.tsx +30 -69
  19. package/src/client/components/slotEntry.tsx +37 -0
  20. package/src/client/host-types.ts +11 -3
  21. package/src/client/hotkey.ts +38 -0
  22. package/src/client/i18n.ts +18 -4
  23. package/src/client/index.tsx +9 -0
  24. package/src/client/markdown/code.ts +281 -0
  25. package/src/client/markdown/extensions.ts +576 -0
  26. package/src/client/markdown/heavy-assets.d.ts +11 -0
  27. package/src/client/markdown/heavy-shared.ts +50 -0
  28. package/src/client/markdown/heavy.ts +159 -0
  29. package/src/client/markdown/index.ts +289 -0
  30. package/src/client/markdown/math.ts +134 -0
  31. package/src/client/markdown/mermaid.ts +358 -0
  32. package/src/client/markdown/sanitize.ts +103 -0
  33. package/src/client/markdown/styles.ts +171 -0
  34. package/src/client/prefs.ts +206 -23
  35. package/src/client/settings.tsx +7 -0
  36. package/src/client/stats.ts +92 -20
  37. package/src/client/styles.ts +55 -30
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-codinput",
3
- "version": "0.3.9",
3
+ "version": "0.4.0",
4
4
  "description": "DSH 插件:把聊天输入框变成代码编辑器一样的输入风格(CodeMirror 6 接管 conversation.composer.bar),web 与桌面端通用。",
5
5
  "license": "MIT",
6
6
  "author": "Witherwithwinter",
@@ -66,7 +66,10 @@
66
66
  "@codemirror/state": "^6.5.2",
67
67
  "@codemirror/view": "^6.36.0",
68
68
  "dompurify": "^3.2.4",
69
- "marked": "^15.0.6"
69
+ "highlight.js": "^11.12.0",
70
+ "katex": "^0.18.0",
71
+ "marked": "^15.0.6",
72
+ "mermaid": "^12.3.0"
70
73
  },
71
74
  "devDependencies": {
72
75
  "@types/react": "^18.3.18",
package/src/client/cm.ts CHANGED
@@ -139,6 +139,19 @@ export function subscribeEditorPrefs(view: EditorView, placeholder: string, edit
139
139
  });
140
140
  }
141
141
 
142
+ /**
143
+ * 永远按字面字符渲染:同时关掉 `liga` / `calt`。
144
+ *
145
+ * 连字会把 `->`、`//` 等替换成合并字形,视觉上像「字符消失」;更关键的是
146
+ * DSH 桌面 0.2.2 内置的 Chromium 152 在「`calt` 连字 + 后续任意字符」时会把
147
+ * 连字字形第一格画空(宿主 shaping bug,CSS 层面无法稳定规避)。故本插件不做
148
+ * 连字:编辑器所见即所存,也与预览 / 消息区的纯文本渲染保持一致。
149
+ */
150
+ const LITERAL_TEXT_STYLES = {
151
+ fontVariantLigatures: 'none',
152
+ fontFeatureSettings: '"liga" 0, "calt" 0',
153
+ } as const;
154
+
142
155
  function baseTheme(fontFamily: string): Extension {
143
156
  return EditorView.theme({
144
157
  '&': { fontSize: '13px', height: '100%' },
@@ -147,8 +160,13 @@ function baseTheme(fontFamily: string): Extension {
147
160
  fontFamily,
148
161
  lineHeight: '1.6',
149
162
  overflow: 'auto',
163
+ ...LITERAL_TEXT_STYLES,
164
+ },
165
+ '.cm-content': {
166
+ caretColor: 'var(--dsw-alias-state-business-primary, currentColor)',
167
+ paddingBottom: '8px',
168
+ ...LITERAL_TEXT_STYLES,
150
169
  },
151
- '.cm-content': { caretColor: 'var(--dsw-alias-state-business-primary, currentColor)', paddingBottom: '8px' },
152
170
  '.cm-line': { padding: '0 8px' },
153
171
  // CodeMirror 自绘光标(drawSelection)默认黑色,深色主题下不可见——
154
172
  // 显式对齐官方输入框的 caret 颜色(品牌强调色)。
@@ -3,9 +3,10 @@
3
3
  * 贡献。
4
4
  *
5
5
  * /codinput:输入回车即切换接管(进入/退出),菜单描述随当前状态变化;接管
6
- * 关闭(官方输入框)时输入同样生效——贡献注册是全局生命周期。菜单拾取路径
7
- * 会把 "/codinput" 插进草稿而不提交,切换后需清空当前草稿。侧栏里发起的退
8
- * 出会经总线请求 body 关掉自己的标签(见 sideinput)。
6
+ * 关闭(官方输入框)时输入同样生效——贡献注册是全局生命周期。命令 token 由
7
+ * 宿主 consume-token 通道消费;插件只在残留时兜底剥离 token 本身,绝不整体
8
+ * 清空草稿(否则「先有草稿再 /codinput 退出」会丢草稿)。侧栏里发起的退出会
9
+ * 经总线请求 body 关掉自己的标签(见 sideinput)。
9
10
  *
10
11
  */
11
12
 
@@ -17,13 +18,40 @@ import { t } from './i18n';
17
18
  import { IconCodinputCommand16 } from './icons';
18
19
  import { diagWindow, runGuarded } from './diag';
19
20
 
20
- function clearDraftOf(sessionId: string | undefined): void {
21
+ /** `/codinput` 命令 token 的规范拼写(宿主命令名即 `codinput`)。 */
22
+ const COMMAND_TOKEN = '/codinput';
23
+
24
+ /**
25
+ * 清理 `/codinput` 命令 token 的残留,**只剥 token,不碰草稿其余内容**。
26
+ *
27
+ * 官方命令通道在调用 `ui.run` 之前会先派发 `slash/input-consume-token`
28
+ * (菜单拾取 = exact span,裸命令回车 = bare-token),正常路径此时草稿里
29
+ * 已经没有 token。但「工具行指令按钮程序化唤起菜单」等路径的 span 消费
30
+ * 可能落空,草稿里会残留 "/codinput" 前缀。旧实现无条件 `setDraft('')`
31
+ * 把草稿整体清空——用户先有草稿、再输入 `/codinput` 退出接管时,宿主只
32
+ * 消费掉命令 token,剩下的用户草稿随后被这段无条件清空吞掉(普通/悬浮
33
+ * 形态都中招,退出后内置输入框变空)。
34
+ *
35
+ * 这里沿用宿主 bare-token guard 的判据:只有「token 本身」或「token 后跟
36
+ * 空白」才当作命令 token 处理;"/codinputX" 这类真的用户文本不动。清理后
37
+ * 余下的用户内容原样保留。
38
+ */
39
+ function stripCommandToken(sessionId: string | undefined): void {
21
40
  if (!sessionId) return;
22
41
  try {
23
42
  const actx = stash.sessions?.scope(sessionId);
24
- if (actx && stash.conversation) stash.conversation.input.for(actx).setDraft('');
43
+ if (!actx || !stash.conversation) return;
44
+ const input = stash.conversation.input.for(actx);
45
+ const draft = input.state?.getSnapshot?.().draft ?? '';
46
+ const leading = draft.replace(/^\s+/, '');
47
+ if (!leading.toLowerCase().startsWith(COMMAND_TOKEN)) return;
48
+ const rest = leading.slice(COMMAND_TOKEN.length);
49
+ // 宿主命令 token 语法:token 必须以行尾/空白收尾。
50
+ if (rest !== '' && !/^\s/.test(rest)) return;
51
+ const next = rest.replace(/^\s+/, '');
52
+ if (next !== draft) input.setDraft(next);
25
53
  } catch {
26
- /* 会话作用域不可达时忽略(提交路径本就会清空草稿) */
54
+ /* 会话作用域不可达时忽略(退出接管本身不受影响) */
27
55
  }
28
56
  }
29
57
 
@@ -97,8 +125,12 @@ export function registerCodinputCommand(ctx: ClientContext): void {
97
125
  ui: {
98
126
  kind: 'action',
99
127
  run: () => {
100
- // 卸载会清掉诊断 sessionId,先捕获再延迟清草稿。
128
+ // 卸载会清掉诊断 sessionId,先捕获(清理必须在卸载前做完,
129
+ // 否则官方输入框挂载时就会带上残留 token)。
101
130
  const sessionId = diagWindow().__dshCodinputSessionId ?? undefined;
131
+ // 只剥离命令 token:宿主已派发 consume-token,这里兜住
132
+ // 「程序化唤起菜单导致 span 消费落空」的残留;用户草稿保留。
133
+ stripCommandToken(sessionId);
102
134
  savePrefs({ enabled: !loadPrefs().enabled });
103
135
  // 侧栏里发起的退出:关掉承载会话的 Codinput 标签(body 经
104
136
  // tab 域 actions.close 关闭、卸载撤销承载),主输入回到官方
@@ -106,7 +138,6 @@ export function registerCodinputCommand(ctx: ClientContext): void {
106
138
  if (!loadPrefs().enabled) {
107
139
  for (const carried of carriedSessionIds()) requestSidebarTabClose(carried);
108
140
  }
109
- setTimeout(() => clearDraftOf(sessionId), 80);
110
141
  },
111
142
  },
112
143
  }),
@@ -6,8 +6,6 @@
6
6
 
7
7
  import { useEffect, useMemo, useRef, useState } from 'react';
8
8
  import type { EditorView } from '@codemirror/view';
9
- import { marked } from 'marked';
10
- import * as DOMPurifyModule from 'dompurify';
11
9
  import type {
12
10
  ComposerKeyboard,
13
11
  InputActions,
@@ -16,15 +14,22 @@ import type {
16
14
  UseProjection,
17
15
  } from '../host-types';
18
16
  import { stash } from '../services';
19
- import { loadPrefs, savePrefs } from '../prefs';
17
+ import { loadPrefs, savePrefs, subscribePrefs } from '../prefs';
20
18
  import { addFilesToDraft, bindSessionFilePicker, dragHasFiles } from '../attachments';
21
19
  import { diagError, diagSession, diagWindow } from '../diag';
22
20
  import { IconEdit, IconEye } from '../icons';
23
21
  import { t, useT } from '../i18n';
22
+ import {
23
+ activateMarkdown,
24
+ handleMarkdownClick,
25
+ renderMarkdown,
26
+ type MarkdownImageTarget,
27
+ } from '../markdown';
24
28
  import { EditorPane } from './EditorPane';
25
29
  import { SlashMenu } from './SlashMenu';
26
30
  import { PopupSelectView } from './PopupSelectView';
27
31
  import { AttachmentRail } from './AttachmentRail';
32
+ import { ImageLightbox } from './ImageLightbox';
28
33
  import { Toolbar } from './Toolbar';
29
34
  import { StatsRow } from './StatsRow';
30
35
 
@@ -59,12 +64,6 @@ export interface CodinputSurfaceProps {
59
64
  onDragZonePointerDown?: (event: React.PointerEvent<HTMLDivElement>) => void;
60
65
  }
61
66
 
62
- marked.setOptions({ gfm: true, breaks: true });
63
-
64
- /** dompurify 的导出形状随打包条件漂移:默认导出与命名空间都兼容。 */
65
- const DOMPurify = ((DOMPurifyModule as unknown as { default?: unknown }).default ??
66
- DOMPurifyModule) as unknown as { sanitize(html: string): string };
67
-
68
67
  export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
69
68
  const { sessionId, state, actions, useProjection, inputStandard, placeholder, disabled, blocked, running, side, hero, chatNodes, float, onDragZonePointerDown } = props;
70
69
  // 语言切换时整棵表面重渲染(文案都走 i18n 的 t())。
@@ -73,12 +72,22 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
73
72
  const [editOn, setEditOn] = useState<boolean>(() => loadPrefs().defaultEdit);
74
73
  const [previewOn, setPreviewOn] = useState<boolean>(() => loadPrefs().defaultPreview);
75
74
  const [bodyHeight, setBodyHeight] = useState<number | null>(() => loadPrefs().composerBodyHeight);
75
+ /** Markdown 正文等宽连字(设置 → Markdown 等宽连字):即时跟随 prefs 变化。 */
76
+ const [markdownMono, setMarkdownMono] = useState<boolean>(() => loadPrefs().markdownMono);
77
+ useEffect(() => subscribePrefs(() => setMarkdownMono(loadPrefs().markdownMono)), []);
76
78
  /** 拖文件悬停卡片时的落区提示(松手即入草稿附件)。 */
77
79
  const [dropActive, setDropActive] = useState(false);
78
80
  const cardRef = useRef<HTMLDivElement | null>(null);
79
81
  const viewRef = useRef<EditorView | null>(null);
80
82
  const caretRef = useRef<number | null>(null);
81
83
  const previewRef = useRef<HTMLDivElement | null>(null);
84
+ /** 预览 Markdown 根(图片点灯箱 / 扩展渲染 扩展激活的挂点)。 */
85
+ const previewBodyRef = useRef<HTMLDivElement | null>(null);
86
+ /** Markdown 内 http(s) 图片点击后的大图灯箱。 */
87
+ const [lightbox, setLightbox] = useState<MarkdownImageTarget | null>(null);
88
+ const onPreviewClick = (event: React.MouseEvent<HTMLDivElement>): void => {
89
+ handleMarkdownClick(event, { onImage: setLightbox });
90
+ };
82
91
 
83
92
  // 高度调节:仅顶边把手,1:1 跟手(顶边上移多少、编辑区就高多少),松手持久化。
84
93
  // 宽度不做自有调节——沿用官方聊天宽度调节器(wSkVaW_widthHandle):
@@ -233,23 +242,38 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
233
242
  actions.submit();
234
243
  };
235
244
 
236
- // 官方同语义:草稿与附件皆空时发送禁用(空草稿手势仍可经键盘 Steer)。
245
+ // 官方同语义(0.2.2 InputBar):empty = 草稿与附件皆空;machineBusy = 输入机
246
+ // 三个 busy 阶段(adjudicating / submitting / sending)——编辑器软锁、主按钮
247
+ // 不再可提交;sending 时主按钮转 spinner,停止由独立停止钮(interruptible)承接。
237
248
  const inert = disabled === true || blocked === true;
238
249
  const draftEmpty = (state?.draft ?? '').trim() === '';
239
250
  const noAttachments = (state?.attachmentIds.length ?? 0) === 0;
240
- // 官方 primaryStops:运行中且草稿与附件皆空 → 主按钮变停止;停止形态不随
241
- // 禁用锁死(官方 primaryDisabled 在 stops 分支只看 stop 是否可用)。
242
- const primaryStops = running === true && draftEmpty && noAttachments;
243
- const sendDisabled =
244
- inert ||
245
- actions === undefined ||
246
- (state !== undefined && state.phase === 'submitting') ||
247
- (draftEmpty && noAttachments);
248
-
251
+ const empty = draftEmpty && noAttachments;
252
+ const phase = state?.phase;
253
+ const sending = phase === 'sending';
254
+ const machineBusy = phase === 'adjudicating' || phase === 'submitting' || sending;
255
+ // 官方 primaryStops = running && subagent===null && !sending && (empty || blocked)。
256
+ // (插件侧无 subagent;blocked 走 bar owner props。)
257
+ // 停止形态不随禁用锁死:官方 primaryDisabled 在停止分支只看 stop 是否可用。
258
+ const primaryStops = running === true && !sending && (empty || blocked === true);
259
+ // 官方 primaryDisabled 的非停止分支:empty || disabled || machineBusy || uploadsPending
260
+ // (插件侧草稿附件即传即用,无 uploadsPending;!live 等价于 actions 缺席)。
261
+ const sendDisabled = primaryStops ? false : empty || inert || machineBusy || actions === undefined;
262
+
263
+ // 统一 Markdown 管线(与用户消息同一函数):同步渲染,draft 变更即重算。
249
264
  const html = useMemo(
250
- () => DOMPurify.sanitize(marked.parse(state?.draft ?? '') as string),
265
+ () => renderMarkdown(state?.draft ?? '', { surface: 'preview' }),
251
266
  [state?.draft, previewOn],
252
267
  );
268
+ // 扩展渲染 扩展(代码高亮 / KaTeX / Mermaid)的 DOM 激活口;无注册扩展时 no-op。
269
+ // 依赖必须含 previewOn:预览是条件渲染,「先粘贴、后开预览」时 html 没变、根节点
270
+ // 却从 null 变成新挂载节点,只靠 [html] 会跳过激活(late-mount 路径:KaTeX 停在
271
+ // 占位、Mermaid 一直 pending、代码块复制按钮无事件)。previewOn 切换时 React 会
272
+ // 先跑上一次 cleanup,再对新根重新激活;side/float 与 normal 共用本效果。
273
+ useEffect(() => {
274
+ if (!previewOn) return undefined;
275
+ return activateMarkdown(previewBodyRef.current, 'preview');
276
+ }, [html, previewOn]);
253
277
 
254
278
  // ---- 分屏同步滚动:两侧按滚动比例互随(内容高度不同,比例映射) ----
255
279
  // scroll 不冒泡但走捕获相位,挂在 body 上的 onScrollCapture 能收到两栏的
@@ -380,15 +404,27 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
380
404
  placeholder={placeholderText}
381
405
  viewRef={viewRef}
382
406
  onCaretMoved={caretMoved}
383
- disabled={inert}
407
+ // 官方 busy 软锁:adjudicating / submitting / sending 三阶段编辑器只读
408
+ //(EditorPane 内部已覆盖前两者,sending 由这里并入 disabled)。
409
+ disabled={inert || sending}
384
410
  autoFocus={true}
385
411
  solo={!previewOn}
386
412
  stateKey={sessionId ?? 'hero'}
387
413
  />
388
414
  ) : null}
389
415
  {previewOn ? (
390
- <div className="dci-preview" data-solo={!editOn} ref={previewRef}>
391
- {html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : <span style={{ opacity: 0.4 }}>{t("preview.empty")}</span>}
416
+ <div className="dci-preview" data-solo={!editOn} ref={previewRef} onClick={onPreviewClick}>
417
+ {html ? (
418
+ <div
419
+ className="dci-md"
420
+ data-dci-md="preview"
421
+ data-mono={markdownMono ? 'true' : undefined}
422
+ ref={previewBodyRef}
423
+ dangerouslySetInnerHTML={{ __html: html }}
424
+ />
425
+ ) : (
426
+ <span style={{ opacity: 0.4 }}>{t("preview.empty")}</span>
427
+ )}
392
428
  </div>
393
429
  ) : null}
394
430
  </div>
@@ -400,7 +436,11 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
400
436
  triggers={triggers}
401
437
  caretRef={caretRef}
402
438
  useProjection={useProjection}
403
- disabled={inert}
439
+ // 官方两个 owner 信号分开下发:inert(disabled)与 blocked。
440
+ // locked = inert || blocked;modelSeatLocked = inert || !live(不含 blocked)。
441
+ disabled={disabled === true}
442
+ blocked={blocked === true}
443
+ running={running === true}
404
444
  primaryStops={primaryStops}
405
445
  onStop={onStop}
406
446
  onNotify={notifyFor(sessionId)}
@@ -414,9 +454,12 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
414
454
  <StatsRow useProjection={useProjection} sessionId={sessionId} chatNodes={chatNodes} />
415
455
  ) : null}
416
456
 
417
- {/* 官方 MenuView 锚定:主模式以卡片顶上方弹出;侧栏/悬浮形态由
418
- SlashMenu 按 dropdown 改为头部行下方向下弹出(不被面板/视口裁剪) */}
419
- {triggers ? <SlashMenu triggers={triggers} dropdown={side === true || float === true} /> : null}
457
+ {/* 官方 MenuView 锚定:normal 形态仍以卡片顶上方 absolute 浮层弹出(几何不变);
458
+ side/float 形态由 SlashMenu portal 到 body,按卡片四周空位做自适应 fixed
459
+ 浮层(不遮编辑区、不挤压卡片布局)。 */}
460
+ {triggers ? (
461
+ <SlashMenu triggers={triggers} dropdown={side === true || float === true} anchorRef={cardRef} />
462
+ ) : null}
420
463
  </div>
421
464
 
422
465
  {/* 数据行:主输入/侧栏渲染在卡片下方(官方 hero 新会话态无 stats)。 */}
@@ -425,6 +468,16 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
425
468
  ) : null}
426
469
 
427
470
  {popup ? <PopupSelectView popup={popup} /> : null}
471
+
472
+ {/* Markdown 图片大图灯箱(用户消息侧同组件同行为)。 */}
473
+ {lightbox ? (
474
+ <ImageLightbox
475
+ src={lightbox.src}
476
+ alt={lightbox.alt}
477
+ labels={{ dialog: t('image.preview'), close: t('image.closePreview') }}
478
+ onClose={() => setLightbox(null)}
479
+ />
480
+ ) : null}
428
481
  </div>
429
482
  );
430
483
  }
@@ -102,7 +102,9 @@ export function EditorPane(props: EditorPaneProps): JSX.Element {
102
102
  }
103
103
  if (matchEvent(prefsNow.lineBreakHotkey, event) && prefsNow.lineBreakHotkey !== 'enter') {
104
104
  event.preventDefault();
105
- view.dispatch({ changes: { from: view.state.selection.main.head, insert: '\n' } });
105
+ const head = view.state.selection.main.head;
106
+ // 显式插入换行后必须把光标放到插入点之后,否则 CodeMirror 会把光标留在插入点之前,后续输入落回上一行(B1 行为层:Shift+Enter「看起来没换行」);普通 Enter 走 CM 默认 keymap,无此问题。
107
+ view.dispatch({ changes: { from: head, insert: '\n' }, selection: { anchor: head + 1 }, scrollIntoView: true });
106
108
  return true;
107
109
  }
108
110
  return false;
@@ -26,7 +26,15 @@
26
26
  */
27
27
 
28
28
  import { useMemo } from 'react';
29
- import { entryId, listRows, renderSlotEntry, useSlotEntries } from './slotEntry';
29
+ import type { ReactNode } from 'react';
30
+ import {
31
+ entryId,
32
+ entryInjectFailed,
33
+ firstComponentEntry,
34
+ listRows,
35
+ renderSlotEntry,
36
+ useSlotEntries,
37
+ } from './slotEntry';
30
38
 
31
39
  export interface InputSlotSeatProps {
32
40
  /** 官方子槽位全名(如 conversation.input.left)。 */
@@ -47,3 +55,50 @@ export function InputSlotSeat(props: InputSlotSeatProps): JSX.Element | null {
47
55
 
48
56
  return <>{rows.map((entry, index) => renderSlotEntry(entry, entryId(entry, index), { sessionId, standard }))}</>;
49
57
  }
58
+
59
+ export interface InputSingleSlotProps {
60
+ /** 官方单值子槽位全名(如 conversation.input.permission / .plan)。 */
61
+ slot: string;
62
+ sessionId: string | undefined;
63
+ /** 宿主下发给本插件条目的 standard props 原样面。 */
64
+ standard: Record<string, unknown> | undefined;
65
+ /** 官方 renderSlot 第二参数的 owner props(如 { locked })。 */
66
+ ownerProps?: Record<string, unknown> | undefined;
67
+ /**
68
+ * 兜底渲染(可选):
69
+ * - 条目 inject 面在插件 fiber 里不可复现(依赖宿主服务面的 inject 会抛
70
+ * `without inject`,见 slotEntry.boundInject)→ 渲染 fallback;
71
+ * - 条目组件自身崩溃 → EntryBoundary 回退 fallback。
72
+ * 缺省 null(槽位有占用者但无法镜像时按官方「画不出来」处理)。
73
+ */
74
+ fallback?: ReactNode;
75
+ }
76
+
77
+ /**
78
+ * 单值槽位镜像(0.2.2 的 `conversation.input.permission` / `.plan` 用)。
79
+ *
80
+ * 官方口径(InputBar.tsx):`renderSlot("conversation.input.permission", { locked })`
81
+ * 与 `renderSlot("conversation.input.plan", { locked })` 位于 `.tools` 内
82
+ * `.modes` 容器(permission 在前)。**槽位已声明但无占用者 = 官方留空**,
83
+ * 因此这里对「已声明 + 空」返回 null,绝不回退自绘控件;「未声明」由调用方
84
+ * 用 `useSlotDeclaration` 区分后决定回退(旧宿主才回退 PermissionMenu)。
85
+ */
86
+ export function InputSingleSlot(props: InputSingleSlotProps): JSX.Element | null {
87
+ const { slot, sessionId, standard, ownerProps, fallback } = props;
88
+ const entries = useSlotEntries(slot);
89
+ const occupant = useMemo(() => firstComponentEntry(entries), [entries]);
90
+
91
+ // 官方门槛:无会话不画该座位(InputBar 的 sessionId 三元)。
92
+ if (sessionId === undefined || occupant === null) return null;
93
+
94
+ // 让位可行性:inject 面必须在宿主 fiber 上下文里才算数(见 slotEntry)。
95
+ // 不可复现时回退调用方给的兜底(permission 用自绘菜单保住旧功能)。
96
+ if (entryInjectFailed(occupant, sessionId)) return <>{fallback ?? null}</>;
97
+
98
+ return renderSlotEntry(occupant, `single:${entryId(occupant, 0)}`, {
99
+ sessionId,
100
+ standard,
101
+ ownerProps,
102
+ fallback,
103
+ });
104
+ }