dsh-mega-chat-nav 0.1.1

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 (192) hide show
  1. package/LICENSE +201 -0
  2. package/README.en.md +149 -0
  3. package/README.md +149 -0
  4. package/cordis.patch.yml +4 -0
  5. package/lib/client/NavOverlay.js +35 -0
  6. package/lib/client/NavRail.js +12 -0
  7. package/lib/client/QuestionNavSettingsTab.js +26 -0
  8. package/lib/client/QuestionNavStrip.js +555 -0
  9. package/lib/client/components/ConfigPanel.js +14 -0
  10. package/lib/client/components/DotRail.js +62 -0
  11. package/lib/client/components/NavOverlay.js +121 -0
  12. package/lib/client/components/NavRail.js +97 -0
  13. package/lib/client/components/RailDispatch.js +22 -0
  14. package/lib/client/components/RailHost.js +79 -0
  15. package/lib/client/components/SearchBox.js +66 -0
  16. package/lib/client/components/SettingsPage.js +46 -0
  17. package/lib/client/components/StandaloneSettings.js +16 -0
  18. package/lib/client/components/codex/CodexRail.js +139 -0
  19. package/lib/client/components/deepseek/DeepseekRail.js +264 -0
  20. package/lib/client/components/minimal/MinimalRail.js +135 -0
  21. package/lib/client/components/minimal/cards.js +32 -0
  22. package/lib/client/components/minimal/dot.js +11 -0
  23. package/lib/client/components/minimal/hint.js +30 -0
  24. package/lib/client/components/minimal/paging.js +5 -0
  25. package/lib/client/components/minimal/quick-settings.js +28 -0
  26. package/lib/client/components/minimal/search.js +67 -0
  27. package/lib/client/components/minimal/useRail.js +716 -0
  28. package/lib/client/components/rail/cards.js +32 -0
  29. package/lib/client/components/rail/hint.js +30 -0
  30. package/lib/client/components/rail/paging.js +5 -0
  31. package/lib/client/components/rail/quick-settings.js +28 -0
  32. package/lib/client/components/rail/search.js +76 -0
  33. package/lib/client/components/rail/useRail.js +845 -0
  34. package/lib/client/components/settings/SettingsPage.js +46 -0
  35. package/lib/client/components/settings/SettingsTab.js +17 -0
  36. package/lib/client/components/settings/StandaloneSettings.js +16 -0
  37. package/lib/client/components/shared/Cascade.js +26 -0
  38. package/lib/client/components/shared/ConfigPanel.js +27 -0
  39. package/lib/client/components/shared/Dot.js +8 -0
  40. package/lib/client/components/shared/Hint.js +5 -0
  41. package/lib/client/components/shared/PagingButton.js +5 -0
  42. package/lib/client/components/shared/RailShell.js +78 -0
  43. package/lib/client/components/shared/SearchBox.js +63 -0
  44. package/lib/client/components/shared/hooks.js +427 -0
  45. package/lib/client/components/shared/types.js +1 -0
  46. package/lib/client/config.js +1 -0
  47. package/lib/client/engines/jump.js +34 -0
  48. package/lib/client/engines/useAtBottom.js +25 -0
  49. package/lib/client/engines/useJump.js +40 -0
  50. package/lib/client/engines/useScrollSpy.js +88 -0
  51. package/lib/client/hooks.js +11 -0
  52. package/lib/client/index.js +312 -0
  53. package/lib/client/jump.js +255 -0
  54. package/lib/client/locales.js +142 -0
  55. package/lib/client/pure.js +61 -0
  56. package/lib/client/settings.js +369 -0
  57. package/lib/client/virtual/VirtualList.js +21 -0
  58. package/lib/client.js +3213 -0
  59. package/lib/compat.js +145 -0
  60. package/lib/config.js +37 -0
  61. package/lib/core/active-dot.js +17 -0
  62. package/lib/core/align.js +9 -0
  63. package/lib/core/config.js +37 -0
  64. package/lib/core/focus.js +38 -0
  65. package/lib/core/jump.js +98 -0
  66. package/lib/core/logic/active-dot.js +16 -0
  67. package/lib/core/logic/focus.js +34 -0
  68. package/lib/core/logic/jump.js +188 -0
  69. package/lib/core/logic/nodes.js +59 -0
  70. package/lib/core/model/nav-item.js +1 -0
  71. package/lib/core/model/turns.js +73 -0
  72. package/lib/core/nav-item.js +1 -0
  73. package/lib/core/nodes.js +57 -0
  74. package/lib/core/page-size.js +7 -0
  75. package/lib/core/question-entry.js +1 -0
  76. package/lib/core/time.js +23 -0
  77. package/lib/core/turn-dots.js +85 -0
  78. package/lib/core/utils/measure.js +24 -0
  79. package/lib/core/utils/time.js +26 -0
  80. package/lib/index.js +64 -0
  81. package/lib/nav-item.js +1 -0
  82. package/lib/nodes.js +59 -0
  83. package/lib/projection.js +126 -0
  84. package/lib/schema.js +26 -0
  85. package/lib/search.js +285 -0
  86. package/lib/settings.js +89 -0
  87. package/lib/types/client/NavOverlay.d.ts +18 -0
  88. package/lib/types/client/NavRail.d.ts +7 -0
  89. package/lib/types/client/QuestionNavSettingsTab.d.ts +16 -0
  90. package/lib/types/client/QuestionNavStrip.d.ts +48 -0
  91. package/lib/types/client/components/ConfigPanel.d.ts +14 -0
  92. package/lib/types/client/components/DotRail.d.ts +15 -0
  93. package/lib/types/client/components/NavOverlay.d.ts +30 -0
  94. package/lib/types/client/components/NavRail.d.ts +16 -0
  95. package/lib/types/client/components/RailDispatch.d.ts +8 -0
  96. package/lib/types/client/components/RailHost.d.ts +27 -0
  97. package/lib/types/client/components/SearchBox.d.ts +9 -0
  98. package/lib/types/client/components/SettingsPage.d.ts +9 -0
  99. package/lib/types/client/components/StandaloneSettings.d.ts +13 -0
  100. package/lib/types/client/components/codex/CodexRail.d.ts +3 -0
  101. package/lib/types/client/components/deepseek/DeepseekRail.d.ts +3 -0
  102. package/lib/types/client/components/minimal/MinimalRail.d.ts +8 -0
  103. package/lib/types/client/components/minimal/cards.d.ts +19 -0
  104. package/lib/types/client/components/minimal/dot.d.ts +14 -0
  105. package/lib/types/client/components/minimal/hint.d.ts +11 -0
  106. package/lib/types/client/components/minimal/paging.d.ts +10 -0
  107. package/lib/types/client/components/minimal/quick-settings.d.ts +8 -0
  108. package/lib/types/client/components/minimal/search.d.ts +27 -0
  109. package/lib/types/client/components/minimal/useRail.d.ts +90 -0
  110. package/lib/types/client/components/rail/cards.d.ts +19 -0
  111. package/lib/types/client/components/rail/hint.d.ts +11 -0
  112. package/lib/types/client/components/rail/paging.d.ts +10 -0
  113. package/lib/types/client/components/rail/quick-settings.d.ts +8 -0
  114. package/lib/types/client/components/rail/search.d.ts +34 -0
  115. package/lib/types/client/components/rail/useRail.d.ts +132 -0
  116. package/lib/types/client/components/settings/SettingsPage.d.ts +9 -0
  117. package/lib/types/client/components/settings/SettingsTab.d.ts +6 -0
  118. package/lib/types/client/components/settings/StandaloneSettings.d.ts +13 -0
  119. package/lib/types/client/components/shared/Cascade.d.ts +14 -0
  120. package/lib/types/client/components/shared/ConfigPanel.d.ts +8 -0
  121. package/lib/types/client/components/shared/Dot.d.ts +17 -0
  122. package/lib/types/client/components/shared/Hint.d.ts +6 -0
  123. package/lib/types/client/components/shared/PagingButton.d.ts +10 -0
  124. package/lib/types/client/components/shared/RailShell.d.ts +43 -0
  125. package/lib/types/client/components/shared/SearchBox.d.ts +18 -0
  126. package/lib/types/client/components/shared/hooks.d.ts +64 -0
  127. package/lib/types/client/components/shared/types.d.ts +101 -0
  128. package/lib/types/client/config.d.ts +9 -0
  129. package/lib/types/client/engines/jump.d.ts +17 -0
  130. package/lib/types/client/engines/useAtBottom.d.ts +1 -0
  131. package/lib/types/client/engines/useJump.d.ts +16 -0
  132. package/lib/types/client/engines/useScrollSpy.d.ts +12 -0
  133. package/lib/types/client/hooks.d.ts +7 -0
  134. package/lib/types/client/index.d.ts +31 -0
  135. package/lib/types/client/jump.d.ts +47 -0
  136. package/lib/types/client/locales.d.ts +142 -0
  137. package/lib/types/client/pure.d.ts +21 -0
  138. package/lib/types/client/settings.d.ts +173 -0
  139. package/lib/types/client/virtual/VirtualList.d.ts +15 -0
  140. package/lib/types/compat.d.ts +44 -0
  141. package/lib/types/config.d.ts +44 -0
  142. package/lib/types/core/active-dot.d.ts +15 -0
  143. package/lib/types/core/align.d.ts +10 -0
  144. package/lib/types/core/config.d.ts +44 -0
  145. package/lib/types/core/focus.d.ts +24 -0
  146. package/lib/types/core/jump.d.ts +39 -0
  147. package/lib/types/core/logic/active-dot.d.ts +15 -0
  148. package/lib/types/core/logic/focus.d.ts +22 -0
  149. package/lib/types/core/logic/jump.d.ts +39 -0
  150. package/lib/types/core/logic/nodes.d.ts +48 -0
  151. package/lib/types/core/model/nav-item.d.ts +12 -0
  152. package/lib/types/core/model/turns.d.ts +30 -0
  153. package/lib/types/core/nav-item.d.ts +12 -0
  154. package/lib/types/core/nodes.d.ts +43 -0
  155. package/lib/types/core/page-size.d.ts +8 -0
  156. package/lib/types/core/question-entry.d.ts +12 -0
  157. package/lib/types/core/time.d.ts +4 -0
  158. package/lib/types/core/turn-dots.d.ts +29 -0
  159. package/lib/types/core/utils/measure.d.ts +12 -0
  160. package/lib/types/core/utils/time.d.ts +4 -0
  161. package/lib/types/index.d.ts +4 -0
  162. package/lib/types/nav-item.d.ts +12 -0
  163. package/lib/types/nodes.d.ts +48 -0
  164. package/lib/types/projection.d.ts +71 -0
  165. package/lib/types/schema.d.ts +37 -0
  166. package/lib/types/search.d.ts +42 -0
  167. package/lib/types/settings.d.ts +99 -0
  168. package/package.json +71 -0
  169. package/src/client/components/RailDispatch.tsx +27 -0
  170. package/src/client/components/codex/CodexRail.tsx +348 -0
  171. package/src/client/components/deepseek/DeepseekRail.tsx +522 -0
  172. package/src/client/components/minimal/MinimalRail.tsx +324 -0
  173. package/src/client/components/minimal/dot.tsx +32 -0
  174. package/src/client/components/rail/cards.tsx +102 -0
  175. package/src/client/components/rail/hint.tsx +50 -0
  176. package/src/client/components/rail/paging.tsx +26 -0
  177. package/src/client/components/rail/quick-settings.tsx +130 -0
  178. package/src/client/components/rail/search.tsx +147 -0
  179. package/src/client/components/rail/useRail.ts +906 -0
  180. package/src/client/components/settings/SettingsPage.tsx +198 -0
  181. package/src/client/components/settings/StandaloneSettings.tsx +28 -0
  182. package/src/client/components/shared/types.ts +99 -0
  183. package/src/client/index.ts +388 -0
  184. package/src/client/jump.ts +293 -0
  185. package/src/client/locales.ts +143 -0
  186. package/src/client/settings.ts +482 -0
  187. package/src/client/styles.css +1580 -0
  188. package/src/compat.ts +161 -0
  189. package/src/index.ts +66 -0
  190. package/src/projection.ts +174 -0
  191. package/src/search.ts +291 -0
  192. package/src/settings.ts +169 -0
