@roaming-ai/dsh-group-chat 0.2.2 → 0.3.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 (59) hide show
  1. package/README.md +3 -2
  2. package/lib/client.js +984 -296
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +524 -52
  5. package/lib/types/client/components/Bubble.d.ts +6 -3
  6. package/lib/types/client/components/ChatPanel.d.ts +1 -0
  7. package/lib/types/client/components/ConstraintList.d.ts +11 -0
  8. package/lib/types/client/components/FailCard.d.ts +9 -0
  9. package/lib/types/client/components/Fold.d.ts +19 -0
  10. package/lib/types/client/components/HoverTip.d.ts +22 -0
  11. package/lib/types/client/components/MessageFlow.d.ts +1 -0
  12. package/lib/types/client/components/MsgActions.d.ts +14 -0
  13. package/lib/types/client/hooks/useComposer.d.ts +6 -1
  14. package/lib/types/client/lib/composer-draft.d.ts +17 -0
  15. package/lib/types/core/constraints.d.ts +61 -0
  16. package/lib/types/core/errors.d.ts +54 -0
  17. package/lib/types/core/types.d.ts +22 -0
  18. package/lib/types/host/api/actions.d.ts +1 -1
  19. package/lib/types/host/engine/conversation.d.ts +5 -3
  20. package/lib/types/host/engine/fold.d.ts +17 -0
  21. package/lib/types/host/engine/index.d.ts +1 -0
  22. package/lib/types/host/engine/retitle.d.ts +5 -5
  23. package/lib/types/host/service.d.ts +1 -1
  24. package/lib/types/host/state.d.ts +2 -0
  25. package/lib/types/index.d.ts +2 -0
  26. package/package.json +1 -1
  27. package/src/client/GroupChatPanel.tsx +18 -3
  28. package/src/client/components/AsidePanel.tsx +10 -8
  29. package/src/client/components/Bubble.tsx +45 -18
  30. package/src/client/components/ChatPanel.tsx +17 -12
  31. package/src/client/components/Composer.tsx +26 -20
  32. package/src/client/components/ConstraintList.tsx +69 -0
  33. package/src/client/components/FailCard.tsx +49 -0
  34. package/src/client/components/Fold.tsx +72 -0
  35. package/src/client/components/HoverTip.tsx +134 -0
  36. package/src/client/components/MessageFlow.tsx +77 -54
  37. package/src/client/components/MsgActions.tsx +85 -0
  38. package/src/client/components/NavPanel.tsx +6 -1
  39. package/src/client/components/ThinkRow.tsx +7 -3
  40. package/src/client/components/ToolRow.tsx +7 -3
  41. package/src/client/hooks/useComposer.ts +40 -3
  42. package/src/client/hooks/useGroupChatState.ts +5 -4
  43. package/src/client/lib/composer-draft.ts +47 -0
  44. package/src/client/lib/styles.ts +73 -12
  45. package/src/client/react-dom-shim.d.ts +2 -0
  46. package/src/core/constraints.ts +210 -0
  47. package/src/core/errors.ts +184 -0
  48. package/src/core/json.ts +1 -0
  49. package/src/core/types.ts +28 -3
  50. package/src/host/api/actions.ts +35 -1
  51. package/src/host/broadcast.ts +3 -3
  52. package/src/host/engine/conversation.ts +103 -38
  53. package/src/host/engine/fold.ts +114 -0
  54. package/src/host/engine/index.ts +1 -0
  55. package/src/host/engine/retitle.ts +16 -11
  56. package/src/host/persistence/persistence.ts +18 -1
  57. package/src/host/service.ts +1 -1
  58. package/src/host/state.ts +5 -4
  59. package/src/index.ts +2 -0
@@ -10,23 +10,40 @@
10
10
 
11
11
  import { memo, type ReactNode } from 'react'
12
12
  import { P } from '../lib/ui.ts'
