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,522 @@
1
+ // deepseek 风格:用户消息面板——风格自治实现(数据/布局/间谍经 ../rail 共享)。
2
+ //
3
+ // 形态:常态 30px 窄轨(每轮行首 8×2 短横把手);hover(JS 状态驱动)展开
4
+ // 280px 面板:面板头 = 操作区独立一行,面板体 = 用户消息列表(30px 行 / 14px 字)。
5
+ // 交互(全部 JS 状态驱动):
6
+ // - 行 hover:该行横线变为收藏按钮(点击收藏/取消),离开恢复横线;
7
+ // - 点 🔍:操作区变为搜索框(仅保留搜索钮 + 输入),列表随输入变动,清空/Esc/再点还原;
8
+ // 搜索态鼠标移出面板**不收起**,仅点击外部才关闭;
9
+ // - 点 ⚙:导航面板收起(还原未 hover 的 30px 窄轨),弹出**通用设置浮层**(rail 对话侧);
10
+ // - 收藏筛选 ⭐ 开启后同搜索:移出不收起,仅点击外部关闭;取消最后一个收藏自动退出筛选;
11
+ // - 点击外部(rail 与浮层之外):关闭展开/搜索/设置/收藏筛选并还原列表。
12
+ import type { ReactNode } from 'react'
13
+ import { memo, useEffect, useRef, useState } from 'react'
14
+ import { useMarkerData, usePinning, useReadingSpy, useFailureNotice, useLoadingNotice, useFavorites, useMobileMode, useMobileSearchButton, useNavSettings, clockText } from '../rail/useRail.ts'
15
+ import { Hint } from '../rail/hint.tsx'
16
+ import { fetchHits, type SearchHit } from '../rail/search.tsx'
17
+ import { QuickSettings } from '../rail/quick-settings.tsx'
18
+ import { BAND_HEIGHT_PX } from '../../settings.ts'
19
+ import type { RailMarker, RailProps } from '../shared/types.ts'
20
+
21
+ /** deepseek 消息行高(px):行高 30px = 行距几何(文字 14px) */
22
+ const DS_ROW_HEIGHT = 30
23
+
24
+ /** 消息行:行首横线(hover 变收藏按钮)+ 用户消息单行预览 */
25
+ const DsRow = memo(function DsRow(props: {
26
+ marker: RailMarker
27
+ current: boolean
28
+ jumping: boolean
29
+ hovered: boolean
30
+ favorited: boolean
31
+ label: string
32
+ onHover: () => void
33
+ onLeave: () => void
34
+ onToggleFavorite: () => void
35
+ onJump: () => void
36
+ }): ReactNode {
37
+ const { marker, current, jumping, hovered, favorited, label, onHover, onLeave, onToggleFavorite, onJump } = props
38
+ const cls = ['mgcn-dsRow']
39
+ if (current) cls.push('mgcn-dsCurrent')
40
+ if (jumping) cls.push('mgcn-dsJump')
41
+ if (hovered) cls.push('mgcn-dsRowHover')
42
+ return (
43
+ <div
44
+ className={cls.join(' ')}
45
+ data-mega-chat-nav-key={marker.key}
46
+ onMouseEnter={onHover}
47
+ onMouseLeave={onLeave}
48
+ >
49
+ {hovered ? (
50
+ <button
51
+ type="button"
52
+ className={favorited ? 'mgcn-dsFav mgcn-dsFavOn' : 'mgcn-dsFav'}
53
+ aria-label={favorited ? 'unfavorite' : 'favorite'}
54
+ onClick={(e) => { e.stopPropagation(); onToggleFavorite() }}
55
+ >
56
+ <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">
57
+ <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"
58
+ fill={favorited ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" />
59
+ </svg>
60
+ </button>
61
+ ) : (
62
+ <i className="mgcn-dsMark" aria-hidden="true" />
63
+ )}
64
+ <button type="button" className="mgcn-dsText" aria-label={label} onClick={onJump}>
65
+ <span className="mgcn-dsTextInner">{marker.texts[0] ?? ''}</span>
66
+ </button>
67
+ </div>
68
+ )
69
+ })
70
+
71
+ /** 就地搜索结果行(点击跳转) */
72
+ function SearchHitRow(props: { hit: SearchHit; onJump: () => void }): ReactNode {
73
+ const { hit, onJump } = props
74
+ const roleTag = hit.role === 'assistant' ? '助手' : hit.role === 'tool' ? '工具' : '用户'
75
+ return (
76
+ <button type="button" className="mgcn-dsHit" onClick={onJump}>
77
+ <i className="mgcn-dsMark" aria-hidden="true" />
78
+ <span className="mgcn-dsHitBody">
79
+ <span className="mgcn-dsHitMeta">
80
+ {hit.turn > 0 ? <span>第 {hit.turn} 轮</span> : null}
81
+ <span className={'mgcn-dsHitTag mgcn-dsHitTag--' + hit.role}>{roleTag}</span>
82
+ </span>
83
+ <span className="mgcn-dsHitText">
84
+ {hit.hitStart >= 0 && hit.hitLen > 0 ? (
85
+ <>
86
+ {hit.snippet.slice(0, hit.hitStart)}
87
+ <mark className="mgcn-search-mark">{hit.snippet.slice(hit.hitStart, hit.hitStart + hit.hitLen)}</mark>
88
+ {hit.snippet.slice(hit.hitStart + hit.hitLen)}
89
+ </>
90
+ ) : hit.snippet}
91
+ </span>
92
+ </span>
93
+ </button>
94
+ )
95
+ }
96
+
97
+ /** 阅读位置跟随:正文滚动时面板内当前行平滑居中。
98
+ * resetKey:外部事件计数(退出搜索 searchOpen / 鼠标离开 rail followTick),
99
+ * 变化时强制重新居中到当前行——即使 currentKey/markers 未变也执行一次跟随。 */
100
+ function useFollowCurrent(
101
+ listRef: { current: HTMLDivElement | null },
102
+ currentKey: string | null,
103
+ markers: readonly RailMarker[],
104
+ resetKey?: boolean | number | string,
105
+ ): void {
106
+ useEffect(() => {
107
+ const list = listRef.current
108
+ if (list === null || currentKey === null) return
109
+ const index = markers.findIndex((d) => d.key === currentKey)
110
+ if (index < 0) return
111
+ const slotTop = index * DS_ROW_HEIGHT
112
+ const contentHeight = markers.length * DS_ROW_HEIGHT + 12
113
+ const maxScroll = Math.max(0, contentHeight - list.clientHeight)
114
+ const target = Math.max(0, Math.min(slotTop - list.clientHeight / 2 + DS_ROW_HEIGHT / 2, maxScroll))
115
+ if (Math.abs(target - list.scrollTop) < 2) return
116
+ list.scrollTo({ top: target, behavior: 'smooth' })
117
+ }, [currentKey, markers, resetKey])
118
+ }
119
+
120
+ export function DeepseekRail(props: RailProps): ReactNode {
121
+ const { injected, useSessions, t } = props
122
+ const panelRef = useRef<HTMLDivElement>(null)
123
+ const listRef = useRef<HTMLDivElement>(null)
124
+ const searchInputRef = useRef<HTMLInputElement>(null)
125
+ const [open, setOpen] = useState(false) // JS hover 展开态(不用 CSS :hover)
126
+ const [jumpingKey, setJumpingKey] = useState<string | null>(null)
127
+ const [searchOpen, setSearchOpen] = useState(false)
128
+ const [configOpen, setConfigOpen] = useState(false)
129
+ const [favFilter, setFavFilter] = useState(false)
130
+ const [hoverKey, setHoverKey] = useState<string | null>(null)
131
+ const [drawerOpen, setDrawerOpen] = useState(false)
132
+ const closeTimer = useRef<number | null>(null)
133
+ const [query, setQuery] = useState('')
134
+ const [results, setResults] = useState<SearchHit[]>([])
135
+ const [searching, setSearching] = useState(false) // 检索进行中(列表 loading 态)
136
+ // 鼠标离开导航区域触发一次跟随:并入 resetKey 使跟随 effect 重跑(几何复用,无新增计算)
137
+ const [followTick, setFollowTick] = useState(0)
138
+ const abortRef = useRef<AbortController | null>(null)
139
+ const debounceRef = useRef<number | null>(null)
140
+
141
+ const mobile = useMobileMode()
142
+
143
+ const { sessionId, visible, markers } = useMarkerData(injected, useSessions)
144
+ const { favorites, toggle: toggleFavorite } = useFavorites(sessionId)
145
+ const settings = useNavSettings(injected)
146
+ const { align, bandHeight } = settings
147
+ useMobileSearchButton(() => setDrawerOpen(true), mobile && markers.length > 0, align, t('search.placeholder'))
148
+
149
+ const effectiveFilter = favFilter && favorites.size > 0
150
+ const displayMarkers = effectiveFilter
151
+ ? markers.filter((m) => m.members.some((k) => favorites.has(k)))
152
+ : markers
153
+
154
+ usePinning(panelRef, visible, align, settings.railOffset, sessionId)
155
+ const [currentKey, forceKey] = useReadingSpy(visible, displayMarkers)
156
+
157
+ const hint = useFailureNotice(t)
158
+ const loading = useLoadingNotice(t)
159
+
160
+ useFollowCurrent(listRef, currentKey, displayMarkers, String(searchOpen) + ':' + followTick)
161
+
162
+ // 一键复位:收起面板并关闭全部就地模式(搜索/设置/收藏筛选),列表还原
163
+ const collapseToIdle = (): void => {
164
+ cancelClose()
165
+ setOpen(false)
166
+ setHoverKey(null)
167
+ setSearchOpen(false)
168
+ setQuery('')
169
+ setResults([])
170
+ setSearching(false)
171
+ if (debounceRef.current !== null) {
172
+ window.clearTimeout(debounceRef.current)
173
+ debounceRef.current = null
174
+ }
175
+ abortRef.current?.abort()
176
+ setConfigOpen(false)
177
+ setFavFilter(false)
178
+ }
179
+ // 展开/收起 JS 状态:进入即开,离开延迟 260ms 收。
180
+ // 就地模式(搜索/设置)开启时移出**不收起**——只有点击外部才关闭。
181
+ const cancelClose = (): void => {
182
+ if (closeTimer.current !== null) {
183
+ window.clearTimeout(closeTimer.current)
184
+ closeTimer.current = null
185
+ }
186
+ }
187
+ const scheduleClose = (): void => {
188
+ cancelClose()
189
+ if (searchOpen || configOpen || effectiveFilter) return
190
+ closeTimer.current = window.setTimeout(() => {
191
+ closeTimer.current = null
192
+ collapseToIdle()
193
+ }, 260)
194
+ }
195
+ // 复位函数经 ref 提供给稳定的全局监听(点击外部)
196
+ const collapseToIdleRef = useRef(collapseToIdle)
197
+ collapseToIdleRef.current = collapseToIdle
198
+
199
+ // 点击外部(rail 及就地浮层之外):收起面板并复位全部就地状态
200
+ useEffect(() => {
201
+ const onDocPointerDown = (e: PointerEvent): void => {
202
+ const panel = panelRef.current
203
+ if (panel === null) return
204
+ const t = e.target
205
+ if (t instanceof Node && panel.contains(t)) return
206
+ collapseToIdleRef.current?.()
207
+ }
208
+ document.addEventListener('pointerdown', onDocPointerDown, true)
209
+ return () => document.removeEventListener('pointerdown', onDocPointerDown, true)
210
+ }, [])
211
+
212
+ // 卸载清理
213
+ useEffect(() => () => {
214
+ if (closeTimer.current !== null) window.clearTimeout(closeTimer.current)
215
+ if (debounceRef.current !== null) window.clearTimeout(debounceRef.current)
216
+ abortRef.current?.abort()
217
+ }, [])
218
+
219
+ // 搜索:就地输入防抖 → host 检索(进行中列表进入 loading 态)
220
+ const runSearch = (raw: string): void => {
221
+ abortRef.current?.abort()
222
+ const ac = new AbortController()
223
+ abortRef.current = ac
224
+ const q = raw.trim()
225
+ if (q.length < 1) { setResults([]); setSearching(false); return }
226
+ setSearching(true)
227
+ fetchHits(sessionId, q, settings.searchScopes, ac.signal)
228
+ .then((hits) => {
229
+ if (abortRef.current !== ac) return
230
+ setResults(hits)
231
+ setSearching(false)
232
+ })
233
+ .catch(() => { if (abortRef.current === ac) setSearching(false) })
234
+ }
235
+ const onSearchInput = (value: string): void => {
236
+ setQuery(value)
237
+ if (debounceRef.current !== null) window.clearTimeout(debounceRef.current)
238
+ if (value.trim() === '') {
239
+ // 清空:取消在途检索并还原列表(不再等防抖)
240
+ debounceRef.current = null
241
+ abortRef.current?.abort()
242
+ setResults([])
243
+ setSearching(false)
244
+ return
245
+ }
246
+ setSearching(true) // 输入即 loading(含防抖窗口),结果到达后熄灭
247
+ // 250ms 防抖:连续输入只触发一次检索
248
+ debounceRef.current = window.setTimeout(() => {
249
+ debounceRef.current = null
250
+ runSearch(value)
251
+ }, 250)
252
+ }
253
+ const openSearch = (): void => {
254
+ cancelClose()
255
+ setConfigOpen(false)
256
+ setFavFilter(false) // 搜索替代收藏筛选:全量历史检索
257
+ setSearchOpen(true)
258
+ setOpen(true) // 搜索就地态要求面板保持展开(移出不收)
259
+ window.setTimeout(() => searchInputRef.current?.focus(), 30)
260
+ }
261
+ // 收藏行切换:筛选态下取消「最后一个收藏」→ 自动退出收藏模式
262
+ // (再点行收藏只 +1 收藏,不再自动进入筛选;需点操作栏 ⭐ 才重新进入)
263
+ const onToggleRowFavorite = (key: string): void => {
264
+ const removingLast = effectiveFilter && favorites.size === 1 && favorites.has(key)
265
+ toggleFavorite(key)
266
+ if (removingLast) setFavFilter(false)
267
+ }
268
+ const closeSearch = (): void => {
269
+ cancelClose()
270
+ setSearchOpen(false)
271
+ setQuery('')
272
+ setResults([])
273
+ setSearching(false)
274
+ if (debounceRef.current !== null) {
275
+ window.clearTimeout(debounceRef.current)
276
+ debounceRef.current = null
277
+ }
278
+ abortRef.current?.abort()
279
+ // 还原时指针已不在 rail 上 → 一并收起;仍在 rail 上则保持展开
280
+ if (panelRef.current !== null && !panelRef.current.matches(':hover')) {
281
+ setOpen(false)
282
+ setHoverKey(null)
283
+ }
284
+ }
285
+ // ⚙:消息面板收起(还原未 hover 的窄轨)+ 弹出通用设置浮层(rail 对话侧)
286
+ const toggleConfig = (): void => {
287
+ cancelClose()
288
+ if (configOpen) {
289
+ setConfigOpen(false)
290
+ setHoverKey(null)
291
+ // 点击浮层 ✕/外部后若指针已离开 rail 区域则整体收起
292
+ if (panelRef.current !== null && !panelRef.current.matches(':hover')) {
293
+ setOpen(false)
294
+ }
295
+ return
296
+ }
297
+ closeSearch()
298
+ setOpen(false)
299
+ setHoverKey(null)
300
+ setConfigOpen(true)
301
+ }
302
+
303
+ if (!visible || injected === undefined || markers.length === 0) return null
304
+
305
+ const onJump = (row: RailMarker): void => {
306
+ if (sessionId === undefined) return
307
+ setJumpingKey(row.key)
308
+ forceKey(row.key)
309
+ const currentMarker = currentKey === null ? undefined : displayMarkers.find((m) => m.key === currentKey)
310
+ injected.jump(sessionId, row.key, row.seq, currentMarker?.seq)
311
+ window.setTimeout(() => setJumpingKey((k) => (k === row.key ? null : k)), 600)
312
+ }
313
+ const onJumpHit = (hit: SearchHit): void => {
314
+ if (sessionId === undefined) return
315
+ setJumpingKey(hit.key)
316
+ forceKey(hit.key)
317
+ injected.jump(sessionId, hit.key, hit.seq)
318
+ window.setTimeout(() => setJumpingKey((k) => (k === hit.key ? null : k)), 600)
319
+ }
320
+
321
+ if (mobile) {
322
+ return (
323
+ <>
324
+ {drawerOpen ? (
325
+ <>
326
+ <div className="mgcn-drawerMask" onClick={() => setDrawerOpen(false)} />
327
+ <div className="mgcn-searchDrawer" role="dialog" aria-label={t('search.placeholder')}>
328
+ <div className="mgcn-drawerInput">
329
+ <input
330
+ className="mgcn-search-input"
331
+ value={query}
332
+ placeholder={t('search.placeholder')}
333
+ autoFocus
334
+ onChange={(e) => onSearchInput(e.target.value)}
335
+ onKeyDown={(e) => { if (e.key === 'Escape') setDrawerOpen(false) }}
336
+ />
337
+ </div>
338
+ {query.trim() === '' ? (
339
+ <div className="mgcn-drawerList">
340
+ {[...markers].reverse().slice(0, 20).map((m) => (
341
+ <button
342
+ key={m.key}
343
+ type="button"
344
+ className="mgcn-drawerItem"
345
+ onClick={() => { setDrawerOpen(false); onJump(m) }}
346
+ >
347
+ <span className="mgcn-drawerItemTitle">
348
+ <span className="mgcn-drawerItemTurn">{m.turn !== null ? 'Turn ' + m.turn : ''}</span>
349
+ <span className="mgcn-drawerItemTime">{clockText(m.time, Date.now())}</span>
350
+ </span>
351
+ <span className="mgcn-drawerItemText">{m.texts[0] ?? ''}</span>
352
+ </button>
353
+ ))}
354
+ </div>
355
+ ) : searching ? (
356
+ <div className="mgcn-drawerList">
357
+ <div className="mgcn-dsLoadingRow" role="status">
358
+ <span className="mgcn-dsLoadingDots" aria-hidden="true"><i /><i /><i /></span>
359
+ <span>{t('search.loading')}</span>
360
+ </div>
361
+ </div>
362
+ ) : (
363
+ <div className="mgcn-drawerList">
364
+ {results.map((hit) => (
365
+ <SearchHitRow key={hit.key + ':' + hit.seq} hit={hit} onJump={() => { setDrawerOpen(false); onJumpHit(hit) }} />
366
+ ))}
367
+ {results.length === 0 ? <div className="mgcn-dsNoHit">{t('search.empty')}</div> : null}
368
+ </div>
369
+ )}
370
+ </div>
371
+ </>
372
+ ) : null}
373
+ </>
374
+ )
375
+ }
376
+
377
+ const messagesBody = (scrollable: boolean): ReactNode => (
378
+ <div ref={scrollable ? listRef : undefined} className="mgcn-dsList">
379
+ {displayMarkers.map((row) => (
380
+ <DsRow
381
+ key={row.key}
382
+ marker={row}
383
+ current={currentKey === row.key}
384
+ jumping={jumpingKey === row.key}
385
+ hovered={hoverKey === row.key}
386
+ favorited={favorites.has(row.key)}
387
+ label={row.texts[0] ?? ''}
388
+ onHover={() => setHoverKey(row.key)}
389
+ onLeave={() => setHoverKey((k) => (k === row.key ? null : k))}
390
+ onToggleFavorite={() => onToggleRowFavorite(row.key)}
391
+ onJump={() => onJump(row)}
392
+ />
393
+ ))}
394
+ </div>
395
+ )
396
+
397
+ return (
398
+ <div
399
+ ref={panelRef}
400
+ className={
401
+ (align === 'right' ? 'mgcn-rail mgcn-dsRail mgcn-railRight' : 'mgcn-rail mgcn-dsRail')
402
+ + (open ? ' mgcn-dsOpen' : '')
403
+ + (configOpen ? ' mgcn-dsSnap' : '') // 设置浮层开启:rail 收起窄轨(无过渡、层级抬升)
404
+ }
405
+ data-mega-chat-nav="rail"
406
+ onMouseEnter={() => { cancelClose(); if (!configOpen) setOpen(true) }} // 设置浮层开启时不随 hover 展开
407
+ onMouseLeave={() => { setFollowTick((n) => n + 1); scheduleClose() }} // 离开:先触发一次跟随,再按原调度收起
408
+ >
409
+ <div className="mgcn-queue">
410
+ {displayMarkers.length === 0 ? (
411
+ <div className="mgcn-empty">{t('strip.empty')}</div>
412
+ ) : (
413
+ <div
414
+ className="mgcn-list"
415
+ style={{ '--mgcn-band-height': BAND_HEIGHT_PX.deepseek[bandHeight] + 'px' } as React.CSSProperties}
416
+ >
417
+ <div role="menu" className="mgcn-dsPanel">
418
+ {/* 面板头:操作区独立一行。搜索开启时变为「搜索钮 + 输入框」 */}
419
+ <div className="mgcn-dsHead">
420
+ {settings.showSearch !== 'hide' ? (
421
+ searchOpen ? (
422
+ <>
423
+ <button
424
+ type="button"
425
+ className="mgcn-config-gear mgcn-dsModeOn"
426
+ aria-label={t('search.placeholder')}
427
+ onClick={closeSearch}
428
+ >
429
+ <svg className="mgcn-btn-icon" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
430
+ <circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.6" />
431
+ <line x1="10.4" y1="10.4" x2="14" y2="14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
432
+ </svg>
433
+ </button>
434
+ <input
435
+ ref={searchInputRef}
436
+ className="mgcn-dsSearchInput"
437
+ value={query}
438
+ placeholder={t('search.placeholder')}
439
+ onChange={(e) => onSearchInput(e.target.value)}
440
+ onKeyDown={(e) => { if (e.key === 'Escape') closeSearch() }}
441
+ />
442
+ </>
443
+ ) : (
444
+ <button
445
+ type="button"
446
+ className="mgcn-config-gear"
447
+ aria-label={t('search.placeholder')}
448
+ onClick={openSearch}
449
+ >
450
+ <svg className="mgcn-btn-icon" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
451
+ <circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.6" />
452
+ <line x1="10.4" y1="10.4" x2="14" y2="14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
453
+ </svg>
454
+ </button>
455
+ )
456
+ ) : null}
457
+ {!searchOpen && settings.showQuickSettings !== 'hide' ? (
458
+ <button
459
+ type="button"
460
+ className={configOpen ? 'mgcn-config-gear mgcn-dsModeOn' : 'mgcn-config-gear'}
461
+ aria-label={t('config.title')}
462
+ onClick={toggleConfig}
463
+ >
464
+ <svg className="mgcn-btn-icon" viewBox="0 0 24 24" width="13" height="13" aria-hidden="true">
465
+ <circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" strokeWidth="1.7" />
466
+ <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" />
467
+ </svg>
468
+ </button>
469
+ ) : null}
470
+ {!searchOpen && settings.showFavorites !== 'hide' ? (
471
+ <button
472
+ type="button"
473
+ className={effectiveFilter ? 'mgcn-config-gear mgcn-favFilterOn' : 'mgcn-config-gear'}
474
+ aria-label={t('fav.filter')}
475
+ onClick={() => { if (favorites.size > 0) setFavFilter((v) => !v) }}
476
+ >
477
+ <svg className="mgcn-btn-icon" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
478
+ <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"
479
+ fill={favFilter ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
480
+ </svg>
481
+ </button>
482
+ ) : null}
483
+ {!searchOpen && settings.showCount !== 'hide' ? (
484
+ <span className="mgcn-count">{displayMarkers.length}</span>
485
+ ) : null}
486
+ </div>
487
+ {/* 面板体:消息 / 就地搜索(⚙ 设置已改由带外浮层承载,不占面板体) */}
488
+ {searchOpen ? (
489
+ <div className="mgcn-dsMode">
490
+ {query.trim() === '' ? (
491
+ messagesBody(false)
492
+ ) : searching ? (
493
+ <div className="mgcn-dsLoadingRow" role="status">
494
+ <span className="mgcn-dsLoadingDots" aria-hidden="true"><i /><i /><i /></span>
495
+ <span>{t('search.loading')}</span>
496
+ </div>
497
+ ) : (
498
+ <div className="mgcn-dsList">
499
+ {results.map((hit) => (
500
+ <SearchHitRow key={hit.key + ':' + hit.seq} hit={hit} onJump={() => onJumpHit(hit)} />
501
+ ))}
502
+ {results.length === 0 ? <div className="mgcn-dsNoHit">{t('search.empty')}</div> : null}
503
+ </div>
504
+ )}
505
+ </div>
506
+ ) : (
507
+ messagesBody(true)
508
+ )}
509
+ </div>
510
+ {/* 设置浮层:挂在 band 盒(.mgcn-list 为定位上下文)角上,rail 收起窄轨时浮于对话侧 */}
511
+ {configOpen ? (
512
+ <span className={'mgcn-gear-wrap mgcn-gear-wrap--' + align + ' mgcn-dsFloat'}>
513
+ <QuickSettings injected={injected} t={t} onClose={() => { cancelClose(); setConfigOpen(false); setHoverKey(null) }} />
514
+ </span>
515
+ ) : null}
516
+ </div>
517
+ )}
518
+ </div>
519
+ {loading !== null ? <Hint text={loading} align={align} position="bottom" loading /> : hint !== null ? <Hint text={hint} align={align} /> : null}
520
+ </div>
521
+ )
522
+ }