@@ -0,0 +1,324 @@
1
+ // 简约风格:圆点轨形态——视觉自治(轨道数据/布局/间谍/翻页/卡片能力经 ../rail 共享,
2
+ // 本目录仅保留风格专属部件:MinimalRail 装配 + dot 圆点按钮)
3
+ import type { ReactNode } from 'react'
4
+ import { useEffect, useRef, useState } from 'react'
5
+ import { useMarkerData, usePinning, useReadingSpy, useBandPaging, useCardPreview, useFailureNotice, useLoadingNotice, useFavorites, useMobileMode, useMobileSearchButton, useNavSettings, clockText, markerScale, markerTier, ROW_HEIGHT, usePeekState, useClosePopoversOnOutside } from '../rail/useRail.ts'
6
+ import { Dot } from './dot.tsx'
7
+ import { Cards } from '../rail/cards.tsx'
8
+ import { PagingButton } from '../rail/paging.tsx'
9
+ import { Hint } from '../rail/hint.tsx'
10
+ import { SearchBox } from '../rail/search.tsx'
11
+ import { QuickSettings } from '../rail/quick-settings.tsx'
12
+ import { STYLE_CAPABILITIES, BAND_HEIGHT_PX } from '../../settings.ts'
13
+
14
+
15
+ import type { RailMarker } from '../shared/types.ts'
16
+ import type { NavInjected, SessionListLike, Translate } from '../shared/types.ts'
17
+
18
+ export interface MinimalRailProps {
19
+ injected: NavInjected | undefined
20
+ useSessions?: <S>(selector: (s: SessionListLike) => S) => S
21
+ t: Translate
22
+ }
23
+
24
+ export function MinimalRail(props: MinimalRailProps): ReactNode {
25
+ const { injected, useSessions, t } = props
26
+ const panelRef = useRef<HTMLDivElement>(null)
27
+ const listRef = useRef<HTMLDivElement>(null)
28
+ const [jumpingKey, setJumpingKey] = useState<string | null>(null)
29
+ const [configOpen, setConfigOpen] = useState(false)
30
+ const [searchOpen, setSearchOpen] = useState(false)
31
+ const [favFilter, setFavFilter] = useState(false)
32
+ const [drawerOpen, setDrawerOpen] = useState(false)
33
+ // 鼠标离开导航区域触发一次跟随:仅作 effect 重跑计数(跟随几何复用下方 effect)
34
+ const [followTick, setFollowTick] = useState(0)
35
+ const { showPeek, hidePeek, peekCls } = usePeekState()
36
+ // 上次 hover 命中的圆点 key(同一圆点内 mouseover 高频触发不重复开卡)
37
+ const lastHoverKey = useRef<string | null>(null)
38
+
39
+ // 手机模式(<1024px):仅搜索图标 + 底部半宽搜索抽屉
40
+ const mobile = useMobileMode()
41
+
42
+ // 数据与设置
43
+ const { sessionId, visible, markers } = useMarkerData(injected, useSessions)
44
+ const { favorites, toggle: toggleFavorite } = useFavorites(sessionId)
45
+ const settings = useNavSettings(injected)
46
+ const { style, align, bandHeight, cardCount, cardItems, showPaging } = settings
47
+ useMobileSearchButton(() => setDrawerOpen(true), mobile && markers.length > 0, align, t('search.placeholder'))
48
+
49
+ // 收藏过滤:派生状态——无收藏数据时自动视为未过滤
50
+ // (取消最后一个收藏立即恢复全部列表,不会困在空过滤态)
51
+ const effectiveFilter = favFilter && favorites.size > 0
52
+ const displayMarkers = effectiveFilter
53
+ ? markers.filter((m) => m.members.some((k) => favorites.has(k)))
54
+ : markers
55
+
56
+ // 布局钉位(含导航栏偏移;sessionId 变化 → 校准循环重启,覆盖会话/工作区切换重建)
57
+ usePinning(panelRef, visible, align, settings.railOffset, sessionId)
58
+ const [currentKey, forceKey] = useReadingSpy(visible, displayMarkers)
59
+ const { scrollable, revealed, syncScroll, pageBy, canPageUp, canPageDown } = useBandPaging(listRef, displayMarkers)
60
+
61
+ // 悬浮卡片(风格能力)
62
+ const caps = STYLE_CAPABILITIES[style]
63
+ const focusRadius = Math.floor((cardCount - 1) / 2)
64
+ const { focus, openCards, cancelClear, scheduleClear } = useCardPreview(displayMarkers, align, focusRadius, caps.hoverCards)
65
+
66
+ // 跳转失败提示 / 加载较早记录提示
67
+ const hint = useFailureNotice(t)
68
+ const loading = useLoadingNotice(t)
69
+
70
+ useClosePopoversOnOutside(configOpen, searchOpen, () => {
71
+ setConfigOpen(false)
72
+ setSearchOpen(false)
73
+ })
74
+
75
+ // 激活节点跟随:正文滚动时导航列表平滑居中(点击跳转后不冻结)
76
+ useEffect(() => {
77
+ const list = listRef.current
78
+ if (list === null || currentKey === null) return
79
+ const index = displayMarkers.findIndex((d) => d.key === currentKey)
80
+ if (index < 0) return
81
+ const step = ROW_HEIGHT // 行距 14px(8px 节点 + 6px 间距,与 rail 层几何一致)
82
+ const dotTop = 8 + index * step // 列表上内边距 8px + 节点位置
83
+ const contentHeight = 16 + displayMarkers.length * step - 6
84
+ const maxScroll = Math.max(0, contentHeight - list.clientHeight)
85
+ const target = Math.max(0, Math.min(dotTop - list.clientHeight / 2 + step / 2, maxScroll))
86
+ if (Math.abs(target - list.scrollTop) < 2) return
87
+ list.scrollTo({ top: target, behavior: 'smooth' })
88
+ }, [currentKey, markers, followTick]) // followTick:鼠标离开 rail 时重跑一次跟随
89
+
90
+ if (!visible || injected === undefined || markers.length === 0) return null
91
+
92
+ const onJump = (dot: RailMarker): void => {
93
+ if (sessionId === undefined) return
94
+ setJumpingKey(dot.key)
95
+ forceKey(dot.key) // 点击瞬间强制激活目标(正文滚动稳定后由采样判据接管)
96
+ const currentMarker = currentKey === null ? undefined : displayMarkers.find((m) => m.key === currentKey)
97
+ injected.jump(sessionId, dot.key, dot.seq, currentMarker?.seq)
98
+ window.setTimeout(() => setJumpingKey((k) => (k === dot.key ? null : k)), 600)
99
+ }
100
+
101
+ // 事件委托(圆点是 memo 纯展示):hover 开悬浮卡 / 点击跳转。
102
+ // 用 onMouseOver(真实冒泡,圆点间移动每次触发)而非 onMouseEnter
103
+ // (容器级 mouseenter 只在从外部进入时触发一次——圆点间移动不会换卡)。
104
+ const onDotsOver = (e: React.MouseEvent): void => {
105
+ const el = e.target as HTMLElement
106
+ const hit = el.closest<HTMLElement>('[data-mega-chat-nav-key]')
107
+ if (hit === null) return
108
+ const key = hit.dataset.megaChatNavKey
109
+ if (key === undefined || key === lastHoverKey.current) return
110
+ const dot = displayMarkers.find((d) => d.key === key)
111
+ if (dot === undefined) return
112
+ lastHoverKey.current = key
113
+ cancelClear()
114
+ openCards(dot, hit)
115
+ }
116
+ const onDotsClick = (e: React.MouseEvent): void => {
117
+ const el = e.target as HTMLElement
118
+ const hit = el.closest<HTMLElement>('[data-mega-chat-nav-key]')
119
+ if (hit === null) return
120
+ const key = hit.dataset.megaChatNavKey
121
+ const dot = displayMarkers.find((d) => d.key === key)
122
+ if (dot !== undefined) onJump(dot)
123
+ }
124
+
125
+ if (mobile) {
126
+ return (
127
+ <>
128
+ {drawerOpen ? (
129
+ <>
130
+ <div className="mgcn-drawerMask" onClick={() => setDrawerOpen(false)} />
131
+ <div className="mgcn-searchDrawer" role="dialog" aria-label={t('search.placeholder')}>
132
+ <SearchBox
133
+ sessionId={sessionId}
134
+ scopes={settings.searchScopes}
135
+ t={t}
136
+ onJump={(key, seq) => {
137
+ setDrawerOpen(false)
138
+ onJump({ key, seq: seq ?? 0, turn: null, time: Date.now(), texts: [], members: [key] })
139
+ }}
140
+ onClose={() => setDrawerOpen(false)}
141
+ emptyContent={
142
+ <div className="mgcn-drawerList">
143
+ {[...markers].reverse().slice(0, 20).map((m) => (
144
+ <button
145
+ key={m.key}
146
+ type="button"
147
+ className="mgcn-drawerItem"
148
+ onClick={() => { setDrawerOpen(false); onJump(m) }}
149
+ >
150
+ <span className="mgcn-drawerItemTitle">
151
+ <span className="mgcn-drawerItemTurn">{m.turn !== null ? 'Turn ' + m.turn : ''}</span>
152
+ <span className="mgcn-drawerItemTime">{clockText(m.time, Date.now())}</span>
153
+ </span>
154
+ <span className="mgcn-drawerItemText">{m.texts[0] ?? ''}</span>
155
+ </button>
156
+ ))}
157
+ </div>
158
+ }
159
+ />
160
+ </div>
161
+ </>
162
+ ) : null}
163
+ </>
164
+ )
165
+ }
166
+
167
+ const selectedIndex = focus === null ? -1 : displayMarkers.findIndex((d) => d.key === focus?.key)
168
+
169
+ return (
170
+ <div
171
+ ref={panelRef}
172
+ className={align === 'right' ? 'mgcn-rail mgcn-railRight' : 'mgcn-rail'}
173
+ data-mega-chat-nav="rail"
174
+ onMouseEnter={showPeek}
175
+ onMouseLeave={() => { hidePeek(); setFollowTick((n) => n + 1) }} // 离开:触发一次跟随计算
176
+ >
177
+ <div className="mgcn-queue">
178
+ {displayMarkers.length === 0 ? (
179
+ <div className="mgcn-empty">{t('strip.empty')}</div>
180
+ ) : (
181
+ <div
182
+ className="mgcn-list"
183
+ style={{ '--mgcn-band-height': BAND_HEIGHT_PX.minimal[bandHeight] + 'px' } as React.CSSProperties}
184
+ >
185
+ {/* 操作区:.mgcn-list 子级悬浮定位,不占列表高度、不混入圆点流 */}
186
+ <div className="mgcn-actions">
187
+ {settings.showQuickSettings !== 'hide' ? (
188
+ <span className={'mgcn-gear-wrap mgcn-gear-wrap--' + align + peekCls(settings.showQuickSettings, configOpen)}>
189
+ <button
190
+ type="button"
191
+ className="mgcn-config-gear"
192
+ aria-label={t('config.title')}
193
+ onClick={() => { setConfigOpen((v) => !v); setSearchOpen(false) }}
194
+ >
195
+ <svg className="mgcn-btn-icon" viewBox="0 0 24 24" width="12" height="12" aria-hidden="true">
196
+ <circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" strokeWidth="1.7" />
197
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
198
+ </svg>
199
+ </button>
200
+ {configOpen ? (
201
+ <QuickSettings injected={injected} t={t} onClose={() => setConfigOpen(false)} />
202
+ ) : null}
203
+ </span>
204
+ ) : null}
205
+ {settings.showFavorites !== 'hide' ? (
206
+ <span className={'mgcn-gear-wrap mgcn-gear-wrap--' + align + peekCls(settings.showFavorites, false)}>
207
+ <button
208
+ type="button"
209
+ className={effectiveFilter ? 'mgcn-config-gear mgcn-favFilterOn' : 'mgcn-config-gear'}
210
+ aria-label={t('fav.filter')}
211
+ onClick={() => { if (favorites.size > 0) setFavFilter((v) => !v) }}
212
+ >
213
+ <svg className="mgcn-btn-icon" viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
214
+ <path d="M8 2.2l1.76 3.56 3.93.57-2.84 2.77.67 3.91L8 11.4l-3.52 1.85.67-3.91-2.84-2.77 3.93-.57z"
215
+ fill={favFilter ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
216
+ </svg>
217
+ </button>
218
+ </span>
219
+ ) : null}
220
+ {settings.showSearch !== 'hide' ? (
221
+ <span className={'mgcn-gear-wrap mgcn-gear-wrap--' + align + peekCls(settings.showSearch, searchOpen)}>
222
+ <button
223
+ type="button"
224
+ className="mgcn-config-gear"
225
+ aria-label={t('search.placeholder')}
226
+ onClick={() => { setSearchOpen((v) => !v); setConfigOpen(false) }}
227
+ >
228
+ <svg className="mgcn-btn-icon" viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
229
+ <circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.6" />
230
+ <line x1="10.4" y1="10.4" x2="14" y2="14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
231
+ </svg>
232
+ </button>
233
+ {searchOpen ? (
234
+ <SearchBox
235
+ sessionId={sessionId}
236
+ scopes={settings.searchScopes}
237
+ t={t}
238
+ onJump={(key, seq) => onJump({ key, seq: seq ?? 0, turn: null, time: Date.now(), texts: [], members: [key] })}
239
+ onClose={() => setSearchOpen(false)}
240
+ />
241
+ ) : null}
242
+ </span>
243
+ ) : null}
244
+ {settings.showCount !== 'hide' ? (
245
+ <span className={'mgcn-count' + peekCls(settings.showCount, false)}>{displayMarkers.length}</span>
246
+ ) : null}
247
+ </div>
248
+ {scrollable && showPaging !== 'hide' ? (
249
+ <div className={'mgcn-pageUp' + peekCls(showPaging, false)}>
250
+ <PagingButton
251
+ dir={-1}
252
+ visible={canPageUp}
253
+ label={t('strip.up')}
254
+ onEnter={cancelClear}
255
+ onLeave={scheduleClear}
256
+ onClick={() => pageBy(-1)}
257
+ />
258
+ </div>
259
+ ) : null}
260
+ <div
261
+ ref={listRef}
262
+ className="mgcn-dots"
263
+ onScroll={syncScroll}
264
+ onMouseLeave={() => { lastHoverKey.current = null; scheduleClear() }}
265
+ onMouseOver={onDotsOver}
266
+ onClick={onDotsClick}
267
+ >
268
+ {displayMarkers.map((dot, index) => {
269
+ const isFocused = focus !== null && focus.key === dot.key
270
+ const tier = selectedIndex < 0 ? null : markerTier(index - selectedIndex)
271
+ const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? markerScale(tier) : 1
272
+ const reveal = revealed?.get(dot.key)
273
+ const cls = ['mgcn-dot']
274
+ if (isFocused) cls.push('mgcn-focused')
275
+ if (jumpingKey === dot.key) cls.push('mgcn-active')
276
+ if (currentKey === dot.key) cls.push('mgcn-current')
277
+ if (reveal !== undefined) {
278
+ cls.push(reveal.dir === 1 ? 'mgcn-dotEnterFromBottom' : 'mgcn-dotEnterFromTop')
279
+ }
280
+ return (
281
+ <Dot
282
+ key={dot.key}
283
+ keyName={dot.key}
284
+ index={index}
285
+ className={cls.join(' ')}
286
+ scale={scale}
287
+ label={dot.texts[0] ?? ''}
288
+ focused={isFocused}
289
+ revealed={reveal}
290
+ />
291
+ )
292
+ })}
293
+ </div>
294
+ {scrollable && showPaging !== 'hide' ? (
295
+ <div className={'mgcn-pageDown' + peekCls(showPaging, false)}>
296
+ <PagingButton
297
+ dir={1}
298
+ visible={canPageDown}
299
+ label={t('strip.down')}
300
+ onEnter={cancelClear}
301
+ onLeave={scheduleClear}
302
+ onClick={() => pageBy(1)}
303
+ />
304
+ </div>
305
+ ) : null}
306
+ </div>
307
+ )}
308
+ </div>
309
+ {loading !== null ? <Hint text={loading} align={align} position="bottom" loading /> : hint !== null ? <Hint text={hint} align={align} /> : null}
310
+ {focus !== null ? (
311
+ <Cards
312
+ focus={focus}
313
+ align={align}
314
+ cardItems={cardItems}
315
+ favorites={favorites}
316
+ onToggleFavorite={toggleFavorite}
317
+ onJump={onJump}
318
+ onEnter={cancelClear}
319
+ onLeave={scheduleClear}
320
+ />
321
+ ) : null}
322
+ </div>
323
+ )
324
+ }
@@ -0,0 +1,32 @@
1
+ // 圆点按钮:纯展示(缩放/跳转/翻页弹出状态由父级 memo 前算好)——
2
+ // memo 化后长会话 hover 不再整列重渲;事件由列表容器委托
3
+ import { memo, type ReactNode } from 'react'
4
+
5
+ export interface DotProps {
6
+ /** 缩放(聚焦模型输出;跳转中 1.6) */
7
+ scale: number
8
+ className: string
9
+ label: string
10
+ index: number
11
+ keyName: string
12
+ focused: boolean
13
+ revealed?: { dir: 1 | -1; order: number }
14
+ }
15
+
16
+ export const Dot = memo(function Dot(props: DotProps): ReactNode {
17
+ const { scale, className, label, index, keyName, focused, revealed } = props
18
+ return (
19
+ <button
20
+ type="button"
21
+ className={className}
22
+ style={{
23
+ transform: 'scale(' + scale + ')',
24
+ ...(revealed !== undefined ? { animationDelay: revealed.order * 35 + 'ms' } : {}),
25
+ }}
26
+ data-mega-chat-nav-focused={focused ? 'true' : undefined}
27
+ data-mega-chat-nav-index={index}
28
+ data-mega-chat-nav-key={keyName}
29
+ aria-label={label}
30
+ />
31
+ )
32
+ })
@@ -0,0 +1,102 @@
1
+ // 悬浮卡片组(minimal 能力):内容块由配置多选(轮次 / 用时 / 收藏)控制
2
+ import type { ReactNode } from 'react'
3
+ import { createPortal } from 'react-dom'
4
+ import type { RailAlign, CardItem } from '../../settings.ts'
5
+ import { cardMetrics, metricsLine, clockText } from './useRail.ts'
6
+ import type { FocusState, RailMarker } from '../shared/types.ts'
7
+
8
+ export interface CardsProps {
9
+ focus: FocusState
10
+ align: RailAlign
11
+ /** 显示哪些内容块(多选配置) */
12
+ cardItems: readonly CardItem[]
13
+ /** 收藏集合 */
14
+ favorites: ReadonlySet<string>
15
+ /** 切换收藏(按该轮首问锚点 key) */
16
+ onToggleFavorite: (key: string) => void
17
+ onJump: (dot: RailMarker) => void
18
+ /** 卡片 hover 保活(取消 rail 的清焦定时) */
19
+ onEnter: () => void
20
+ /** 离开卡片后重新排程清焦 */
21
+ onLeave: () => void
22
+ }
23
+
24
+ function favoriteButton(favorited: boolean, onClick: () => void): ReactNode {
25
+ return (
26
+ <button
27
+ type="button"
28
+ className={favorited ? 'mgcn-cardFav mgcn-cardFavOn' : 'mgcn-cardFav'}
29
+ aria-label={favorited ? 'unfavorite' : 'favorite'}
30
+ onClick={(e) => { e.stopPropagation(); onClick() }}
31
+ >
32
+ <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">
33
+ <path d="M8 2.2l1.76 3.56 3.93.57-2.84 2.77.67 3.91L8 11.4l-3.52 1.85.67-3.91-2.84-2.77 3.93-.57z"
34
+ fill={favorited ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
35
+ </svg>
36
+ </button>
37
+ )
38
+ }
39
+
40
+ export function Cards(props: CardsProps): ReactNode {
41
+ const { focus, align, cardItems, favorites, onToggleFavorite, onJump, onEnter, onLeave } = props
42
+ const showTurn = cardItems.includes('turn')
43
+ const showDuration = cardItems.includes('duration')
44
+ const showFavorite = cardItems.includes('favorite')
45
+ return createPortal(
46
+ <div
47
+ className={align === 'right' ? 'mgcn-cascade mgcn-cascadeRight' : 'mgcn-cascade'}
48
+ style={{
49
+ ...(focus.left !== undefined ? { left: focus.left } : {}),
50
+ ...(focus.right !== undefined ? { right: focus.right } : {}),
51
+ top: focus.centerY,
52
+ transform: 'translateY(-50%)',
53
+ }}
54
+ onMouseEnter={onEnter}
55
+ onMouseLeave={onLeave}
56
+ >
57
+ {focus.items.map((item) => {
58
+ const metrics = cardMetrics(item.distance)
59
+ if (metrics === null) return null
60
+ const isSelected = item.distance === 0
61
+ const dot = item.dot
62
+ const text = dot.texts.join(' · ')
63
+ const cls = isSelected
64
+ ? 'mgcn-card mgcn-cardSelected' + (align === 'right' ? ' mgcn-cardSelectedRight' : '')
65
+ : 'mgcn-card'
66
+ const favorited = favorites.has(dot.key)
67
+ return (
68
+ <div
69
+ key={dot.key}
70
+ className={cls}
71
+ role="button"
72
+ tabIndex={-1}
73
+ style={{ width: metrics.widthPx }}
74
+ onClick={() => onJump(dot)}
75
+ >
76
+ <div className="mgcn-cardHead">
77
+ {showFavorite ? favoriteButton(favorited, () => onToggleFavorite(dot.key)) : null}
78
+ {showTurn && isSelected && dot.turn !== null ? (
79
+ <div className="mgcn-cardTitle">Turn {dot.turn}</div>
80
+ ) : null}
81
+ <div className="mgcn-cardTime">{clockText(dot.time, Date.now())}</div>
82
+ </div>
83
+ {showDuration && dot.metrics !== undefined && isSelected ? (
84
+ <div className="mgcn-cardMetrics">{metricsLine(dot.metrics)}</div>
85
+ ) : null}
86
+ <div
87
+ className={isSelected ? 'mgcn-cardBody' : 'mgcn-cardClamp'}
88
+ style={
89
+ isSelected
90
+ ? undefined
91
+ : { WebkitLineClamp: metrics.maxLines, fontSize: metrics.fontSize } as React.CSSProperties
92
+ }
93
+ >
94
+ {text}
95
+ </div>
96
+ </div>
97
+ )
98
+ })}
99
+ </div>,
100
+ document.body,
101
+ )
102
+ }
@@ -0,0 +1,50 @@
1
+ // 跳转失败 / 加载提示(portal 到 body):
2
+ // - 失败提示:贴 rail 侧顶部(rail 左右对齐)
3
+ // - 加载提示:对话输入框上方细条,水平对齐相对**对话区域**左右边缘(跟随 rail 侧)
4
+ import { useEffect, useState, type ReactNode } from 'react'
5
+ import { createPortal } from 'react-dom'
6
+ import type { RailAlign } from '../../settings.ts'
7
+
8
+ export interface HintProps {
9
+ text: string
10
+ align: RailAlign
11
+ /** bottom:显示在对话输入框上方(加载较早记录提示),水平对齐跟随对话区域左右边缘 */
12
+ position?: 'rail' | 'bottom'
13
+ /** 加载点动画(仅加载中提示) */
14
+ loading?: boolean
15
+ }
16
+
17
+ /** 对话区域左/右边缘到视口的距离(fixed 定位用) */
18
+ function conversationEdge(align: RailAlign): number | null {
19
+ const root = document.querySelector<HTMLElement>('[data-slot="conversation"] > div[data-phase]')
20
+ if (root === null) return null
21
+ const r = root.getBoundingClientRect()
22
+ return align === 'right' ? window.innerWidth - r.right : r.left
23
+ }
24
+
25
+ export function Hint({ text, align, position = 'rail', loading = false }: HintProps): ReactNode {
26
+ const [edge, setEdge] = useState<number | null>(() => conversationEdge(align))
27
+
28
+ useEffect(() => {
29
+ const measure = (): void => setEdge(conversationEdge(align))
30
+ measure()
31
+ window.addEventListener('resize', measure)
32
+ return () => window.removeEventListener('resize', measure)
33
+ }, [align])
34
+
35
+ const cls = position === 'bottom' ? 'mgcn-hint mgcn-hint--bottom' : 'mgcn-hint'
36
+ const style = position === 'bottom'
37
+ ? align === 'right'
38
+ ? { right: (edge ?? 0) + 20, bottom: 150 }
39
+ : { left: (edge ?? 0) + 20, bottom: 150 }
40
+ : align === 'right' ? { right: 52, top: 12 } : { left: 52, top: 12 }
41
+ return createPortal(
42
+ <div className={cls} style={style}>
43
+ {loading ? (
44
+ <span className="mgcn-hint-dots" aria-hidden="true"><i /><i /><i /></span>
45
+ ) : null}
46
+ <span>{text}</span>
47
+ </div>,
48
+ document.body,
49
+ )
50
+ }
@@ -0,0 +1,26 @@
1
+ // ▲/▼ 翻页按钮(minimal 与 codex 统一三角形态;显示模式由宿主配置 showPaging 控制)
2
+ import type { ReactNode } from 'react'
3
+
4
+ export interface PagingButtonProps {
5
+ dir: 1 | -1
6
+ visible: boolean
7
+ label: string
8
+ onEnter: () => void
9
+ onLeave: () => void
10
+ onClick: () => void
11
+ }
12
+
13
+ export function PagingButton(props: PagingButtonProps): ReactNode {
14
+ const cls = props.dir === -1 ? 'mgcn-navBtn mgcn-navUp' : 'mgcn-navBtn mgcn-navDown'
15
+ return (
16
+ <button
17
+ type="button"
18
+ className={cls}
19
+ aria-label={props.label}
20
+ style={{ visibility: props.visible ? 'visible' : 'hidden' }}
21
+ onMouseEnter={props.onEnter}
22
+ onMouseLeave={props.onLeave}
23
+ onClick={props.onClick}
24
+ />
25
+ )
26
+ }
@@ -0,0 +1,130 @@
1
+ // 快捷设置面板:对齐/条带高度/卡片数(能力感知)+ 显示模式
2
+ import { useEffect, useState, type ReactNode } from 'react'
3
+ import { ALIGN_OPTIONS, BAND_OPTIONS, CARD_OPTIONS, MARK_TONE_OPTIONS, SHOW_OPTIONS, STYLE_CAPABILITIES, type BandHeight, type CardCount, type MarkTone, type RailAlign, type ShowMode } from '../../settings.ts'
4
+ import type { NavInjected, Translate } from '../shared/types.ts'
5
+
6
+ export interface QuickSettingsProps {
7
+ injected: NavInjected
8
+ t: Translate
9
+ onClose: () => void
10
+ }
11
+
12
+ function Seg<T extends string | number>(props: { options: readonly T[]; value: T; labels: Record<string, string>; onPick: (v: T) => void }): ReactNode {
13
+ return (
14
+ <div className="mgcn-segmented" role="radiogroup">
15
+ {props.options.map((o) => (
16
+ <button
17
+ key={o}
18
+ type="button"
19
+ role="radio"
20
+ aria-checked={props.value === o}
21
+ className={props.value === o ? 'mgcn-segment mgcn-segmentActive' : 'mgcn-segment'}
22
+ onClick={() => props.onPick(o)}
23
+ >
24
+ {props.labels[String(o)] ?? String(o)}
25
+ </button>
26
+ ))}
27
+ </div>
28
+ )
29
+ }
30
+
31
+ function ModeSeg({ value, onChange, t }: { value: ShowMode; onChange(v: ShowMode): void; t: Translate }): ReactNode {
32
+ return (
33
+ <Seg
34
+ options={SHOW_OPTIONS}
35
+ value={value}
36
+ labels={{
37
+ show: t('config.showMode.show'),
38
+ peek: t('config.showMode.peek'),
39
+ hide: t('config.showMode.hide'),
40
+ }}
41
+ onPick={(v) => onChange(v)}
42
+ />
43
+ )
44
+ }
45
+
46
+ function useSettingsTick(subscribe: NavInjected['subscribeSettings']): void {
47
+ const [, bump] = useState(0)
48
+ useEffect(() => subscribe(() => bump((n) => n + 1)), [subscribe])
49
+ }
50
+
51
+ export function QuickSettings(props: QuickSettingsProps): ReactNode {
52
+ const { injected, t, onClose } = props
53
+ useSettingsTick(injected.subscribeSettings)
54
+ const caps = STYLE_CAPABILITIES[injected.style()]
55
+ return (
56
+ <div className="mgcn-config-panel" role="dialog" aria-label={t('config.title')}>
57
+ <div className="mgcn-config-head">
58
+ <span className="mgcn-config-title">{t('config.style.' + injected.style())} · {t('config.settings')}</span>
59
+ <button type="button" className="mgcn-config-close" aria-label={t('config.close')} onClick={onClose}>✕</button>
60
+ </div>
61
+ <div className="mgcn-config-row">
62
+ <span className="mgcn-config-label">{t('config.align')}</span>
63
+ <Seg
64
+ options={ALIGN_OPTIONS}
65
+ value={injected.align()}
66
+ labels={{ left: t('settings.align.left'), right: t('settings.align.right') }}
67
+ onPick={(v: RailAlign) => injected.setAlign(v)}
68
+ />
69
+ </div>
70
+ <div className="mgcn-config-row">
71
+ <span className="mgcn-config-label">{t('config.bandHeight')}</span>
72
+ <Seg
73
+ options={BAND_OPTIONS}
74
+ value={injected.bandHeight()}
75
+ labels={{
76
+ compact: t('config.bandHeight.compact'),
77
+ standard: t('config.bandHeight.standard'),
78
+ tall: t('config.bandHeight.tall'),
79
+ }}
80
+ onPick={(v: BandHeight) => injected.setBandHeight(v)}
81
+ />
82
+ </div>
83
+ {caps.hoverCards ? (
84
+ <div className="mgcn-config-row">
85
+ <span className="mgcn-config-label">{t('config.cardCount')}</span>
86
+ <Seg
87
+ options={CARD_OPTIONS}
88
+ value={injected.cardCount()}
89
+ labels={{ 1: '1', 3: '3', 5: '5' }}
90
+ onPick={(v: CardCount) => injected.setCardCount(v)}
91
+ />
92
+ </div>
93
+ ) : null}
94
+ {injected.style() === 'codex' ? (
95
+ <div className="mgcn-config-row">
96
+ <span className="mgcn-config-label">{t('config.markTone')}</span>
97
+ <Seg
98
+ options={MARK_TONE_OPTIONS}
99
+ value={injected.markTone()}
100
+ labels={{ soft: t('config.markTone.soft'), deep: t('config.markTone.deep') }}
101
+ onPick={(v: MarkTone) => injected.setMarkTone(v)}
102
+ />
103
+ </div>
104
+ ) : null}
105
+ <div className="mgcn-config-sep" />
106
+ <div className="mgcn-config-row">
107
+ <span className="mgcn-config-label">{t('config.showQuickSettings')}</span>
108
+ <ModeSeg value={injected.showQuickSettings()} onChange={(v) => injected.setShowQuickSettings(v)} t={t} />
109
+ </div>
110
+ <div className="mgcn-config-row">
111
+ <span className="mgcn-config-label">{t('config.showSearch')}</span>
112
+ <ModeSeg value={injected.showSearch()} onChange={(v) => injected.setShowSearch(v)} t={t} />
113
+ </div>
114
+ <div className="mgcn-config-row">
115
+ <span className="mgcn-config-label">{t('config.showFavorites')}</span>
116
+ <ModeSeg value={injected.showFavorites()} onChange={(v) => injected.setShowFavorites(v)} t={t} />
117
+ </div>
118
+ <div className="mgcn-config-row">
119
+ <span className="mgcn-config-label">{t('config.showCount')}</span>
120
+ <ModeSeg value={injected.showCount()} onChange={(v) => injected.setShowCount(v)} t={t} />
121
+ </div>
122
+ {STYLE_CAPABILITIES[injected.style()].paging ? (
123
+ <div className="mgcn-config-row">
124
+ <span className="mgcn-config-label">{t('config.showPaging')}</span>
125
+ <ModeSeg value={injected.showPaging()} onChange={(v) => injected.setShowPaging(v)} t={t} />
126
+ </div>
127
+ ) : null}
128
+ </div>
129
+ )
130
+ }