13
- import { fmtTime, MD_LABELS, type ClientSnapshot, type SnapshotRole } from '../lib/model.ts'
13
+ import { classifySpeakFailure, formatSpeakFailureCopy, isSpeakFailure } from '../../core/errors.ts'
14
+ import { fmtTime, MD_LABELS, type SnapshotRole } from '../lib/model.ts'
15
+ import type { SnapshotMessage } from '../lib/model.ts'
14
16
  import { ThinkRow } from './ThinkRow.tsx'
15
17
  import { ToolRow } from './ToolRow.tsx'
18
+ import { FailCard } from './FailCard.tsx'
19
+ import { MsgActions } from './MsgActions.tsx'
16
20
 
17
21
  export interface BubbleProps {
18
- m: ClientSnapshot['messages'][number]
22
+ m: SnapshotMessage
19
23
  /** 父级解析好的发言角色(user/system 消息为 null)——避免 Bubble 依赖 snap identity。 */
20
24
  role: SnapshotRole | null
25
+ busy?: boolean
26
+ onRetry?: (messageId: string) => void
21
27
  }
22
28
 
23
- function BubbleInner({ m, role }: BubbleProps): ReactNode {
29
+ function BubbleInner({ m, role, busy, onRetry }: BubbleProps): ReactNode {
24
30
  const isUser = m.speaker === 'user'
25
- const isSys = m.speaker === 'system'
26
- const name = isUser ? '' : isSys ? '系统' : role ? role.name : '成员'
27
- if (isSys) return <div className={'dsgc-sysmsg' + (m.error ? ' err' : '')}>{m.text}</div>
31
+ const isFail = isSpeakFailure(m)
32
+ const isSys = m.speaker === 'system' && !isFail && !role
33
+ const name = isUser ? '' : role ? role.name : isSys ? '系统' : '成员'
34
+ if (isSys) return <div className="dsgc-sysmsg">{m.text}</div>
35
+
36
+ const retryTitle = !role
37
+ ? '失败角色已不存在,无法重试'
38
+ : !role.enabled
39
+ ? '该角色已停用,无法重试'
40
+ : busy
41
+ ? '已有对话进行中,请先停止'
42
+ : '重试该角色发言'
43
+ const copyText = isFail ? formatSpeakFailureCopy(classifySpeakFailure(m.text)) : (m.text || '')
44
+
28
45
  return (
29
- <div className={'dsgc-msg' + (isUser ? ' mine' : '')}>
46
+ <div className={'dsgc-msg' + (isUser ? ' mine' : '') + (isFail ? ' fail' : '')}>
30
47
  <div
31
48
  className={'dsgc-avatar' + (isUser ? ' mine' : '')}
32
49
  style={isUser || !role ? undefined : { border: '2px solid ' + (role.color || '#888') }}
@@ -39,15 +56,24 @@ function BubbleInner({ m, role }: BubbleProps): ReactNode {
39
56
  {m.model ? <span className="dsgc-msgmodel" title={m.model}>{m.model}</span> : null}
40
57
  {m.ts ? <span className="dsgc-msgtime">{fmtTime(m.ts)}</span> : null}
41
58
  </div>
42
- {isUser
43
- ? <div className="dsgc-msgtext">{m.text}</div>
44
- : (
45
- <div className="dsgc-msgtext">
46
- {m.reasoning ? <ThinkRow text={m.reasoning} /> : null}
47
- {(Array.isArray(m.toolCalls) ? m.toolCalls : []).map((c, i) => <ToolRow key={'tc' + i} c={c} />)}
48
- <P.MarkdownText text={m.text || '(无内容)'} labels={MD_LABELS} />
49
- </div>
50
- )}
59
+ {isFail
60
+ ? <FailCard raw={m.text} />
61
+ : isUser
62
+ ? <div className="dsgc-msgtext">{m.text}</div>
63
+ : (
64
+ <div className="dsgc-msgtext">
65
+ {m.reasoning ? <ThinkRow text={m.reasoning} /> : null}
66
+ {(Array.isArray(m.toolCalls) ? m.toolCalls : []).map((c, i) => <ToolRow key={'tc' + i} c={c} />)}
67
+ <P.MarkdownText text={m.text || '(无内容)'} labels={MD_LABELS} />
68
+ </div>
69
+ )}
70
+ <MsgActions
71
+ copyText={copyText}
72
+ always={isFail}
73
+ onRetry={isFail && onRetry ? () => { onRetry(m.id) } : undefined}
74
+ retryDisabled={busy || !role || !role.enabled}
75
+ retryTitle={retryTitle}
76
+ />
51
77
  </div>
52
78
  </div>
53
79
  )
@@ -55,10 +81,11 @@ function BubbleInner({ m, role }: BubbleProps): ReactNode {
55
81
 
56
82
  /** 渲染相关字段的值比较(消息不可变;角色仅名/色参与渲染)。 */
57
83
  function bubblePropsEqual(a: BubbleProps, b: BubbleProps): boolean {
84
+ if (a.busy !== b.busy || (a.onRetry == null) !== (b.onRetry == null)) return false
58
85
  const x = a.m
59
86
  const y = b.m
60
87
  if (x !== y) {
61
- if (x.id !== y.id || x.speaker !== y.speaker || x.text !== y.text || x.reasoning !== y.reasoning || x.model !== y.model || x.error !== y.error || x.ts !== y.ts) return false
88
+ if (x.id !== y.id || x.speaker !== y.speaker || x.text !== y.text || x.reasoning !== y.reasoning || x.model !== y.model || x.error !== y.error || x.failedRoleId !== y.failedRoleId || x.ts !== y.ts) return false
62
89
  const ta = Array.isArray(x.toolCalls) ? x.toolCalls : []
63
90
  const tb = Array.isArray(y.toolCalls) ? y.toolCalls : []
64
91
  if (ta.length !== tb.length) return false
@@ -70,7 +97,7 @@ function bubblePropsEqual(a: BubbleProps, b: BubbleProps): boolean {
70
97
  }
71
98
  const ra = a.role
72
99
  const rb = b.role
73
- return (ra ? ra.name + '\u0000' + (ra.color || '') : '') === (rb ? rb.name + '\u0000' + (rb.color || '') : '')
100
+ return (ra ? ra.id + '\u0000' + ra.name + '\u0000' + (ra.color || '') + '\u0000' + (ra.enabled ? '1' : '0') : '') === (rb ? rb.id + '\u0000' + rb.name + '\u0000' + (rb.color || '') + '\u0000' + (rb.enabled ? '1' : '0') : '')
74
101
  }
75
102
 
76
103
  export const Bubble = memo(BubbleInner, bubblePropsEqual)
@@ -54,6 +54,7 @@ interface ChatPanelProps {
54
54
  action: (payload: Record<string, unknown>) => Promise<unknown>
55
55
  mutate: (args: Record<string, unknown>) => Promise<unknown>
56
56
  setMention: (val: AtToken | null) => void
57
+ onRetrySpeak: (messageId: string) => void
57
58
  }
58
59
 
59
60
  export function ChatPanel(props: ChatPanelProps): ReactNode {
@@ -101,6 +102,7 @@ export function ChatPanel(props: ChatPanelProps): ReactNode {
101
102
  action,
102
103
  mutate,
103
104
  setMention,
105
+ onRetrySpeak,
104
106
  } = props
105
107
 
106
108
  const commitTopic = (): void => {
@@ -115,6 +117,7 @@ export function ChatPanel(props: ChatPanelProps): ReactNode {
115
117
  busyNow={busyNow}
116
118
  msgById={msgById}
117
119
  action={action}
120
+ onRetrySpeak={onRetrySpeak}
118
121
  />
119
122
  )
120
123
 
@@ -143,18 +146,20 @@ export function ChatPanel(props: ChatPanelProps): ReactNode {
143
146
  </button>
144
147
 
145
148
  <div className="dsgc-chathead">
146
- {sess ? <span className="dsgc-sess-title" title={'当前会话:' + sess.name}>{sess.name}</span> : null}
147
- <input
148
- className="dsgc-topic"
149
- value={topicDraft === null ? (sess ? sess.topic : '') : topicDraft}
150
- placeholder="设置本会话主题(可选)…"
151
- onChange={(e) => { setTopicDraft(e.target.value) }}
152
- onBlur={commitTopic}
153
- onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
154
- />
155
- <P.Button variant="ghost" size="sm" className="dsgc-clearbtn" title="清空当前会话的消息记录" onClick={() => { if (sess) setConfirmClear(true) }}>
156
- 清空
157
- </P.Button>
149
+ <div className="dsgc-chathead-row">
150
+ {sess ? <span className="dsgc-sess-title" title={'当前会话:' + sess.name}>{sess.name}</span> : null}
151
+ <input
152
+ className="dsgc-topic"
153
+ value={topicDraft === null ? (sess ? sess.topic : '') : topicDraft}
154
+ placeholder="设置本会话主题(可选)…"
155
+ onChange={(e) => { setTopicDraft(e.target.value) }}
156
+ onBlur={commitTopic}
157
+ onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
158
+ />
159
+ <P.Button variant="ghost" size="sm" className="dsgc-clearbtn" title="清空当前会话的消息记录" onClick={() => { if (sess) setConfirmClear(true) }}>
160
+ 清空
161
+ </P.Button>
162
+ </div>
158
163
  </div>
159
164
 
160
165
  <div className="dsgc-msgs" ref={scrollRef} onScroll={onMsgsScroll}>
@@ -6,9 +6,13 @@
6
6
  import type { ReactNode, KeyboardEvent as ReactKeyboardEvent, ClipboardEvent as ReactClipboardEvent, DragEvent as ReactDragEvent } from 'react'
7
7
  import { Icon, P } from '../lib/ui.ts'
8
8
  import { PermissionSelect } from './PermissionSelect.tsx'
9
+ import { HoverTip } from './HoverTip.tsx'
9
10
  import { escapeRegExp, type ClientSnapshot, type SnapshotRole } from '../lib/model.ts'
10
11
  import type { AtToken } from '../../shared/file-mention-grammar.ts'
11
12
 
13
+ /** 轮数控件 hover:宿主 Tooltip 三行说明(pre-line)。 */
14
+ const ROUNDS_HINT = '一轮 = 参与角色各说一次。\n要他们自己互相反驳、你不插话时再加轮。\n要边看边插话,就留 1,再点发送。'
15
+
12
16
  interface ComposerProps {
13
17
  snap: ClientSnapshot
14
18
  sess: ClientSnapshot['sessions'][number] | null
@@ -102,23 +106,25 @@ export function Composer(props: ComposerProps): ReactNode {
102
106
  {err ? <div className="dsgc-err">{err}</div> : null}
103
107
  <div className="dsgc-parts">
104
108
  <span className="dsgc-partslabel">参与角色</span>
105
- {mentionedRoles.length
106
- ? <span className="dsgc-partslabel">已 @ {mentionedRoles.map((r) => r.name).join('、')}(本轮仅被点名成员发言)</span>
107
- : enabledRoles.length
108
- ? enabledRoles.map((r) => (
109
- <button
110
- key={r.id}
111
- type="button"
112
- className={'dsgc-partchip' + (participants.includes(r.id) ? ' on' : '')}
113
- onClick={() => togglePart(r.id)}
114
- disabled={busyNow}
115
- title={busyNow ? '对话进行中,暂停调整' : '点击切换本轮是否参与'}
116
- >
117
- <span className="dsgc-chipdot" style={{ background: r.color || '#888' }} />
118
- {r.name}
119
- </button>
120
- ))
121
- : <span className="dsgc-hint">还没有启用的角色,请在右侧添加</span>}
109
+ <div className="dsgc-partlist">
110
+ {mentionedRoles.length
111
+ ? <span className="dsgc-partslabel">已 @ {mentionedRoles.map((r) => r.name).join('、')}(本轮仅被点名成员发言)</span>
112
+ : enabledRoles.length
113
+ ? enabledRoles.map((r) => (
114
+ <button
115
+ key={r.id}
116
+ type="button"
117
+ className={'dsgc-partchip' + (participants.includes(r.id) ? ' on' : '')}
118
+ onClick={() => togglePart(r.id)}
119
+ disabled={busyNow}
120
+ title={busyNow ? '对话进行中,暂停调整' : '点击切换本轮是否参与'}
121
+ >
122
+ <span className="dsgc-chipdot" style={{ background: r.color || '#888' }} />
123
+ {r.name}
124
+ </button>
125
+ ))
126
+ : <span className="dsgc-hint">还没有启用的角色,请在右侧添加</span>}
127
+ </div>
122
128
  </div>
123
129
  <div className="dsgc-card">
124
130
  <div className="dsgc-mentionwrap">
@@ -210,16 +216,16 @@ export function Composer(props: ComposerProps): ReactNode {
210
216
  onSelect={(tier) => { void mutate({ op: 'setPermissionTier', groupId: group.id, tier }) }}
211
217
  />
212
218
  <span style={{ flex: 1 }} />
213
- <div className="dsgc-rounds" title="自由讨论的轮数(1–10):一轮 = 全体参与角色按顺序各发言一次">
219
+ <HoverTip label={ROUNDS_HINT} side="top" delayMs={500} maxWidth={280} className="dsgc-rounds">
214
220
  <button type="button" className="dsgc-roundbtn" aria-label="减少轮数" disabled={rounds <= 1} onClick={() => { setRounds(Math.max(1, rounds - 1)) }}>
215
221
  {Icon(P.IconChevronLeftOutline14, 12)}
216
222
  </button>
217
- <span className="dsgc-roundnum" title="轮数">{rounds}</span>
223
+ <span className="dsgc-roundnum">{rounds}</span>
218
224
  <button type="button" className="dsgc-roundbtn" aria-label="增加轮数" disabled={rounds >= 10} onClick={() => { setRounds(Math.min(10, rounds + 1)) }}>
219
225
  {Icon(P.IconChevronRightOutline14, 12)}
220
226
  </button>
221
227
  <span style={{ padding: '0 6px 0 2px' }}>轮</span>
222
- </div>
228
+ </HoverTip>
223
229
  {busyNow
224
230
  ? (
225
231
  <P.Button variant="outline" className="dsgc-stopbtn" onClick={() => { void stopRun() }}>
@@ -0,0 +1,69 @@
1
+ /**
2
+ * 会话流折点处:只读结论备忘卡(超过 4 条默认露 3 条)。
3
+ * @module dsh-group-chat/client/components
4
+ */
5
+
6
+ import { useState, type ReactNode } from 'react'
7
+ import type { SessionConstraint } from '../../core/types.ts'
8
+ import { ClipWell, Fold } from './Fold.tsx'
9
+
10
+ const KIND_LABEL: Record<SessionConstraint['kind'], string> = {
11
+ decided: '已定',
12
+ rejected: '否决',
13
+ open: '未决',
14
+ }
15
+
16
+ interface ConstraintListProps {
17
+ items: SessionConstraint[]
18
+ }
19
+
20
+ function ConstraintRow(props: { item: SessionConstraint }): ReactNode {
21
+ const { item } = props
22
+ return (
23
+ <div className="dsgc-constraint">
24
+ <span className={'dsgc-ckind ' + item.kind}>{KIND_LABEL[item.kind]}</span>
25
+ <span className="dsgc-ctext">{item.text}</span>
26
+ </div>
27
+ )
28
+ }
29
+
30
+ export function ConstraintList(props: ConstraintListProps): ReactNode {
31
+ const { items } = props
32
+ const [open, setOpen] = useState(false)
33
+ if (!items.length) return null
34
+ const overflow = items.length > 4
35
+ const head = overflow ? items.slice(0, 3) : items
36
+ const extra = overflow ? items.slice(3) : []
37
+ const rest = items.length - 3
38
+
39
+ return (
40
+ <section className="dsgc-constraints" aria-labelledby="dsgc-constraints-title">
41
+ <div className="dsgc-chead">
42
+ <h2 id="dsgc-constraints-title" className="dsgc-ctitle">结论备忘</h2>
43
+ <p className="dsgc-cdesc">窗口外消息折成的已定 / 否决 / 未决,供后续角色接着用。</p>
44
+ </div>
45
+ <ClipWell maxHeight={160} className="dsgc-clist" watch={open}>
46
+ {head.map((c, i) => <ConstraintRow key={i} item={c} />)}
47
+ {overflow
48
+ ? (
49
+ <Fold open={open}>
50
+ {extra.map((c, i) => <ConstraintRow key={i + 3} item={c} />)}
51
+ </Fold>
52
+ )
53
+ : null}
54
+ </ClipWell>
55
+ {overflow
56
+ ? (
57
+ <button
58
+ type="button"
59
+ className="dsgc-cmore"
60
+ onClick={() => { setOpen(!open) }}
61
+ aria-expanded={open}
62
+ >
63
+ {open ? '收起' : '还有 ' + rest + ' 条约束'}
64
+ </button>
65
+ )
66
+ : null}
67
+ </section>
68
+ )
69
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 角色发言失败卡:人话标题 + 可展开原文。操作条由 Bubble 放在气泡外下方。
3
+ * @module dsh-group-chat/client/FailCard
4
+ */
5
+
6
+ import { useState, type ReactNode } from 'react'
7
+ import { Icon, P } from '../lib/ui.ts'
8
+ import { classifySpeakFailure } from '../../core/errors.ts'
9
+ import { ClipWell, Fold } from './Fold.tsx'
10
+
11
+ export interface FailCardProps {
12
+ raw: string
13
+ }
14
+
15
+ export function FailCard(props: FailCardProps): ReactNode {
16
+ const view = classifySpeakFailure(props.raw)
17
+ const [open, setOpen] = useState(false)
18
+
19
+ return (
20
+ <div className="dsgc-fail">
21
+ <div className="dsgc-failhead">
22
+ <span className="dsgc-failicon" aria-hidden="true">{Icon(P.IconWarningOutline16, 14)}</span>
23
+ <div className="dsgc-failcopy">
24
+ <div className="dsgc-failtitle">{view.title}</div>
25
+ {view.detail ? <div className="dsgc-faildetail">{view.detail}</div> : null}
26
+ </div>
27
+ </div>
28
+ {view.raw
29
+ ? (
30
+ <>
31
+ <button
32
+ type="button"
33
+ className={'dsgc-failmore' + (open ? ' open' : '')}
34
+ aria-expanded={open}
35
+ onClick={() => { setOpen((v) => !v) }}
36
+ >
37
+ {open ? '收起原始错误' : '查看原始错误'}
38
+ </button>
39
+ <Fold open={open}>
40
+ <ClipWell maxHeight={220} watch={open}>
41
+ <pre className="dsgc-failraw">{view.raw}</pre>
42
+ </ClipWell>
43
+ </Fold>
44
+ </>
45
+ )
46
+ : null}
47
+ </div>
48
+ )
49
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 会话内折叠:高度 0fr→1fr + 溢出滚动遮罩。思考 / 工具 / 失败原文 / 结论备忘共用。
3
+ * @module dsh-group-chat/client/components
4
+ */
5
+
6
+ import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
7
+
8
+ interface FoldProps {
9
+ open: boolean
10
+ children: ReactNode
11
+ className?: string
12
+ }
13
+
14
+ export function Fold(props: FoldProps): ReactNode {
15
+ const { open, children, className } = props
16
+ return (
17
+ <div className={'dsgc-fold' + (open ? ' open' : '') + (className ? ' ' + className : '')}>
18
+ <div className="dsgc-fold-inner">{children}</div>
19
+ </div>
20
+ )
21
+ }
22
+
23
+ interface ClipWellProps {
24
+ children: ReactNode
25
+ maxHeight: number
26
+ className?: string
27
+ watch?: unknown
28
+ }
29
+
30
+ export function ClipWell(props: ClipWellProps): ReactNode {
31
+ const { children, maxHeight, className, watch } = props
32
+ const [clip, setClip] = useState({ up: false, down: false })
33
+ const ref = useRef<HTMLDivElement>(null)
34
+
35
+ useLayoutEffect(() => {
36
+ const el = ref.current
37
+ if (!el) return
38
+ const measure = (): void => {
39
+ if (el.clientHeight < 2) {
40
+ setClip((cur) => cur.up || cur.down ? { up: false, down: false } : cur)
41
+ return
42
+ }
43
+ const max = el.scrollHeight - el.clientHeight
44
+ const up = el.scrollTop > 1
45
+ const down = max > 1 && el.scrollTop < max - 1
46
+ setClip((cur) => cur.up === up && cur.down === down ? cur : { up, down })
47
+ }
48
+ measure()
49
+ const ro = new ResizeObserver(measure)
50
+ ro.observe(el)
51
+ for (const child of el.children) ro.observe(child)
52
+ el.addEventListener('scroll', measure, { passive: true })
53
+ el.addEventListener('transitionend', measure)
54
+ return () => {
55
+ ro.disconnect()
56
+ el.removeEventListener('scroll', measure)
57
+ el.removeEventListener('transitionend', measure)
58
+ }
59
+ }, [watch])
60
+
61
+ return (
62
+ <div className={'dsgc-clip' + (clip.up ? ' can-up' : '') + (clip.down ? ' can-down' : '')}>
63
+ <div
64
+ className={'dsgc-clip-scroll' + (className ? ' ' + className : '')}
65
+ ref={ref}
66
+ style={{ maxHeight } as CSSProperties}
67
+ >
68
+ {children}
69
+ </div>
70
+ </div>
71
+ )
72
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * 对齐宿主 Tooltip 的 hover 气泡:portal 到 document.body,躲开
3
+ * `.dsgc-root` 的 container-type 把 position:fixed 按容器定位。
4
+ * @module dsh-group-chat/client/components
5
+ */
6
+
7
+ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
8
+ import { createPortal } from 'react-dom'
9
+
10
+ export type HoverTipSide = 'top' | 'right'
11
+
12
+ interface HoverTipProps {
13
+ label: string
14
+ side?: HoverTipSide
15
+ delayMs?: number
16
+ maxWidth?: number
17
+ className?: string
18
+ children: ReactNode
19
+ }
20
+
21
+ interface AnchorBox {
22
+ x: number
23
+ top: number
24
+ bottom: number
25
+ }
26
+
27
+ /**
28
+ * @param props.label 气泡正文(pre-line)
29
+ * @param props.side 默认 right(对齐侧栏会话行);composer 轮数用 top
30
+ * @param props.delayMs hover 延迟,默认 500;键盘 focus 立即出
31
+ */
32
+ export function HoverTip(props: HoverTipProps): ReactNode {
33
+ const { label, side = 'right', delayMs = 500, maxWidth, className, children } = props
34
+ const anchorRef = useRef<HTMLDivElement>(null)
35
+ const bubbleRef = useRef<HTMLSpanElement>(null)
36
+ const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
37
+ const hover = useRef(false)
38
+ const [pos, setPos] = useState<AnchorBox | null>(null)
39
+ const [placement, setPlacement] = useState<'top' | 'bottom' | 'right'>(side)
40
+
41
+ const cancel = (): void => {
42
+ if (showTimer.current === null) return
43
+ clearTimeout(showTimer.current)
44
+ showTimer.current = null
45
+ }
46
+ const show = (): void => {
47
+ const el = anchorRef.current
48
+ if (!el || !label) return
49
+ const r = el.getBoundingClientRect()
50
+ setPlacement(side)
51
+ setPos({
52
+ x: side === 'right' ? r.right + 10 : r.left + r.width / 2,
53
+ top: r.top,
54
+ bottom: r.bottom,
55
+ })
56
+ }
57
+ const hideIfIdle = (): void => {
58
+ if (!hover.current && !(anchorRef.current && anchorRef.current.contains(document.activeElement))) setPos(null)
59
+ }
60
+
61
+ useEffect(() => cancel, [])
62
+ useLayoutEffect(() => {
63
+ if (!pos) return
64
+ const fit = (): void => {
65
+ const el = bubbleRef.current
66
+ if (!el) return
67
+ el.style.left = pos.x + 'px'
68
+ const r = el.getBoundingClientRect()
69
+ let dx = 0
70
+ if (r.right > window.innerWidth - 12) dx = window.innerWidth - 12 - r.right
71
+ if (r.left + dx < 12) dx = 12 - r.left
72
+ el.style.left = pos.x + dx + 'px'
73
+ if (side === 'right') return
74
+ const fitsBelow = pos.bottom + 8 + r.height <= window.innerHeight - 12
75
+ const fitsAbove = pos.top - 8 - r.height >= 12
76
+ if (placement === 'bottom' && !fitsBelow && fitsAbove) setPlacement('top')
77
+ if (placement === 'top' && !fitsAbove && fitsBelow) setPlacement('bottom')
78
+ }
79
+ fit()
80
+ window.addEventListener('resize', fit)
81
+ return () => { window.removeEventListener('resize', fit) }
82
+ }, [pos, placement, label, side])
83
+
84
+ const y = pos === null
85
+ ? 0
86
+ : placement === 'right'
87
+ ? pos.top + (pos.bottom - pos.top) / 2
88
+ : placement === 'top'
89
+ ? pos.top - 8
90
+ : pos.bottom + 8
91
+
92
+ return (
93
+ <>
94
+ <div
95
+ ref={anchorRef}
96
+ className={className}
97
+ onMouseEnter={() => {
98
+ hover.current = true
99
+ cancel()
100
+ if (delayMs <= 0) { show(); return }
101
+ showTimer.current = setTimeout(() => { showTimer.current = null; show() }, delayMs)
102
+ }}
103
+ onMouseLeave={() => {
104
+ hover.current = false
105
+ cancel()
106
+ hideIfIdle()
107
+ }}
108
+ onFocus={() => { cancel(); show() }}
109
+ onBlur={(e) => {
110
+ const next = e.relatedTarget as Node | null
111
+ if (next && anchorRef.current && anchorRef.current.contains(next)) return
112
+ cancel()
113
+ hideIfIdle()
114
+ }}
115
+ >
116
+ {children}
117
+ </div>
118
+ {pos
119
+ ? createPortal(
120
+ <span
121
+ ref={bubbleRef}
122
+ className="dsgc-hovertip"
123
+ data-side={placement}
124
+ role="tooltip"
125
+ style={{ left: pos.x, top: y, ...(maxWidth ? { maxWidth } : {}) }}
126
+ >
127
+ {label}
128
+ </span>,
129
+ document.body,
130
+ )
131
+ : null}
132
+ </>
133
+ )
134
+ }