dsh-codinput 0.3.7 → 0.3.8

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.
@@ -20,12 +20,12 @@
20
20
  * 出极简 JSON 块(官方 JsonBlock 的降级形态)。
21
21
  */
22
22
 
23
- import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
23
+ import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
24
24
  import { Marked, type MarkedExtension } from 'marked';
25
25
  import * as DOMPurifyModule from 'dompurify';
26
26
  import type { SlotRegisterOptions } from '../host-types';
27
27
  import { diagError } from '../diag';
28
- import { bindForeign, t, type TranslateParams } from '../i18n';
28
+ import { bindForeign, t, useT, type TranslateParams } from '../i18n';
29
29
  import { FileGlyph, fileExtension, fileSizeText } from './AttachmentRail';
30
30
 
31
31
  /* ---------- 注册 ---------- */
@@ -75,7 +75,9 @@ export interface UserMessageMarkdownProps {
75
75
 
76
76
  interface ContentParts {
77
77
  text: string;
78
- attachments: ({ type: 'image'; image: unknown } | { type: 'file'; file: { name?: string; bytes?: number } })[];
78
+ /** 图片:官方 contentParts 把它包成 { attachment: ImageAttachmentRef }——官方 ImageGallery
79
+ * 的 React key 读 image.attachment.attachmentId;裸 ref 会让 key 计算访问 preview.url 崩。 */
80
+ attachments: ({ type: 'image'; image: { attachment: unknown } } | { type: 'file'; file: { name?: string; bytes?: number } })[];
79
81
  rest: unknown[];
80
82
  }
81
83
 
@@ -87,7 +89,7 @@ function contentParts(content: readonly unknown[] | undefined): ContentParts {
87
89
  for (const block of content) {
88
90
  const b = block as Partial<TextBlock> | Partial<ImageBlock> | Partial<FileBlock> | null;
89
91
  if (b && b.type === 'text' && typeof b.text === 'string') texts.push(b.text);
90
- else if (b && b.type === 'image' && b.attachment !== undefined) parts.attachments.push({ type: 'image', image: b.attachment });
92
+ else if (b && b.type === 'image' && b.attachment !== undefined) parts.attachments.push({ type: 'image', image: { attachment: b.attachment } });
91
93
  else if (b && b.type === 'file' && b.attachment !== undefined) parts.attachments.push({ type: 'file', file: (b.attachment ?? {}) as { name?: string; bytes?: number } });
92
94
  else parts.rest.push(block);
93
95
  }
@@ -99,7 +101,24 @@ function contentParts(content: readonly unknown[] | undefined): ContentParts {
99
101
 
100
102
  /** dompurify 的导出形状随打包条件漂移:默认导出与命名空间都兼容。 */
101
103
  const DOMPurify = ((DOMPurifyModule as unknown as { default?: unknown }).default ??
102
- DOMPurifyModule) as unknown as { sanitize(html: string): string };
104
+ DOMPurifyModule) as unknown as { sanitize(html: string, options?: Record<string, unknown>): string };
105
+
106
+ /** 用户消息是「不可信 Markdown」面:marked 会原样透传原始 HTML,官方 MarkdownText 则是
107
+ * mdast→React 逐节点渲染且链接/图片只放行 http(s)/mailto。这里用 DOMPurify 白名单收敛到
108
+ * Markdown 产出面——禁 style/form/控件/媒体等原始 HTML(<style> 一旦进 DOM 会全局生效),
109
+ * URI 只留 http(s)/mailto,行内 style 一律去掉。 */
110
+ const SANITIZE_OPTIONS = {
111
+ ALLOWED_TAGS: [
112
+ 'p', 'br', 'strong', 'em', 'del', 'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'a', 'img',
113
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'input', 'span',
114
+ ],
115
+ ALLOWED_ATTR: [
116
+ 'href', 'src', 'alt', 'title', 'target', 'rel', 'type', 'checked', 'disabled', 'start',
117
+ 'colspan', 'rowspan', 'align', 'data-dci-wire',
118
+ ],
119
+ ALLOWED_URI_REGEXP: /^(?:https?:|mailto:)/i,
120
+ FORBID_ATTR: ['style'],
121
+ };
103
122
 
104
123
  function escapeHtml(text: string): string {
105
124
  return text.replace(
@@ -135,7 +154,7 @@ const mdParser = new Marked({ gfm: true, breaks: true, extensions: [sessionWireE
135
154
 
136
155
  function renderMarkdown(text: string): string {
137
156
  try {
138
- return DOMPurify.sanitize(mdParser.parse(text) as string);
157
+ return DOMPurify.sanitize(mdParser.parse(text) as string, SANITIZE_OPTIONS);
139
158
  } catch (error) {
140
159
  diagError('umsg.markdown', error);
141
160
  return `<p>${escapeHtml(text).replace(/\n/g, '<br>')}</p>`;
@@ -284,12 +303,15 @@ function chipClickGuard(event: MouseEvent): boolean {
284
303
  return false;
285
304
  }
286
305
 
287
- function chipElement(range: ChipRange, actions: ChipActions): HTMLElement {
306
+ function chipElement(range: ChipRange, actionsRef: React.MutableRefObject<ChipActions>): HTMLElement {
307
+ const actions = actionsRef.current;
308
+ // 官方 projectUserText:只有 file 与 /skill 可点(folder / session 是纯 span);
309
+ // 点击时读 actionsRef.current,回调后到时无需重新装饰 DOM。
288
310
  const clickable =
289
311
  range.clickable &&
290
- (range.kind === 'file' || range.kind === 'folder'
312
+ (range.kind === 'file'
291
313
  ? typeof actions.openFile === 'function'
292
- : typeof actions.openSkill === 'function');
314
+ : range.kind === 'slash' && typeof actions.openSkill === 'function');
293
315
  const chip = document.createElement(clickable ? 'button' : 'span');
294
316
  if (clickable) chip.setAttribute('type', 'button');
295
317
  chip.className = 'dci-umsg-chip';
@@ -301,8 +323,9 @@ function chipElement(range: ChipRange, actions: ChipActions): HTMLElement {
301
323
  if (clickable) {
302
324
  chip.addEventListener('click', (event: Event) => {
303
325
  if (chipClickGuard(event as MouseEvent)) return;
304
- if (range.kind === 'file' || range.kind === 'folder') actions.openFile?.(range.label.slice(1).replace(/^"|"$/gu, ''));
305
- else actions.openSkill?.(range.display.slice(1));
326
+ const live = actionsRef.current;
327
+ if (range.kind === 'slash') live.openSkill?.(range.display.slice(1));
328
+ else live.openFile?.(range.label.slice(1).replace(/^"|"$/gu, ''));
306
329
  });
307
330
  }
308
331
  return chip;
@@ -314,19 +337,19 @@ function rangeRank(range: ChipRange): number {
314
337
  }
315
338
 
316
339
  /** 把一段文本按 ranges 切开,plain 部分留原文本节点,token 位换成 chip 元素。 */
317
- function splitTextNode(node: Text, ranges: ChipRange[], actions: ChipActions): void {
340
+ function splitTextNode(node: Text, ranges: ChipRange[], actionsRef: React.MutableRefObject<ChipActions>): void {
318
341
  const fragment = document.createDocumentFragment();
319
342
  let cursor = 0;
320
343
  for (const range of ranges) {
321
344
  if (range.start > cursor) fragment.appendChild(document.createTextNode(node.data.slice(cursor, range.start)));
322
- fragment.appendChild(chipElement(range, actions));
345
+ fragment.appendChild(chipElement(range, actionsRef));
323
346
  cursor = range.end;
324
347
  }
325
348
  if (cursor < node.data.length) fragment.appendChild(document.createTextNode(node.data.slice(cursor)));
326
349
  node.parentNode?.replaceChild(fragment, node);
327
350
  }
328
351
 
329
- function chipTextNodes(root: HTMLElement, actions: ChipActions): void {
352
+ function chipTextNodes(root: HTMLElement, actionsRef: React.MutableRefObject<ChipActions>): void {
330
353
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
331
354
  acceptNode(node: Node): number {
332
355
  const parent = (node as Text).parentElement;
@@ -342,11 +365,12 @@ function chipTextNodes(root: HTMLElement, actions: ChipActions): void {
342
365
  nodes.push(current as Text);
343
366
  current = walker.nextNode();
344
367
  }
368
+ const { skillNames, referenceLabels } = actionsRef.current;
345
369
  for (const node of nodes) {
346
370
  const ranges = [
347
371
  ...sessionWireRanges(node.data),
348
- ...sessionLabelRanges(node.data, actions.referenceLabels),
349
- ...genericTokenRanges(node.data, actions.skillNames),
372
+ ...sessionLabelRanges(node.data, referenceLabels),
373
+ ...genericTokenRanges(node.data, skillNames),
350
374
  ];
351
375
  if (ranges.length === 0) continue;
352
376
  ranges.sort((a, b) => a.start - b.start || rangeRank(a) - rangeRank(b) || b.end - a.end);
@@ -358,19 +382,43 @@ function chipTextNodes(root: HTMLElement, actions: ChipActions): void {
358
382
  cursor = range.end;
359
383
  }
360
384
  if (picked.length === 0) continue;
361
- splitTextNode(node, picked, actions);
385
+ splitTextNode(node, picked, actionsRef);
362
386
  }
363
387
  }
364
388
 
365
389
  /** innerHTML 落地后的整备:链接新开页、表格出滚动壳、线格式与 @/// token 换 chip。 */
366
- function decorateMarkdownRoot(root: HTMLElement | null, actions: ChipActions): void {
390
+ function decorateMarkdownRoot(root: HTMLElement | null, actionsRef: React.MutableRefObject<ChipActions>): void {
367
391
  if (!root) return;
368
392
  try {
369
- for (const anchor of Array.from(root.querySelectorAll('a[href]'))) {
393
+ // 官方 sanitizeUrl 只放行 http(s)/mailto:其余链接(含 DOMPurify 已剥掉 href 的残链)
394
+ // 没有可点面,拆回纯文本;http(s) 统一新开页。
395
+ for (const anchor of Array.from(root.querySelectorAll('a'))) {
370
396
  const href = anchor.getAttribute('href') ?? '';
371
- if (!/^https?:/i.test(href)) continue;
372
- anchor.setAttribute('target', '_blank');
373
- anchor.setAttribute('rel', 'noreferrer noopener');
397
+ if (/^https?:/i.test(href)) {
398
+ anchor.setAttribute('target', '_blank');
399
+ anchor.setAttribute('rel', 'noreferrer noopener');
400
+ continue;
401
+ }
402
+ if (/^mailto:/i.test(href)) {
403
+ anchor.setAttribute('rel', 'noreferrer noopener');
404
+ continue;
405
+ }
406
+ anchor.replaceWith(...Array.from(anchor.childNodes));
407
+ }
408
+ // 官方 remoteImageUrl 只放行 http(s);相对/data: 图片降级成 alt 文本,不留破图。
409
+ for (const img of Array.from(root.querySelectorAll('img'))) {
410
+ if (/^https?:/i.test(img.getAttribute('src') ?? '')) continue;
411
+ img.replaceWith(document.createTextNode(img.getAttribute('alt') ?? ''));
412
+ }
413
+ // 只保留 GFM 任务清单的只读复选框(marked 产出 checked/disabled,type 可能被
414
+ // sanitize 剥掉),其余 <input> 一律移除——不让用户消息里出现可编辑表单控件。
415
+ for (const input of Array.from(root.querySelectorAll('input'))) {
416
+ if (!input.hasAttribute('disabled')) {
417
+ input.remove();
418
+ continue;
419
+ }
420
+ input.setAttribute('type', 'checkbox');
421
+ input.setAttribute('disabled', '');
374
422
  }
375
423
  for (const table of Array.from(root.querySelectorAll('table'))) {
376
424
  const parent = table.parentElement;
@@ -382,9 +430,9 @@ function decorateMarkdownRoot(root: HTMLElement | null, actions: ChipActions): v
382
430
  }
383
431
  for (const placeholder of Array.from(root.querySelectorAll('span[data-dci-wire]'))) {
384
432
  const label = placeholder.getAttribute('data-dci-wire') ?? '';
385
- placeholder.replaceWith(chipElement({ start: 0, end: 0, kind: 'session', label, display: label, clickable: false }, actions));
433
+ placeholder.replaceWith(chipElement({ start: 0, end: 0, kind: 'session', label, display: label, clickable: false }, actionsRef));
386
434
  }
387
- chipTextNodes(root, actions);
435
+ chipTextNodes(root, actionsRef);
388
436
  } catch (error) {
389
437
  diagError('umsg.decorate', error);
390
438
  }
@@ -487,7 +535,7 @@ function CheckGlyph(): JSX.Element {
487
535
  function MessageFileCard({ file }: { file: { name?: string; bytes?: number } }): JSX.Element {
488
536
  const name = typeof file.name === 'string' ? file.name : '';
489
537
  const bytes = typeof file.bytes === 'number' ? file.bytes : 0;
490
- const meta = [fileExtension(name).toUpperCase().slice(0, 8), bytes > 0 ? fileSizeText(bytes) : ''].filter(Boolean).join(' ');
538
+ const meta = [fileExtension(name).toUpperCase().slice(0, 8), fileSizeText(bytes)].filter(Boolean).join(' ');
491
539
  return (
492
540
  <span className="dci-umsg-file" title={name}>
493
541
  <span className="dci-umsg-fileglyph">
@@ -501,8 +549,29 @@ function MessageFileCard({ file }: { file: { name?: string; bytes?: number } }):
501
549
  );
502
550
  }
503
551
 
504
- export function UserMessageMarkdown(props: UserMessageMarkdownProps): JSX.Element | null {
552
+ /** 旧/异形宿主缺 renderMessageImages 座位 prop 时的图片降级卡(不静默丢内容)。 */
553
+ function MessageImageFallback({ image }: { image: { attachment: unknown } }): JSX.Element {
554
+ const att = (image.attachment ?? {}) as { name?: unknown; mediaType?: unknown; bytes?: unknown };
555
+ const name = typeof att.name === 'string' && att.name !== '' ? att.name : t('umsg.image');
556
+ const media = typeof att.mediaType === 'string' ? att.mediaType.replace(/^image\//i, '').toUpperCase() : '';
557
+ const meta = [media, typeof att.bytes === 'number' ? fileSizeText(att.bytes) : ''].filter(Boolean).join(' ');
558
+ return (
559
+ <span className="dci-umsg-file" data-image-fallback="true" title={name}>
560
+ <span className="dci-umsg-fileglyph">
561
+ <FileGlyph />
562
+ </span>
563
+ <span className="dci-umsg-filecontent">
564
+ <span className="dci-umsg-filename">{name}</span>
565
+ <span className="dci-umsg-filemeta">{meta}</span>
566
+ </span>
567
+ </span>
568
+ );
569
+ }
570
+
571
+ function UserMessageMarkdownView(props: UserMessageMarkdownProps): JSX.Element | null {
505
572
  const { node, renderMessageImages, openFile, openSkill } = props;
573
+ // 订阅宿主 locale 快照:语言切换时复制钮/引用摘要等文案即时刷新(其它组件同款)。
574
+ useT();
506
575
  const data = (node?.data ?? {}) as UserMessageData;
507
576
  const content = data.content;
508
577
  const referenceLabels = useMemo(() => (Array.isArray(data.referenceLabels) ? data.referenceLabels : []), [data.referenceLabels]);
@@ -515,17 +584,30 @@ export function UserMessageMarkdown(props: UserMessageMarkdownProps): JSX.Elemen
515
584
  const bodyRef = useRef<HTMLDivElement | null>(null);
516
585
  const actionsRef = useRef<ChipActions>({ openFile, openSkill, skillNames, referenceLabels });
517
586
  actionsRef.current = { openFile, openSkill, skillNames, referenceLabels };
587
+ // 引用标签 / 技能名单后到(投影刷新)时重跑装饰,补齐 @引用与 /技能 chip。
518
588
  useEffect(() => {
519
- decorateMarkdownRoot(bodyRef.current, actionsRef.current);
520
- }, [html]);
589
+ decorateMarkdownRoot(bodyRef.current, actionsRef);
590
+ }, [html, referenceLabels, skillNames]);
521
591
 
522
592
  const [copied, setCopied] = useState(false);
593
+ const copyTimer = useRef<number | null>(null);
594
+ // 卸载时清掉「已复制」回退定时器,避免卸载后的 setState。
595
+ useEffect(
596
+ () => () => {
597
+ if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
598
+ },
599
+ [],
600
+ );
523
601
  const onCopy = useCallback(() => {
524
602
  if (copied || text === '') return;
525
603
  void writeClipboard(text).then((ok) => {
526
604
  if (!ok) return;
527
605
  setCopied(true);
528
- window.setTimeout(() => setCopied(false), 1000);
606
+ if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
607
+ copyTimer.current = window.setTimeout(() => {
608
+ copyTimer.current = null;
609
+ setCopied(false);
610
+ }, 1000);
529
611
  });
530
612
  }, [copied, text]);
531
613
 
@@ -544,7 +626,7 @@ export function UserMessageMarkdown(props: UserMessageMarkdownProps): JSX.Elemen
544
626
  <Fragment key={`image:${index}`}>
545
627
  {renderMessageImages
546
628
  ? renderMessageImages({ images: [att.image], align: 'end', compact: compactImages })
547
- : null}
629
+ : <MessageImageFallback image={att.image} />}
548
630
  </Fragment>
549
631
  ) : (
550
632
  <MessageFileCard key={`file:${index}`} file={att.file} />
@@ -589,3 +671,6 @@ export function UserMessageMarkdown(props: UserMessageMarkdownProps): JSX.Elemen
589
671
  </div>
590
672
  );
591
673
  }
674
+
675
+ /** 与官方 UserMessageNodeView 一致用 memo 包裹:历史节点快照重建时不连坐重渲染。 */
676
+ export const UserMessageMarkdown = memo(UserMessageMarkdownView);
@@ -100,6 +100,7 @@ const ZH: Dict = {
100
100
  'umsg.clock.md': '{m}月{d}日',
101
101
  'umsg.clock.ymd': '{y}年{m}月{d}日',
102
102
  'umsg.extraBlock': '额外内容块',
103
+ 'umsg.image': '图片',
103
104
  'set.lineNumbers': '显示行号',
104
105
  'set.lineNumbersDesc': '当前行与行号高亮常开',
105
106
  'set.font': '编辑器字体',
@@ -212,6 +213,7 @@ const EN: Dict = {
212
213
  'umsg.clock.md': '{m}/{d}',
213
214
  'umsg.clock.ymd': '{y}/{m}/{d}',
214
215
  'umsg.extraBlock': 'Extra content block',
216
+ 'umsg.image': 'Image',
215
217
  'set.lineNumbers': 'Show line numbers',
216
218
  'set.lineNumbersDesc': 'The current line and its gutter number stay highlighted',
217
219
  'set.font': 'Editor font',
@@ -307,16 +309,18 @@ export function installI18n(locale: unknown): void {
307
309
  * 的翻译函数;宿主 face 未就绪或 bind 失败返回 null。结果按 ns 缓存。
308
310
  */
309
311
  export function bindForeign(ns: string): ((key: string, params?: TranslateParams) => string) | null {
310
- if (!foreignBinds.has(ns)) {
311
- let bound: ((key: string, params?: TranslateParams) => string) | null = null;
312
- try {
313
- bound = hostFace?.bind?.(ns) ?? null;
314
- } catch {
315
- bound = null;
316
- }
317
- foreignBinds.set(ns, bound);
312
+ const cached = foreignBinds.get(ns);
313
+ if (cached) return cached;
314
+ // 只缓存成功的绑定:宿主 locale 服务可能晚于首次调用就绪,null 缓存会让
315
+ // 之后所有文案永远拿不到官方词典。
316
+ let bound: ((key: string, params?: TranslateParams) => string) | null = null;
317
+ try {
318
+ bound = hostFace?.bind?.(ns) ?? null;
319
+ } catch {
320
+ bound = null;
318
321
  }
319
- return foreignBinds.get(ns) ?? null;
322
+ if (bound) foreignBinds.set(ns, bound);
323
+ return bound;
320
324
  }
321
325
 
322
326
  export function subscribeI18n(fn: () => void): () => void {
@@ -56,7 +56,12 @@ export function apply(ctx: ClientContext): void {
56
56
  const orig = w.console.error.bind(w.console);
57
57
  w.console.error = (...args: unknown[]): void => {
58
58
  const first = args.find((a) => a instanceof Error);
59
- if (first) (w.__dshCodinputErrs as unknown as Error[]).push(first as Error);
59
+ if (first) {
60
+ // 与 diagError 同口径限长:长会话里 console.error 不应无界堆积。
61
+ const errs = w.__dshCodinputErrs as unknown as unknown[];
62
+ errs.push(first);
63
+ if (errs.length > 200) errs.splice(0, errs.length - 200);
64
+ }
60
65
  orig(...args);
61
66
  };
62
67
  }
@@ -412,13 +412,19 @@ button.dci-attach-body { color: inherit; font: inherit; text-align: left; backgr
412
412
  height: 36px; color: var(--dsw-alias-label-primary, inherit); border: none; border-radius: 18px; outline: none;
413
413
  padding: 0 14px; font-size: 13px; min-width: 260px; max-width: 320px; }
414
414
  .dci-set-input:focus { outline: 2px solid var(--dsw-alias-border-l3, transparent); }
415
+ /* 开关:官方 primitives Switch 逐值(Switch.module.css)。ON 轨道走
416
+ --dsw-alias-brand-primary——该 token 自 0.2.0-rc.2 起浅色主题即近黑(#0f1115)、
417
+ 深色主题近白(#f9fafb),桌面 rc.3 同款;同 token 跟随宿主主题,就是官方样式的来源。 */
415
418
  .dci-switch { box-sizing: border-box; position: relative; flex: none; width: 36px; height: 20px; padding: 2px;
416
- border: 0; border-radius: 10px; background: var(--dsw-alias-border-l3, color-mix(in srgb, currentColor 30%, transparent));
419
+ border: 0; border-radius: 999px; corner-shape: round;
420
+ background: var(--dsw-alias-border-l3, color-mix(in srgb, currentColor 30%, transparent));
417
421
  cursor: pointer; }
418
422
  .dci-switch[aria-checked="true"] { background: var(--dsw-alias-brand-primary, color-mix(in srgb, currentColor 70%, transparent)); }
419
- .dci-switch:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, currentColor); outline-offset: 2px; }
420
- .dci-switch::after { content: ''; display: block; width: 16px; height: 16px; border-radius: 50%;
423
+ .dci-switch:disabled { cursor: default; opacity: 0.5; }
424
+ .dci-switch:focus-visible { outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary, currentColor)); outline-offset: 2px; }
425
+ .dci-switch::after { content: ''; display: block; width: 16px; height: 16px; border-radius: 50%; corner-shape: round;
421
426
  background: var(--dsw-alias-label-primary-foreground, #fff); transition: transform 120ms ease; }
427
+ .dci-switch[aria-checked="false"]::after { background: var(--dsw-alias-switch-thumb, #fff); }
422
428
  .dci-switch[aria-checked="true"]::after { transform: translateX(16px); }
423
429
 
424
430
 
@@ -440,15 +446,17 @@ button.dci-attach-body { color: inherit; font: inherit; text-align: left; backgr
440
446
  padding: 4px 0 0; font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px);
441
447
  line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
442
448
  justify-content: center; gap: 12px; display: flex; }
443
- .dci-stat-anchor { min-width: 0; display: inline-flex; position: relative; }
449
+ .dci-stat-anchor { min-width: 0; display: inline-flex; }
444
450
 
445
- /* 数据行详情面板(官方 bRhRbq 复刻:锚点上方弹出) */
446
- .dci-stat-panel { z-index: 1100; box-sizing: border-box; background: var(--dsw-menu-surface-fill, var(--dsw-specific-menu, rgba(30, 30, 34, 0.98)));
451
+ /* 数据行详情面板(官方 bRhRbq 复刻:portal 到 body、fixed、锚点上方弹出)。
452
+ 背景与圆角逐值同官方:--dsw-specific-menu 优先(macOS 桌面上官方把它覆写成不透明),
453
+ 旧宿主/皮肤缺该 token 时回退 --dsw-menu-surface-fill 链,不落到硬编码深色。 */
454
+ .dci-stat-panel { z-index: 1100; box-sizing: border-box; background: var(--dsw-specific-menu, var(--dsw-menu-surface-fill, rgba(30, 30, 34, 0.98)));
447
455
  backdrop-filter: var(--dsw-menu-backdrop-filter, none);
448
456
  color: var(--dsw-alias-label-secondary, inherit); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1, transparent);
449
457
  width: max-content; min-width: min(300px, 100vw - 24px); max-width: min(440px, 100vw - 24px); text-align: left;
450
458
  box-shadow: var(--dsw-elevation-prominent, 0 10px 34px rgba(0, 0, 0, 0.35)); cursor: default; border: 0;
451
- border-radius: 12px; padding: 16px; font-size: 12px; line-height: 18px; position: absolute; bottom: calc(100% + 8px); left: 0; }
459
+ border-radius: var(--dsw-radius-lg, 12px); padding: 16px; font-size: 12px; line-height: 18px; position: fixed; }
452
460
  .dci-stat-panelTitle { color: var(--dsw-alias-label-primary, inherit); justify-content: space-between; gap: 16px;
453
461
  margin-bottom: 8px; font-weight: 500; display: flex; }
454
462
  .dci-stat-panelLabel { align-items: center; gap: 6px; min-width: 0; display: inline-flex; }
@@ -460,36 +468,40 @@ button.dci-attach-body { color: inherit; font: inherit; text-align: left; backgr
460
468
  .dci-stat-details dt, .dci-stat-details dd { min-width: 0; margin: 0; }
461
469
  .dci-stat-details dd { color: var(--dsw-alias-label-secondary, inherit); font-variant-numeric: tabular-nums; text-align: right; }
462
470
  .dci-stat-pill { font: inherit; box-sizing: border-box; max-width: 100%; color: var(--dsw-alias-label-tertiary, currentColor);
463
- white-space: nowrap; background: transparent; border: none; border-radius: 24px; align-items: center; gap: 6px;
471
+ white-space: nowrap; background: transparent; border: none; border-radius: 999px; corner-shape: round;
472
+ line-height: inherit; align-items: center; gap: 6px;
464
473
  padding: 1px 8px; display: inline-flex; cursor: pointer; font-variant-numeric: tabular-nums; }
465
474
  .dci-stat-pill svg { flex: none; width: 14px; height: 14px; }
466
- .dci-stat-pill:hover { background: var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 8%, transparent));
475
+ .dci-stat-pill:hover, .dci-stat-pill[aria-expanded="true"] { background: var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 8%, transparent));
467
476
  color: var(--dsw-alias-label-secondary, inherit); }
468
477
  .dci-stat-label { text-overflow: ellipsis; min-width: 0; overflow: hidden; }
469
478
  .dci-stat-sep { color: var(--dsw-alias-separator-primary, currentColor); margin: 0 6px; }
470
479
 
471
480
  /* ===== 上下文小圈(官方 JObwrW 复刻:双环仪表 + 分段面板)
472
481
  0.1.7-rc.2 起官方把它挪进卡片下方 dock 行(stats 胶囊之后),trigger 变为「圈+百分比」药丸 ===== */
473
- .dci-gauge-root { flex: none; display: inline-flex; position: relative; }
482
+ .dci-gauge-root { flex: none; display: inline-flex; }
474
483
  .dci-gauge-trigger { border-radius: var(--dsw-radius-sm, 8px); color: var(--dsw-alias-label-tertiary, currentColor);
475
- font-family: inherit; font-size: 13px; font-variant-numeric: tabular-nums; line-height: 20px; white-space: nowrap;
484
+ font-family: inherit; font-size: var(--dsh-content-font-size-secondary, 13px); font-variant-numeric: tabular-nums;
485
+ line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); white-space: nowrap;
476
486
  cursor: pointer; background: transparent; border: none; flex: none; align-items: center; gap: 6px; padding: 1px 8px; display: inline-flex; }
477
487
  .dci-gauge-trigger:hover, .dci-gauge-trigger[aria-expanded="true"] { background: var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 8%, transparent));
478
488
  color: var(--dsw-alias-label-secondary, inherit); }
479
489
  .dci-gauge-track { fill: none; stroke: var(--dsw-alias-border-l3, currentColor); stroke-width: 2px; }
480
490
  .dci-gauge-fill { fill: none; stroke: var(--dsw-alias-label-tertiary, currentColor); stroke-width: 2px; stroke-linecap: round; }
481
- .dci-gauge-panel { z-index: 1100; box-sizing: border-box; background: var(--dsw-menu-surface-fill, var(--dsw-specific-menu, rgba(30, 30, 34, 0.98)));
491
+ /* 面板 portal 到 body + fixed,left/top 由 useAnchoredPanel(官方 useAnchoredPosition
492
+ 移植:面板左缘对齐锚点左缘、面板底距锚点顶 8px、视口 margin 12 夹取)下发。 */
493
+ .dci-gauge-panel { z-index: 1100; box-sizing: border-box; background: var(--dsw-specific-menu, var(--dsw-menu-surface-fill, rgba(30, 30, 34, 0.98)));
482
494
  backdrop-filter: var(--dsw-menu-backdrop-filter, none);
483
495
  --dsw-elevation-stroke-color: var(--dsw-alias-border-l1, transparent); width: min(264px, 100vw - 24px);
484
496
  box-shadow: var(--dsw-elevation-prominent, 0 10px 34px rgba(0, 0, 0, 0.35));
485
497
  color: var(--dsw-alias-label-secondary, inherit); cursor: default; border: 0; border-radius: var(--dsw-radius-lg, 12px); padding: 12px;
486
- font-size: 12px; line-height: 20px; position: absolute; bottom: calc(100% + 8px); right: 0; }
498
+ font-size: 12px; line-height: 20px; position: fixed; }
487
499
  .dci-gauge-header { align-items: center; gap: 6px; display: flex; }
488
500
  .dci-gauge-figures { font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-primary, inherit); margin-left: auto; font-weight: 500; }
489
501
  .dci-gauge-percent { color: var(--dsw-alias-label-primary, inherit); font-weight: 500; }
490
502
  .dci-gauge-headline { color: var(--dsw-alias-label-tertiary, currentColor); }
491
503
  .dci-gauge-bar { background: var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 8%, transparent));
492
- border-radius: 999px; gap: 1px; height: 4px; margin: 10px 0 12px; display: flex; overflow: hidden; }
504
+ border-radius: 999px; corner-shape: round; gap: 1px; height: 4px; margin: 10px 0 12px; display: flex; overflow: hidden; }
493
505
  .dci-gauge-segment { background: var(--meter-tint, var(--dsw-alias-label-tertiary, currentColor)); border-radius: 1px;
494
506
  flex: 0 0 auto; min-width: 2px; height: 100%; }
495
507
  .dci-gauge-swatch { background: var(--meter-tint, currentColor); vertical-align: baseline; border-radius: 2px;
@@ -569,13 +581,11 @@ button.dci-attach-body { color: inherit; font: inherit; text-align: left; backgr
569
581
  border: none; justify-content: center; align-items: center; padding: 6px; display: inline-flex; }
570
582
  .dci-umsg-action svg { width: calc(15px + var(--dsh-content-font-delta, 0px)); height: calc(15px + var(--dsh-content-font-delta, 0px)); }
571
583
  .dci-umsg-action:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); }
572
- /* 悬停显形(官方同款 data 属性面:chat 根的 data-actions-reveal 与 flowItem 的
573
- data-chat-flow-kind 都在宿主 DOM 上,这里只换目标类)。 */
584
+ /* 悬停显形(官方 MessageItem.module.css 同款):data-actions-reveal 挂在 assistant 的
585
+ turn-tail 根上、不在用户消息的祖先链里,用户消息的实际驱动只有 flowItem 的
586
+ data-chat-flow-kind 兄弟关系(后一条 user/steering 存在即隐藏,悬停/聚焦显形)。 */
574
587
  @media (hover: hover) {
575
- [data-actions-reveal="hover"] .dci-umsg-actions,
576
588
  :is([data-chat-flow-kind="user"], [data-chat-flow-kind="steering"]):has(~ :is([data-chat-flow-kind="user"], [data-chat-flow-kind="steering"])) .dci-umsg-actions { opacity: 0; transition: opacity 80ms; }
577
- [data-actions-reveal="hover"]:hover .dci-umsg-actions,
578
- [data-actions-reveal="hover"]:focus-within .dci-umsg-actions,
579
589
  :is([data-chat-flow-kind="user"], [data-chat-flow-kind="steering"]):hover .dci-umsg-actions,
580
590
  :is([data-chat-flow-kind="user"], [data-chat-flow-kind="steering"]):focus-within .dci-umsg-actions { opacity: 1; }
581
591
  }
@@ -658,9 +668,15 @@ button.dci-umsg-chip:focus-visible { box-shadow: 0 0 0 2px var(--dsw-focus-ring-
658
668
  `;
659
669
 
660
670
  export function injectStyles(): void {
661
- if (document.getElementById(STYLE_ID)) return;
662
- const el = document.createElement('style');
663
- el.id = STYLE_ID;
664
- el.textContent = CSS;
665
- document.head.appendChild(el);
671
+ const el = document.getElementById(STYLE_ID);
672
+ // 已有本插件样式(宿主热替换/原地更新插件后重 apply)时按内容原位刷新:
673
+ // 旧 <style> 还挂着会让新版新增的类(如 dci-umsg-*)永远注入不进去。
674
+ if (el) {
675
+ if (el.textContent !== CSS) el.textContent = CSS;
676
+ return;
677
+ }
678
+ const tag = document.createElement('style');
679
+ tag.id = STYLE_ID;
680
+ tag.textContent = CSS;
681
+ document.head.appendChild(tag);
666
682
  }