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,388 @@
1
+ /**
2
+ * Browser-half entry for the dsh-mega-chat-nav plugin.
3
+ *
4
+ * Registers one surface into the frame-wide floating layer (`shell.overlay`):
5
+ * a vertical strip on the LEFT edge of the conversation column listing every
6
+ * user question in the current session as a small button, one dot per turn.
7
+ * Clicking a button scrolls the chat to that turn's first question.
8
+ *
9
+ * The dots are driven by the host-folded `msgNavMessages` session projection
10
+ * (registered by the plugin's host half): the projection registry folds the
11
+ * WHOLE event log without touching the chat's paged render window, the
12
+ * projection cache persists it, and the standard carriers (history tail-page
13
+ * baseline + session/projection push frames) keep it live. Live-window
14
+ * questions not yet recorded by the projection are merged on top so a
15
+ * just-sent question appears immediately.
16
+ *
17
+ * Failure policy: nothing here throws at apply time — an external plugin must
18
+ * never take the GUI down.
19
+ */
20
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
21
+ // Type-only: pulls the locale plugin's Context merge (ctx.locale).
22
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
23
+ // Type-only: pulls the settings shell's SlotMap merge ('settings.plugins.tab')
24
+ // and the ctx.settingsScope Context merge.
25
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
26
+ import type { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
27
+ import { RailDispatch } from './components/RailDispatch.tsx'
28
+ import { SettingsPage } from './components/settings/SettingsPage.tsx'
29
+ import { StandaloneSettings } from './components/settings/StandaloneSettings.tsx'
30
+ import { NavSettingsController } from './settings.ts'
31
+ import type { NavInjected, ObservableFace } from './components/shared/types.ts'
32
+ import { en, zh } from './locales.ts'
33
+ import { goTo, type JumpFailureCode, type JumpPorts, type JumpSnapshot } from './jump.ts'
34
+
35
+ /** Locale namespace this plugin owns. */
36
+ const NS = 'mega-chat-nav'
37
+
38
+ /** Services required by this plugin. */
39
+ export const inject = ['slots', 'locale', 'sessions']
40
+
41
+ /** 会话服务最小面(rc 类型面不全,此处宽松化) */
42
+ interface SessionsFace {
43
+ binding(sessionId: string): { session: SessionFace } | undefined
44
+ list: { subscribe(cb: () => void): () => void }
45
+ }
46
+
47
+ interface SessionSnapshotLike {
48
+ openState?: string
49
+ hasMore?: boolean
50
+ loadingOlder?: boolean
51
+ chat?: { nodes?: { values(): Iterable<{ key?: string; anchorSeq?: number; visibility?: string; kind?: string; data?: unknown }> } }
52
+ }
53
+
54
+ interface SessionFace {
55
+ getSnapshot(): SessionSnapshotLike
56
+ loadOlder(): Promise<void>
57
+ subscribe(cb: () => void): () => void
58
+ projections: { faceOf(key: string): { getSnapshot(): unknown; subscribe(cb: () => void): () => void } }
59
+ }
60
+
61
+ function sessionsOf(ctx: ClientContext): SessionsFace {
62
+ return ctx.sessions as unknown as SessionsFace
63
+ }
64
+
65
+ /** 会话行最小面(结构型,非 SDK 绑定) */
66
+ interface ChatRowLike {
67
+ key: string
68
+ anchorSeq: number
69
+ visibility?: string
70
+ kind?: string
71
+ data?: unknown
72
+ }
73
+
74
+ interface RowPayload {
75
+ content?: readonly { type?: string; text?: string }[]
76
+ seq?: number
77
+ time?: number
78
+ }
79
+
80
+ /** 提问行 kind */
81
+ const QUESTION_KINDS = ['user', 'steering']
82
+
83
+ /** 消息首文本块;缺省空串 */
84
+ function firstTextBlock(content: readonly { type?: string; text?: string }[] | undefined): string {
85
+ const block = content?.find((part) => typeof part?.text === 'string')
86
+ return block?.text ?? ''
87
+ }
88
+
89
+ /** 从节点窗口提取提问行(kind 过滤 + 首文本 + 按 anchorSeq 排序) */
90
+ function extractQuestionRows(nodes: Iterable<ChatRowLike>): { key: string; anchorSeq: number; seq: number; time: number; text: string }[] {
91
+ return [...nodes]
92
+ .filter((node) => QUESTION_KINDS.includes(node.kind ?? ''))
93
+ .map((node) => {
94
+ const payload = (typeof node.data === 'object' && node.data !== null ? node.data : {}) as RowPayload
95
+ return {
96
+ key: node.key,
97
+ anchorSeq: node.anchorSeq,
98
+ seq: payload.seq ?? -1,
99
+ time: payload.time ?? 0,
100
+ text: firstTextBlock(payload.content),
101
+ }
102
+ })
103
+ .sort((a, b) => a.anchorSeq - b.anchorSeq)
104
+ }
105
+
106
+
107
+ /** Single-instance guard: a duplicated client injection must not mount twice. */
108
+ declare global {
109
+ // eslint-disable-next-line no-var
110
+ var __dshMegaChatNavApplied: boolean | undefined
111
+ }
112
+
113
+ function claimApply(): boolean {
114
+ if (globalThis.__dshMegaChatNavApplied === true) return false
115
+ globalThis.__dshMegaChatNavApplied = true
116
+ return true
117
+ }
118
+
119
+ function releaseApply(): void {
120
+ globalThis.__dshMegaChatNavApplied = undefined
121
+ }
122
+
123
+ /** Map the session snapshot to the jump-loop port surface. */
124
+ function jumpPortsFor(ctx: ClientContext, sessionId: string): JumpPorts {
125
+ const sessions = sessionsOf(ctx)
126
+ return {
127
+ snap: () => {
128
+ const binding = sessions.binding(sessionId)
129
+ const snap = binding?.session.getSnapshot()
130
+ if (snap === undefined) return undefined
131
+ return {
132
+ openState: snap.openState ?? '',
133
+ hasMore: snap.hasMore ?? false,
134
+ loadingOlder: snap.loadingOlder ?? false,
135
+ rows: (snap.chat?.nodes?.values() ?? []) as unknown as JumpSnapshot['rows'],
136
+ }
137
+ },
138
+ loadMore: async () => {
139
+ const binding = sessions.binding(sessionId)
140
+ if (binding === undefined) throw new Error('session unavailable')
141
+ await binding.session.loadOlder()
142
+ },
143
+ active: () => document.querySelector('[data-chat-flow]') !== null,
144
+ locate: (key: string) => {
145
+ const rows = Array.from(document.querySelectorAll<HTMLElement>('[data-chat-anchor-key]'))
146
+ for (const row of rows) {
147
+ if (row.dataset.chatAnchorKey === key) return row
148
+ }
149
+ // 包含兜底(key 为完整锚点串,唯一命中)
150
+ for (const row of rows) {
151
+ const k = row.dataset.chatAnchorKey
152
+ if (k !== undefined && k.includes(key)) return row
153
+ }
154
+ // seq 兜底:取最后一个匹配(防误命中前一行)
155
+ if (key.startsWith('seq:')) {
156
+ const n = key.slice(4)
157
+ let found: HTMLElement | null = null
158
+ for (const row of rows) {
159
+ const k = row.dataset.chatAnchorKey
160
+ if (k !== undefined && k.includes(n)) found = row
161
+ }
162
+ if (found !== null) return found
163
+ }
164
+ return null
165
+ },
166
+ reveal: (row, mode) => {
167
+ // 目标行顶部与视口顶留 12px 间距(不贴顶,视觉更舒适)
168
+ const scrollToRow = (): void => {
169
+ const port = document.querySelector<HTMLElement>('[data-conversation-scroll]')
170
+ if (port !== null) {
171
+ const rowTop = row.getBoundingClientRect().top - port.getBoundingClientRect().top + port.scrollTop
172
+ port.scrollTo({ top: rowTop - 12, behavior: mode })
173
+ } else {
174
+ row.scrollIntoView({ behavior: mode, block: 'start' })
175
+ }
176
+ }
177
+ scrollToRow()
178
+ // 正文在 loadOlder prepend 落地后可能自动滚回底部(跟随滚动):
179
+ // 校验式重滚——仅当目标行明显偏离视口顶(被正文回滚/未到位)才重滚,
180
+ // 用户主动滚动阅读时不干预
181
+ const ensure = (): void => {
182
+ const port = document.querySelector<HTMLElement>('[data-conversation-scroll]')
183
+ if (port === null) return
184
+ const rowRect = row.getBoundingClientRect()
185
+ const portRect = port.getBoundingClientRect()
186
+ const offset = rowRect.top - portRect.top
187
+ if (offset < -60 || offset > 120) scrollToRow()
188
+ }
189
+ window.setTimeout(ensure, 300)
190
+ window.setTimeout(ensure, 800)
191
+ // 跳转确认高亮:监听目标气泡**进入视口(可见)**后才开始闪烁;
192
+ // 背景经 transition 平滑渐变(非突兀切换),主题色两轮缓慢变化
193
+ const bubble = row.querySelector<HTMLElement>('[class*="bubble"]')
194
+ ?? row.querySelector<HTMLElement>('[class*="messageBody"], [class*="bubbleBody"]')
195
+ if (bubble !== null) {
196
+ const original = bubble.style.background
197
+ const themeBg = 'color-mix(in srgb, var(--dsw-alias-brand-primary) 38%, transparent)'
198
+ bubble.style.transition = 'background 350ms ease'
199
+ const paint = (on: boolean): void => {
200
+ bubble.style.background = on ? themeBg : original
201
+ }
202
+ // 轮询等待气泡可见(进入滚动容器视口);4s 兜底后也执行
203
+ const waitVisible = (): Promise<void> => new Promise((resolve) => {
204
+ const port = document.querySelector<HTMLElement>('[data-conversation-scroll]')
205
+ let tries = 0
206
+ const check = (): void => {
207
+ const r = bubble.getBoundingClientRect()
208
+ const portTop = port !== null ? port.getBoundingClientRect().top : 0
209
+ const portBottom = port !== null ? port.getBoundingClientRect().bottom : window.innerHeight
210
+ if ((r.bottom > portTop && r.top < portBottom) || tries > 40) resolve()
211
+ else {
212
+ tries += 1
213
+ window.setTimeout(check, 100)
214
+ }
215
+ }
216
+ check()
217
+ })
218
+ void waitVisible().then(() => {
219
+ // 两轮切换共约 1.5s:渐变到主题色 → 渐变回 → 再切 → 再回
220
+ paint(true)
221
+ window.setTimeout(() => paint(false), 400)
222
+ window.setTimeout(() => paint(true), 800)
223
+ window.setTimeout(() => paint(false), 1200)
224
+ window.setTimeout(() => { bubble.style.transition = '' }, 1500)
225
+ })
226
+ }
227
+ },
228
+ scrollport: () => document.querySelector<HTMLElement>('[data-conversation-scroll]'),
229
+ now: () => Date.now(),
230
+ sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)),
231
+ }
232
+ }
233
+
234
+ /**
235
+ * The session's `msgNavMessages` projection face (getSnapshot + subscribe).
236
+ * Undefined when the session is not bound or the host unit is not registered
237
+ * (e.g. a headless composition) — the strip then shows live-window dots only.
238
+ */
239
+ function navProjectionOf(ctx: ClientContext, sessionId: string): ObservableFace | undefined {
240
+ const face = sessionsOf(ctx).binding(sessionId)?.session.projections.faceOf('msgNavMessages')
241
+ if (face === undefined) return undefined
242
+ return {
243
+ getSnapshot: () => face.getSnapshot(),
244
+ subscribe: (listener) => face.subscribe(listener),
245
+ }
246
+ }
247
+
248
+ function createInject(ctx: ClientContext, settings: NavSettingsController): NavInjected {
249
+ const sessions = sessionsOf(ctx)
250
+ return {
251
+ readQuestions: (sessionId) => {
252
+ const snap = sessions.binding(sessionId)?.session.getSnapshot()
253
+ if (snap === undefined || snap.chat?.nodes === undefined) return []
254
+ return extractQuestionRows(snap.chat.nodes.values() as unknown as Iterable<ChatRowLike>)
255
+ },
256
+ subscribeList: (cb) => sessions.list.subscribe(cb),
257
+ subscribeContent: (sessionId, cb) => {
258
+ const binding = sessions.binding(sessionId)
259
+ if (binding === undefined) return () => {}
260
+ return binding.session.subscribe(cb)
261
+ },
262
+ questionProjection: (sessionId) => navProjectionOf(ctx, sessionId),
263
+ jump: (sessionId, key, seq, currentSeq) => {
264
+ const ports = jumpPortsFor(ctx, sessionId)
265
+ ports.report = (code: JumpFailureCode) => {
266
+ // Surface the failure through the component via a DOM event the
267
+ // strip listens for; simplest reliable cross-boundary channel here.
268
+ window.dispatchEvent(new CustomEvent('mega-chat-nav:jump-failed', { detail: code }))
269
+ }
270
+ ports.onLoading = (loading: boolean, pages: number) => {
271
+ window.dispatchEvent(new CustomEvent('mega-chat-nav:jump-loading', { detail: { loading, pages } }))
272
+ }
273
+ void goTo(ports, key, {}, settings.getSnapshot().scrollBehavior, seq, currentSeq)
274
+ },
275
+ style: () => settings.getSnapshot().style,
276
+ setStyle: (style) => settings.setStyle(style),
277
+ align: () => settings.getSnapshot().align,
278
+ subscribeSettings: (cb) => settings.subscribe(cb),
279
+ setAlign: (align) => settings.setAlign(align),
280
+ bandHeight: () => settings.getSnapshot().bandHeight,
281
+ setBandHeight: (band) => settings.setBandHeight(band),
282
+ scrollBehavior: () => settings.getSnapshot().scrollBehavior,
283
+ setScrollMode: (mode) => settings.setScrollMode(mode),
284
+ showCount: () => settings.getSnapshot().showCount,
285
+ setShowCount: (mode) => settings.setShowCount(mode),
286
+ showSearch: () => settings.getSnapshot().showSearch,
287
+ setShowSearch: (mode) => settings.setShowSearch(mode),
288
+ showQuickSettings: () => settings.getSnapshot().showQuickSettings,
289
+ setShowQuickSettings: (mode) => settings.setShowQuickSettings(mode),
290
+ showFavorites: () => settings.getSnapshot().showFavorites,
291
+ setShowFavorites: (mode) => settings.setShowFavorites(mode),
292
+ showPaging: () => settings.getSnapshot().showPaging,
293
+ setShowPaging: (mode) => settings.setShowPaging(mode),
294
+ searchScopes: () => settings.getSnapshot().searchScopes,
295
+ setSearchScopes: (scopes) => settings.setSearchScopes(scopes),
296
+ railOffset: () => settings.getSnapshot().railOffset,
297
+ setRailOffset: (offset) => settings.setRailOffset(offset),
298
+ cardCount: () => settings.getSnapshot().cardCount,
299
+ setCardCount: (count) => settings.setCardCount(count),
300
+ cardItems: () => settings.getSnapshot().cardItems,
301
+ setCardItems: (items) => settings.setCardItems(items),
302
+ markTone: () => settings.getSnapshot().markTone,
303
+ setMarkTone: (tone) => settings.setMarkTone(tone),
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Register the mega-chat-nav surface.
309
+ * @param ctx - client root context.
310
+ */
311
+ export function apply(ctx: ClientContext): void {
312
+ if (!claimApply()) return
313
+ ctx.effect(() => releaseApply, 'mega-chat-nav: apply claim')
314
+
315
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'mega-chat-nav: dictionaries')
316
+ const t = ctx.locale.bind(NS)
317
+
318
+ const settings = new NavSettingsController()
319
+ const injected = createInject(ctx, settings)
320
+
321
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
322
+ name: 'shell.overlay',
323
+ id: 'mega-chat-nav',
324
+ order: 900,
325
+ locale: NS,
326
+ inject: () => ({ injected }),
327
+ }, RailDispatch))
328
+
329
+ // mega-settings 成员契约:元信息 seat + 页面 seat(宿主缺席 → 静默等待 → 自足)
330
+ ctx.slots.inject('mega.settings.member', () => ctx.slots.register({
331
+ name: 'mega.settings.member',
332
+ id: 'mega-chat-nav',
333
+ order: 10,
334
+ label: () => t('label'),
335
+ inject: () => ({ description: () => t('desc'), version: PKG_VERSION }),
336
+ }, ListRow))
337
+
338
+ ctx.slots.inject('mega.settings.member.page', () => ctx.slots.register({
339
+ name: 'mega.settings.member.page',
340
+ key: 'mega-chat-nav',
341
+ order: 10,
342
+ label: () => t('label'),
343
+ locale: NS,
344
+ inject: () => ({ injected }),
345
+ }, SettingsPage))
346
+
347
+ // 插件页配置入口:仅当未被 mega-settings 收纳时注册(收纳后注销,避免重复入口)。
348
+ // mega-settings 可能晚于本插件加载——注册后订阅 member 条目,宿主出现即注销。
349
+ {
350
+ let tabDispose: (() => void) | null = null
351
+ const ensurePluginsTab = (): void => {
352
+ const hosted = (ctx.slots.entries?.('mega.settings.member')?.length ?? 0) > 0
353
+ if (hosted) {
354
+ tabDispose?.()
355
+ tabDispose = null
356
+ } else if (tabDispose === null) {
357
+ tabDispose = ctx.slots.register({
358
+ name: 'settings.plugins.tab',
359
+ id: 'mega-chat-nav',
360
+ order: 100,
361
+ label: () => t('label'),
362
+ locale: NS,
363
+ inject: () => ({ injected, slots: ctx.slots }),
364
+ }, StandaloneSettings)
365
+ }
366
+ }
367
+ ensurePluginsTab()
368
+ const offMember = ctx.slots.subscribe?.('mega.settings.member', ensurePluginsTab)
369
+ ctx.effect(() => () => {
370
+ tabDispose?.()
371
+ tabDispose = null
372
+ offMember?.()
373
+ }, 'mega-chat-nav: plugins tab ledger')
374
+ }
375
+
376
+ // 设置控制器挂载:settingsScope 服务就绪后绑定 scope(未就绪时配置读写走默认值降级)
377
+ ctx.inject(['settingsScope'], (scopeCtx) => {
378
+ settings.attach(scopeCtx.get('settingsScope') as SettingsScopeBinder)
379
+ })
380
+ }
381
+
382
+ /** 成员列表行占位(元信息 seat 用;宿主自绘列表时不渲染) */
383
+ function ListRow(): null {
384
+ return null
385
+ }
386
+
387
+ /** 构建注入的包版本常量(见 scripts/build-client.mjs) */
388
+ declare const PKG_VERSION: string
@@ -0,0 +1,293 @@
1
+ // 跳转编排(client 装配层自研):定位目标行——按方向分流(向下滚动 / 向上补历史),
2
+ // 等待渲染并滚动;失败回退。结果码与词条绑定(jump-failed 事件契约)。
3
+ //
4
+ // 关键语义:chat.nodes 只是「虚拟渲染窗口」(DOM 渲染行),目标行在已加载范围内
5
+ // 但未渲染时**不能**当作「需要加载更早历史」——必须按目标 seq 与渲染窗口的关系分流。
6
+
7
+ /** 跳转循环所需的会话快照面 */
8
+ export interface JumpSnapshot {
9
+ openState: string
10
+ hasMore: boolean
11
+ loadingOlder: boolean
12
+ rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>
13
+ }
14
+
15
+ /** 外部端口:会话读写、DOM 行查找、滚动与时钟(由浏览器半装配) */
16
+ export interface JumpPorts {
17
+ snap: () => JumpSnapshot | undefined
18
+ loadMore: () => Promise<void>
19
+ active: () => boolean
20
+ locate: (key: string) => HTMLElement | null
21
+ reveal: (row: HTMLElement, mode: 'smooth' | 'instant') => void
22
+ /** 会话滚动容器(虚拟渲染按 scrollTop 触发) */
23
+ scrollport: () => HTMLElement | null
24
+ now: () => number
25
+ sleep: (ms: number) => Promise<void>
26
+ report?: (code: JumpFailureCode, fallback?: boolean) => void
27
+ /** 扩窗加载进度(loading 状态 + 已加载页数),驱动「正在加载较早记录」提示 */
28
+ onLoading?: (loading: boolean, pages: number) => void
29
+ }
30
+
31
+ export type JumpFailureCode = 'VIEW_INACTIVE' | 'TARGET_HIDDEN' | 'NOT_FOUND' | 'TIMEOUT'
32
+
33
+ interface JumpResult {
34
+ ok: boolean
35
+ code?: JumpFailureCode
36
+ fallback?: boolean
37
+ }
38
+
39
+ interface JumpOptions {
40
+ totalTimeoutMs?: number
41
+ maxPages?: number
42
+ rowWaitMs?: number
43
+ pollMs?: number
44
+ }
45
+
46
+ const BUDGET = {
47
+ totalTimeoutMs: 30_000,
48
+ maxPages: 300,
49
+ rowWaitMs: 8_000,
50
+ pollMs: 60,
51
+ }
52
+
53
+ /** 行是否为可渲染锚点行 */
54
+ function isVisibleRow(row: { visibility?: string }): boolean {
55
+ return row.visibility !== 'hidden'
56
+ }
57
+
58
+ /** 快照中是否已含目标行 */
59
+ function hasTarget(rows: Iterable<{ key: string; visibility?: string }>, key: string): boolean {
60
+ for (const row of rows) {
61
+ if (isVisibleRow(row) && row.key === key) return true
62
+ }
63
+ return false
64
+ }
65
+
66
+ /** 渲染窗口的锚点序号极值;空窗口返回 null */
67
+ function seqRange(rows: Iterable<{ anchorSeq: number }>): { min: number; max: number } | null {
68
+ let min: number | null = null
69
+ let max: number | null = null
70
+ for (const row of rows) {
71
+ if (min === null || row.anchorSeq < min) min = row.anchorSeq
72
+ if (max === null || row.anchorSeq > max) max = row.anchorSeq
73
+ }
74
+ return min === null || max === null ? null : { min, max }
75
+ }
76
+
77
+ /** 当前窗口最小锚点序号;空窗口返回 null */
78
+ function smallestSeq(rows: Iterable<{ anchorSeq: number }>): number | null {
79
+ const range = seqRange(rows)
80
+ return range === null ? null : range.min
81
+ }
82
+
83
+ /** 回退目标:排除指定 key 的最近可渲染行(取最新锚点序号) */
84
+ function retreatTo(
85
+ rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>,
86
+ excludeKey: string,
87
+ ): { key: string; anchorSeq: number } | null {
88
+ let best: { key: string; anchorSeq: number } | null = null
89
+ for (const row of rows) {
90
+ if (row.visibility === 'hidden') continue
91
+ if (row.key === excludeKey) continue
92
+ if (best === null || row.anchorSeq > best.anchorSeq) best = { key: row.key, anchorSeq: row.anchorSeq }
93
+ }
94
+ return best
95
+ }
96
+
97
+ /** 等待某行渲染(预算内轮询);视图失活返回 null */
98
+ async function waitForRow(ports: JumpPorts, rowKey: string, pollMs: number, rowWaitMs: number): Promise<HTMLElement | null> {
99
+ for (let waited = 0; waited <= rowWaitMs; waited += pollMs) {
100
+ if (!ports.active()) return null
101
+ const row = ports.locate(rowKey)
102
+ if (row !== null) return row
103
+ await ports.sleep(pollMs)
104
+ }
105
+ return null
106
+ }
107
+
108
+ /**
109
+ * 滚动定位:按方向逐屏滚动(触发虚拟渲染)直至目标行出现或到达边界。
110
+ * 若快照渲染窗口已含目标但 DOM 尚未挂载(渲染延迟),放慢等待 DOM。
111
+ * 返回命中的行;未命中返回 null。
112
+ */
113
+ async function scrollLocate(
114
+ ports: JumpPorts,
115
+ key: string,
116
+ dir: 1 | -1,
117
+ maxSteps: number,
118
+ stepMs: number,
119
+ ): Promise<HTMLElement | null> {
120
+ const port = ports.scrollport()
121
+ if (port === null) return null
122
+ for (let i = 0; i < maxSteps; i++) {
123
+ const row = ports.locate(key)
124
+ if (row !== null) return row
125
+ const before = port.scrollTop
126
+ const step = Math.max(200, Math.round(port.clientHeight * 0.4))
127
+ port.scrollBy({ top: dir * step })
128
+ await ports.sleep(stepMs)
129
+ const floor = Math.max(0, port.scrollHeight - port.clientHeight)
130
+ if (Math.abs(port.scrollTop - before) < 2) {
131
+ // 到边界:反向回扫若干步(虚拟渲染滞后可能已滚过头)
132
+ if (dir === 1) {
133
+ for (let i = 0; i < 10; i++) {
134
+ const row = ports.locate(key)
135
+ if (row !== null) return row
136
+ port.scrollBy({ top: -Math.round(port.clientHeight * 0.4) })
137
+ await ports.sleep(stepMs)
138
+ }
139
+ }
140
+ return null
141
+ }
142
+ // 滚动后:虚拟渲染窗口已覆盖目标(数据窗口含目标行)→ 放慢等待 DOM 挂载
143
+ const snap = ports.snap()
144
+ if (snap !== undefined && hasTarget(snap.rows, key)) {
145
+ const rendered = await waitForRow(ports, key, Math.max(100, stepMs), 2500)
146
+ if (rendered !== null) return rendered
147
+ return null
148
+ }
149
+ }
150
+ return null
151
+ }
152
+
153
+ /**
154
+ * 跳转到 key 对应行:
155
+ * - 目标已在渲染窗口 → 等待 DOM 并滚动;
156
+ * - 目标在下方/窗口内(targetSeq ≥ 渲染窗口最小值)→ 向下滚动定位(无需加载);
157
+ * - 目标更早(targetSeq < 渲染窗口最小值 或未知)→ 预算内 loadOlder 补历史,每页后向上滚动定位;
158
+ * - 全部失败 → 回退最近可渲染行。
159
+ */
160
+ export async function goTo(
161
+ ports: JumpPorts,
162
+ key: string,
163
+ options: JumpOptions = {},
164
+ mode: 'smooth' | 'instant' = 'smooth',
165
+ targetSeq?: number,
166
+ currentSeq?: number,
167
+ ): Promise<JumpResult> {
168
+ const budget = { ...BUDGET, ...options }
169
+ const fail = (code: JumpFailureCode, fallback = false): JumpResult => {
170
+ ports.report?.(code, fallback)
171
+ return fallback ? { ok: false, code, fallback: true } : { ok: false, code }
172
+ }
173
+
174
+ if (!ports.active()) return fail('VIEW_INACTIVE')
175
+
176
+ const deadline = ports.now() + budget.totalTimeoutMs
177
+ let pages = 0
178
+ let lastNotified = -1
179
+ const notifyLoading = (loading: boolean, count: number): void => {
180
+ const key2 = loading ? count : -1
181
+ if (key2 === lastNotified) return
182
+ lastNotified = key2
183
+ ports.onLoading?.(loading, count)
184
+ }
185
+
186
+ // 阶段一:确保目标进入渲染窗口
187
+ for (;;) {
188
+ const snap = ports.snap()
189
+ if (snap === undefined) { notifyLoading(false, 0); return fail('VIEW_INACTIVE') }
190
+ if (hasTarget(snap.rows, key)) { notifyLoading(false, 0); break }
191
+ if (ports.now() > deadline) { notifyLoading(false, 0); return fail('TIMEOUT') }
192
+
193
+ // 明确在下方(目标比当前阅读位置晚):向下滚动定位,绝不加载。
194
+ // 方向判定优先用当前阅读位置 seq(渲染窗口极值含虚拟缓冲,不可靠)
195
+ if (targetSeq !== undefined) {
196
+ const range = seqRange(snap.rows)
197
+ const below = currentSeq !== undefined
198
+ ? targetSeq > currentSeq
199
+ : range !== null && targetSeq > range.max
200
+ if (below) {
201
+ const found = await scrollLocate(ports, key, 1, 500, budget.pollMs * 2)
202
+ if (found !== null) {
203
+ ports.reveal(found, mode)
204
+ return { ok: true }
205
+ }
206
+ notifyLoading(false, 0)
207
+ return fail('TARGET_HIDDEN')
208
+ }
209
+ }
210
+
211
+ // 其余情况:先向上滚动定位(目标可能已加载但位于虚拟缓冲/视口上方未渲染),
212
+ // 仅在目标距渲染窗口较近时尝试,避免无谓的大范围滚动
213
+ if (!snap.loadingOlder) {
214
+ const range = seqRange(snap.rows)
215
+ const closeEnough = targetSeq === undefined || range === null || targetSeq >= range.min - 40
216
+ if (closeEnough) {
217
+ const up = await scrollLocate(ports, key, -1, 40, budget.pollMs * 2)
218
+ if (up !== null) {
219
+ notifyLoading(false, 0)
220
+ ports.reveal(up, mode)
221
+ return { ok: true }
222
+ }
223
+ }
224
+ }
225
+
226
+ // 更早方向:补历史
227
+ if (snap.openState !== 'open') {
228
+ if (snap.openState === 'error') { notifyLoading(false, 0); return fail('VIEW_INACTIVE') }
229
+ await ports.sleep(budget.pollMs)
230
+ continue
231
+ }
232
+ if (snap.hasMore !== true) { notifyLoading(false, 0); return fail('NOT_FOUND') }
233
+ if (pages >= budget.maxPages) { notifyLoading(false, 0); return fail('TIMEOUT') }
234
+ notifyLoading(true, pages + 1)
235
+ if (snap.loadingOlder) {
236
+ await ports.sleep(budget.pollMs)
237
+ continue
238
+ }
239
+ const before = smallestSeq(snap.rows)
240
+ await ports.loadMore()
241
+ pages += 1
242
+ notifyLoading(true, pages)
243
+ // loadOlder 的 Promise resolve 可能早于数据落地:轮询等待窗口最小 seq 真正前移
244
+ let after = before
245
+ for (let waited = 0; waited <= 5000; waited += budget.pollMs) {
246
+ const s2 = ports.snap()
247
+ const cur = smallestSeq(s2 === undefined ? [] : s2.rows)
248
+ if (cur !== null && (before === null || cur < before)) { after = cur; break }
249
+ await ports.sleep(budget.pollMs)
250
+ }
251
+ if (after === null || (before !== null && after >= before)) {
252
+ // 加载无进展:最后向上滚动定位一次再判失败
253
+ const up = await scrollLocate(ports, key, -1, 40, budget.pollMs * 2)
254
+ if (up !== null) {
255
+ notifyLoading(false, 0)
256
+ ports.reveal(up, mode)
257
+ return { ok: true }
258
+ }
259
+ notifyLoading(false, 0)
260
+ return fail('NOT_FOUND')
261
+ }
262
+ // 循环继续:新加载的行在视口上方,顶部迭代会再向上滚动定位
263
+ }
264
+
265
+ // 阶段二:等待渲染并滚动;隐藏则回退
266
+ const row = await waitForRow(ports, key, budget.pollMs, budget.rowWaitMs)
267
+ if (row !== null) {
268
+ ports.reveal(row, mode)
269
+ return { ok: true }
270
+ }
271
+
272
+ // 目标已入渲染窗口但 DOM 未挂载(虚拟渲染延迟):双向滚动定位兜底
273
+ const snap0 = ports.snap()
274
+ if (snap0 !== undefined && hasTarget(snap0.rows, key)) {
275
+ const located = await scrollLocate(ports, key, -1, 60, budget.pollMs * 2)
276
+ ?? await scrollLocate(ports, key, 1, 60, budget.pollMs * 2)
277
+ if (located !== null) {
278
+ ports.reveal(located, mode)
279
+ return { ok: true }
280
+ }
281
+ }
282
+
283
+ const snap = ports.snap()
284
+ const fallback = retreatTo(snap === undefined ? [] : snap.rows, key)
285
+ if (fallback !== null) {
286
+ const fbRow = await waitForRow(ports, fallback.key, budget.pollMs, budget.rowWaitMs)
287
+ if (fbRow !== null) {
288
+ ports.reveal(fbRow, mode)
289
+ return fail('TARGET_HIDDEN', true)
290
+ }
291
+ }
292
+ return fail('TARGET_HIDDEN', false)
293
+ }