@luziyang2026/dsh-question-nav 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +176 -340
- package/lib/client.js.map +1 -1
- package/lib/index.js +93 -3
- package/lib/types/client/QuestionNavStrip.d.ts +9 -3
- package/lib/types/client/index.d.ts +9 -8
- package/lib/types/client/locales.d.ts +0 -6
- package/lib/types/core/nodes.d.ts +0 -7
- package/lib/types/core/question-entry.d.ts +17 -0
- package/lib/types/core/turn-dots.d.ts +48 -0
- package/lib/types/index.d.ts +17 -6
- package/lib/types/projection.d.ts +38 -0
- package/package.json +8 -4
- package/src/client/QuestionNavStrip.tsx +76 -118
- package/src/client/index.ts +20 -46
- package/src/client/locales.ts +0 -6
- package/src/client/question-nav.module.css +15 -24
- package/src/core/nodes.ts +0 -14
- package/src/core/question-entry.ts +17 -0
- package/src/core/turn-dots.ts +98 -0
- package/src/index.ts +23 -6
- package/src/projection.ts +98 -0
- package/src/core/history-index.ts +0 -176
|
@@ -1,24 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Question-nav minimap. Renders a vertical column of small round dots overlaid
|
|
3
3
|
* on the LEFT edge of the conversation column (via the frame-wide
|
|
4
|
-
* `shell.overlay` floating layer), vertically centered: one dot per
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* `shell.overlay` floating layer), vertically centered: one dot per turn that
|
|
5
|
+
* claimed at least one user question — strictly aligned with the Trajectory
|
|
6
|
+
* view's turn numbering (turns without a question produce no dot). Hover
|
|
7
|
+
* enlarges a dot and shows an instant tooltip (portal-rendered, no native
|
|
8
|
+
* delay) with the turn label and the turn's question text(s); clicking jumps
|
|
9
|
+
* the chat to that turn's first question.
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* the injected `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* `loadOlder()` only until that specific page is in the window. If the index
|
|
16
|
-
* safety budget is exhausted, a dimmed dashed "load earlier" dot appears above
|
|
17
|
-
* the oldest question and continues the index on click.
|
|
11
|
+
* Data source: the host-folded `questionIndex` session projection (whole
|
|
12
|
+
* history, persisted host-side, pushed live through session/projection
|
|
13
|
+
* frames) read through the injected `questionProjection` face, plus the live
|
|
14
|
+
* chat window's questions merged on top for the brief window before a
|
|
15
|
+
* just-sent question lands in the projection. No render-window expansion, no
|
|
16
|
+
* client-side history paging.
|
|
18
17
|
*
|
|
19
|
-
* Data arrives through the
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* Data arrives through the props shares: the framework `useSessions` hook
|
|
19
|
+
* (current session), the registrant inject face (read/subscribe/project/
|
|
20
|
+
* jump), and the bound locale translator.
|
|
22
21
|
*/
|
|
23
22
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
24
23
|
import { createPortal } from 'react-dom'
|
|
@@ -27,12 +26,20 @@ import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
|
|
27
26
|
// Type-only: pulls the ui-layout SlotMap merge ('shell.overlay').
|
|
28
27
|
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
|
29
28
|
import type { QuestionNode } from '../core/nodes.ts'
|
|
30
|
-
import {
|
|
29
|
+
import type { QuestionEntry } from '../core/question-entry.ts'
|
|
30
|
+
import { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'
|
|
31
31
|
import type { JumpFailureCode } from '../core/jump.ts'
|
|
32
|
-
import type { HistoryIndexOptions, HistoryIndexResult } from '../core/history-index.ts'
|
|
33
32
|
import type { QuestionNavKey } from './locales.ts'
|
|
34
33
|
import styles from './question-nav.module.css'
|
|
35
34
|
|
|
35
|
+
/** Minimal observable shape of a session projection face. */
|
|
36
|
+
export interface ObservableFace {
|
|
37
|
+
/** Current projection value (unknown — validated structurally at read). */
|
|
38
|
+
getSnapshot: () => unknown
|
|
39
|
+
/** Subscribe to value changes; returns an unsubscribe. */
|
|
40
|
+
subscribe: (listener: () => void) => () => void
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
/** Values the registrant inject face supplies (wired in src/client/index.ts). */
|
|
37
44
|
export interface QuestionNavInjected {
|
|
38
45
|
/** Extract the user questions of a session's currently loaded window. */
|
|
@@ -41,10 +48,10 @@ export interface QuestionNavInjected {
|
|
|
41
48
|
subscribeList: (cb: () => void) => () => void
|
|
42
49
|
/** Subscribe to a session's content; returns an unsubscribe. */
|
|
43
50
|
subscribeContent: (sessionId: SessionId, cb: () => void) => () => void
|
|
51
|
+
/** The session's `questionIndex` projection face, when the host unit is registered. */
|
|
52
|
+
questionProjection: (sessionId: SessionId) => ObservableFace | undefined
|
|
44
53
|
/** Jump the chat to a question row (pages the window on demand). */
|
|
45
54
|
jump: (sessionId: SessionId, key: string) => void
|
|
46
|
-
/** Build the full-session question index from the raw history RPC. */
|
|
47
|
-
fetchQuestionIndex: (sessionId: SessionId, options?: HistoryIndexOptions) => Promise<HistoryIndexResult>
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
type ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>
|
|
@@ -58,11 +65,25 @@ const FAILURE_HINTS: Record<JumpFailureCode, QuestionNavKey> = {
|
|
|
58
65
|
|
|
59
66
|
/** Live position of the instant hover tooltip. */
|
|
60
67
|
interface TooltipState {
|
|
61
|
-
|
|
68
|
+
/** Turn label line (e.g. "Turn 32"); null for ungrouped live questions. */
|
|
69
|
+
title: string | null
|
|
70
|
+
/** Question text lines (one per question folded into the dot). */
|
|
71
|
+
lines: readonly string[]
|
|
62
72
|
left: number
|
|
63
73
|
top: number
|
|
64
74
|
}
|
|
65
75
|
|
|
76
|
+
/** Read the projection face value as a question-entry list (structural guard). */
|
|
77
|
+
function projectionEntries(face: ObservableFace | undefined): QuestionEntry[] {
|
|
78
|
+
const value = face?.getSnapshot()
|
|
79
|
+
if (!Array.isArray(value)) return []
|
|
80
|
+
return value.filter((item): item is QuestionEntry =>
|
|
81
|
+
typeof item === 'object' && item !== null
|
|
82
|
+
&& typeof (item as QuestionEntry).id === 'string'
|
|
83
|
+
&& typeof (item as QuestionEntry).seq === 'number'
|
|
84
|
+
&& typeof (item as QuestionEntry).turn === 'number')
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
function findConvRoot(): HTMLElement | null {
|
|
67
88
|
return document.querySelector<HTMLElement>('[data-slot="conversation"] > div[data-phase]')
|
|
68
89
|
}
|
|
@@ -72,22 +93,12 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
72
93
|
const summary = props.useSessions((s) => (s.current === undefined ? undefined : s.byId[s.current]))
|
|
73
94
|
const visible = current !== undefined && summary !== undefined && summary.blank !== true
|
|
74
95
|
|
|
75
|
-
const [
|
|
96
|
+
const [dots, setDots] = useState<TurnDot[]>([])
|
|
76
97
|
const [jumpingKey, setJumpingKey] = useState<string | null>(null)
|
|
77
98
|
const [hint, setHint] = useState<string | null>(null)
|
|
78
99
|
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
|
|
79
|
-
const [loadingIndex, setLoadingIndex] = useState(false)
|
|
80
|
-
const [moreAvailable, setMoreAvailable] = useState(false)
|
|
81
100
|
const panelRef = useRef<HTMLDivElement | null>(null)
|
|
82
101
|
const hintTimerRef = useRef<number | null>(null)
|
|
83
|
-
/** Full-history index from the raw RPC (per current session). */
|
|
84
|
-
const indexRef = useRef<QuestionNode[]>([])
|
|
85
|
-
/** Next beforeSeq to resume from when the index budget was exhausted. */
|
|
86
|
-
const nextBeforeSeqRef = useRef<number | undefined>(undefined)
|
|
87
|
-
/** Abort controller for the in-flight index build. */
|
|
88
|
-
const indexAbortRef = useRef<AbortController | null>(null)
|
|
89
|
-
/** Session whose index build is in flight, to avoid duplicate loops. */
|
|
90
|
-
const buildingSessionRef = useRef<SessionId | null>(null)
|
|
91
102
|
|
|
92
103
|
const showHint = (message: string): void => {
|
|
93
104
|
setHint(message)
|
|
@@ -95,57 +106,25 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
95
106
|
hintTimerRef.current = window.setTimeout(() => setHint(null), 1800)
|
|
96
107
|
}
|
|
97
108
|
|
|
98
|
-
//
|
|
99
|
-
//
|
|
109
|
+
// Recompute the dot list from the projection + live window; subscribe to
|
|
110
|
+
// the projection push frames, session content, and the session list.
|
|
100
111
|
useEffect(() => {
|
|
101
112
|
if (!visible || current === undefined) {
|
|
102
|
-
|
|
103
|
-
nextBeforeSeqRef.current = undefined
|
|
104
|
-
indexAbortRef.current?.abort()
|
|
105
|
-
indexAbortRef.current = null
|
|
106
|
-
buildingSessionRef.current = null
|
|
107
|
-
setQuestions([])
|
|
108
|
-
setLoadingIndex(false)
|
|
109
|
-
setMoreAvailable(false)
|
|
113
|
+
setDots([])
|
|
110
114
|
return
|
|
111
115
|
}
|
|
112
116
|
const sessionId = current
|
|
113
|
-
|
|
114
|
-
indexRef.current = []
|
|
115
|
-
nextBeforeSeqRef.current = undefined
|
|
117
|
+
const face = props.questionProjection(sessionId)
|
|
116
118
|
const refresh = (): void => {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
const startBuild = (options?: HistoryIndexOptions): void => {
|
|
121
|
-
buildingSessionRef.current = sessionId
|
|
122
|
-
const controller = new AbortController()
|
|
123
|
-
indexAbortRef.current = controller
|
|
124
|
-
setLoadingIndex(true)
|
|
125
|
-
setMoreAvailable(false)
|
|
126
|
-
props.fetchQuestionIndex(sessionId, { ...options, signal: controller.signal })
|
|
127
|
-
.then((result) => {
|
|
128
|
-
if (buildingSessionRef.current !== sessionId) return
|
|
129
|
-
indexRef.current = mergeQuestions(result.questions, indexRef.current)
|
|
130
|
-
nextBeforeSeqRef.current = result.nextBeforeSeq
|
|
131
|
-
setMoreAvailable(result.code === 'BUDGET' && result.nextBeforeSeq !== undefined)
|
|
132
|
-
refresh()
|
|
133
|
-
})
|
|
134
|
-
.finally(() => {
|
|
135
|
-
if (buildingSessionRef.current === sessionId) {
|
|
136
|
-
setLoadingIndex(false)
|
|
137
|
-
if (indexAbortRef.current === controller) indexAbortRef.current = null
|
|
138
|
-
buildingSessionRef.current = null
|
|
139
|
-
}
|
|
140
|
-
})
|
|
119
|
+
const grouped = groupQuestionsByTurn(projectionEntries(face))
|
|
120
|
+
setDots(mergeLiveQuestions(grouped, props.readQuestions(sessionId)))
|
|
141
121
|
}
|
|
142
122
|
refresh()
|
|
143
|
-
|
|
123
|
+
const unsubProjection = face?.subscribe(refresh) ?? (() => {})
|
|
144
124
|
const unsubContent = props.subscribeContent(sessionId, refresh)
|
|
145
125
|
const unsubList = props.subscribeList(refresh)
|
|
146
126
|
return () => {
|
|
147
|
-
|
|
148
|
-
indexAbortRef.current = null
|
|
127
|
+
unsubProjection()
|
|
149
128
|
unsubContent()
|
|
150
129
|
unsubList()
|
|
151
130
|
}
|
|
@@ -208,26 +187,21 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
208
187
|
|
|
209
188
|
if (!visible) return null
|
|
210
189
|
|
|
211
|
-
const onJump = (
|
|
190
|
+
const onJump = (dot: TurnDot): void => {
|
|
212
191
|
if (current === undefined) return
|
|
213
|
-
setJumpingKey(
|
|
214
|
-
props.jump(current,
|
|
215
|
-
window.setTimeout(() => setJumpingKey((k) => (k ===
|
|
192
|
+
setJumpingKey(dot.key)
|
|
193
|
+
props.jump(current, dot.key)
|
|
194
|
+
window.setTimeout(() => setJumpingKey((k) => (k === dot.key ? null : k)), 600)
|
|
216
195
|
}
|
|
217
196
|
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
nextBeforeSeqRef.current = result.nextBeforeSeq
|
|
227
|
-
setMoreAvailable(result.code === 'BUDGET' && result.nextBeforeSeq !== undefined)
|
|
228
|
-
setQuestions(mergeQuestions(indexRef.current, props.readQuestions(current)))
|
|
229
|
-
})
|
|
230
|
-
.finally(() => setLoadingIndex(false))
|
|
197
|
+
const openTooltip = (dot: TurnDot, target: HTMLElement): void => {
|
|
198
|
+
const r = target.getBoundingClientRect()
|
|
199
|
+
setTooltip({
|
|
200
|
+
title: dot.turn === null ? null : `Turn ${dot.turn}`,
|
|
201
|
+
lines: dot.texts,
|
|
202
|
+
left: r.right + 10,
|
|
203
|
+
top: r.top,
|
|
204
|
+
})
|
|
231
205
|
}
|
|
232
206
|
|
|
233
207
|
const t = props.t
|
|
@@ -236,38 +210,19 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
236
210
|
<div ref={panelRef} className={styles.rail} data-question-nav="rail">
|
|
237
211
|
{hint !== null ? <div className={styles.hint} role="status">{hint}</div> : null}
|
|
238
212
|
<div className={styles.list}>
|
|
239
|
-
{
|
|
240
|
-
<div className={styles.empty}>{
|
|
213
|
+
{dots.length === 0 ? (
|
|
214
|
+
<div className={styles.empty}>{t('strip.empty')}</div>
|
|
241
215
|
) : (
|
|
242
216
|
<div className={styles.dots}>
|
|
243
|
-
<span className={styles.count}>
|
|
244
|
-
|
|
245
|
-
{loadingIndex ? <span className={styles.countLoading}>{t('strip.loadingSuffix')}</span> : null}
|
|
246
|
-
</span>
|
|
247
|
-
{moreAvailable && !loadingIndex ? (
|
|
248
|
-
<button
|
|
249
|
-
className={`${styles.dot} ${styles.moreDot}`}
|
|
250
|
-
aria-label={t('strip.loadEarlier')}
|
|
251
|
-
title={t('strip.loadEarlier')}
|
|
252
|
-
onMouseEnter={(e) => {
|
|
253
|
-
const r = e.currentTarget.getBoundingClientRect()
|
|
254
|
-
setTooltip({ text: t('strip.loadEarlier'), left: r.right + 10, top: r.top })
|
|
255
|
-
}}
|
|
256
|
-
onMouseLeave={() => setTooltip(null)}
|
|
257
|
-
onClick={onLoadMore}
|
|
258
|
-
/>
|
|
259
|
-
) : null}
|
|
260
|
-
{questions.map((node) => (
|
|
217
|
+
<span className={styles.count}>{dots.length}</span>
|
|
218
|
+
{dots.map((dot) => (
|
|
261
219
|
<button
|
|
262
|
-
key={
|
|
263
|
-
className={jumpingKey ===
|
|
264
|
-
aria-label={
|
|
265
|
-
onMouseEnter={(e) =>
|
|
266
|
-
const r = e.currentTarget.getBoundingClientRect()
|
|
267
|
-
setTooltip({ text: node.text, left: r.right + 10, top: r.top })
|
|
268
|
-
}}
|
|
220
|
+
key={dot.key}
|
|
221
|
+
className={jumpingKey === dot.key ? `${styles.dot} ${styles.active}` : styles.dot}
|
|
222
|
+
aria-label={dot.texts[0] ?? ''}
|
|
223
|
+
onMouseEnter={(e) => openTooltip(dot, e.currentTarget)}
|
|
269
224
|
onMouseLeave={() => setTooltip(null)}
|
|
270
|
-
onClick={() => onJump(
|
|
225
|
+
onClick={() => onJump(dot)}
|
|
271
226
|
/>
|
|
272
227
|
))}
|
|
273
228
|
</div>
|
|
@@ -276,7 +231,10 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
276
231
|
{tooltip !== null
|
|
277
232
|
? createPortal(
|
|
278
233
|
<div className={styles.tooltip} style={{ left: tooltip.left, top: tooltip.top }}>
|
|
279
|
-
{tooltip.
|
|
234
|
+
{tooltip.title !== null ? <div className={styles.tooltipTitle}>{tooltip.title}</div> : null}
|
|
235
|
+
{tooltip.lines.map((line, index) => (
|
|
236
|
+
<div key={index} className={styles.tooltipLine}>{line}</div>
|
|
237
|
+
))}
|
|
280
238
|
</div>,
|
|
281
239
|
document.body,
|
|
282
240
|
)
|
package/src/client/index.ts
CHANGED
|
@@ -3,31 +3,30 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Registers one surface into the frame-wide floating layer (`shell.overlay`):
|
|
5
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
|
|
7
|
-
* scrolls the chat to that question.
|
|
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
8
|
*
|
|
9
|
-
* The
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* The dots are driven by the host-folded `questionIndex` 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.
|
|
15
16
|
*
|
|
16
17
|
* Failure policy: nothing here throws at apply time — an external plugin must
|
|
17
18
|
* never take the GUI down.
|
|
18
19
|
*/
|
|
19
20
|
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
20
21
|
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
|
21
|
-
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
|
22
22
|
// Type-only: pulls ui-layout's SlotMap merge ('shell.overlay').
|
|
23
23
|
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
|
24
24
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
|
25
25
|
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
26
|
-
import { QuestionNavStrip, type QuestionNavInjected } from './QuestionNavStrip.tsx'
|
|
26
|
+
import { QuestionNavStrip, type ObservableFace, type QuestionNavInjected } from './QuestionNavStrip.tsx'
|
|
27
27
|
import { en, zh, type QuestionNavKey } from './locales.ts'
|
|
28
28
|
import { extractQuestions } from '../core/nodes.ts'
|
|
29
29
|
import { jumpToQuestion, type JumpFailureCode, type JumpPorts } from '../core/jump.ts'
|
|
30
|
-
import { buildQuestionIndex, type HistoryIndexOptions, type HistoryIndexResult, type RawEventLike } from '../core/history-index.ts'
|
|
31
30
|
|
|
32
31
|
/** Locale namespace this plugin owns. */
|
|
33
32
|
const NS = 'question-nav'
|
|
@@ -40,7 +39,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
/** Services required by this plugin. */
|
|
43
|
-
export const inject = ['slots', 'locale', 'sessions'
|
|
42
|
+
export const inject = ['slots', 'locale', 'sessions']
|
|
44
43
|
|
|
45
44
|
/** Single-instance guard: a duplicated client injection must not mount twice. */
|
|
46
45
|
declare global {
|
|
@@ -90,45 +89,20 @@ function jumpPortsFor(ctx: ClientContext, sessionId: SessionId): JumpPorts {
|
|
|
90
89
|
}
|
|
91
90
|
}
|
|
92
91
|
|
|
93
|
-
/** Resolve the connection handle (shared API client) as other DSH plugins do. */
|
|
94
|
-
function connectionOf(ctx: ClientContext): ConnectionHandle {
|
|
95
|
-
return ctx.get('connection') as ConnectionHandle
|
|
96
|
-
}
|
|
97
|
-
|
|
98
92
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* SDK's `SessionEvent` is cast to the structural `RawEventLike` at this
|
|
103
|
-
* boundary (the index reader only touches type/seq/time/surfaceOp/data).
|
|
93
|
+
* The session's `questionIndex` projection face (getSnapshot + subscribe).
|
|
94
|
+
* Undefined when the session is not bound or the host unit is not registered
|
|
95
|
+
* (e.g. a headless composition) — the strip then shows live-window dots only.
|
|
104
96
|
*/
|
|
105
|
-
|
|
106
|
-
ctx
|
|
107
|
-
|
|
108
|
-
beforeSeq: number | undefined,
|
|
109
|
-
maxMessages: number,
|
|
110
|
-
): Promise<{ events: readonly { event: RawEventLike }[]; hasMore: boolean } | undefined> {
|
|
111
|
-
const { api } = connectionOf(ctx)
|
|
112
|
-
const { result } = await api.sessions.history({ sessionId, beforeSeq, maxMessages })
|
|
113
|
-
if (!result.ok) return undefined
|
|
97
|
+
function questionProjectionOf(ctx: ClientContext, sessionId: SessionId): ObservableFace | undefined {
|
|
98
|
+
const face = ctx.sessions.binding(sessionId)?.session.projections.faceOf('questionIndex')
|
|
99
|
+
if (face === undefined) return undefined
|
|
114
100
|
return {
|
|
115
|
-
|
|
116
|
-
|
|
101
|
+
getSnapshot: () => face.getSnapshot(),
|
|
102
|
+
subscribe: (listener) => face.subscribe(listener),
|
|
117
103
|
}
|
|
118
104
|
}
|
|
119
105
|
|
|
120
|
-
/** Build the full-session question index from the raw history RPC (no render). */
|
|
121
|
-
function buildIndexFor(
|
|
122
|
-
ctx: ClientContext,
|
|
123
|
-
sessionId: SessionId,
|
|
124
|
-
options: HistoryIndexOptions = {},
|
|
125
|
-
): Promise<HistoryIndexResult> {
|
|
126
|
-
return buildQuestionIndex({
|
|
127
|
-
history: (beforeSeq, maxMessages) => rawHistoryPage(ctx, sessionId, beforeSeq, maxMessages),
|
|
128
|
-
now: () => Date.now(),
|
|
129
|
-
}, options)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
106
|
function createInject(ctx: ClientContext): QuestionNavInjected {
|
|
133
107
|
return {
|
|
134
108
|
readQuestions: (sessionId) => {
|
|
@@ -142,6 +116,7 @@ function createInject(ctx: ClientContext): QuestionNavInjected {
|
|
|
142
116
|
if (binding === undefined) return () => {}
|
|
143
117
|
return binding.session.subscribe(cb)
|
|
144
118
|
},
|
|
119
|
+
questionProjection: (sessionId) => questionProjectionOf(ctx, sessionId),
|
|
145
120
|
jump: (sessionId, key) => {
|
|
146
121
|
const ports = jumpPortsFor(ctx, sessionId)
|
|
147
122
|
ports.report = (code: JumpFailureCode) => {
|
|
@@ -151,7 +126,6 @@ function createInject(ctx: ClientContext): QuestionNavInjected {
|
|
|
151
126
|
}
|
|
152
127
|
void jumpToQuestion(ports, key)
|
|
153
128
|
},
|
|
154
|
-
fetchQuestionIndex: (sessionId, options) => buildIndexFor(ctx, sessionId, options),
|
|
155
129
|
}
|
|
156
130
|
}
|
|
157
131
|
|
package/src/client/locales.ts
CHANGED
|
@@ -4,9 +4,6 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export const zh = {
|
|
6
6
|
'strip.empty': '本会话还没有提问',
|
|
7
|
-
'strip.loadingAll': '正在加载全部历史…',
|
|
8
|
-
'strip.loadingSuffix': '…',
|
|
9
|
-
'strip.loadEarlier': '加载更早的问题',
|
|
10
7
|
'jump.inactive': '聊天视图未激活',
|
|
11
8
|
'jump.hidden': '目标无独立气泡,已定位到邻近内容',
|
|
12
9
|
'jump.notfound': '目标未加载或不存在(可能已压缩)',
|
|
@@ -15,9 +12,6 @@ export const zh = {
|
|
|
15
12
|
|
|
16
13
|
export const en = {
|
|
17
14
|
'strip.empty': 'No questions in this session yet',
|
|
18
|
-
'strip.loadingAll': 'Loading full history…',
|
|
19
|
-
'strip.loadingSuffix': '…',
|
|
20
|
-
'strip.loadEarlier': 'Load earlier questions',
|
|
21
15
|
'jump.inactive': 'Chat view is not active',
|
|
22
16
|
'jump.hidden': 'No dedicated bubble; landed on nearby content',
|
|
23
17
|
'jump.notfound': 'Target not loaded or missing (maybe compacted)',
|
|
@@ -58,17 +58,6 @@
|
|
|
58
58
|
background: var(--dsw-alias-brand-primary);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/* Dimmed placeholder for unloaded older history (below the count, above the
|
|
62
|
-
oldest question): click to page in more history. */
|
|
63
|
-
.moreDot {
|
|
64
|
-
background: transparent;
|
|
65
|
-
border: 1px dashed var(--dsw-alias-border-l3);
|
|
66
|
-
}
|
|
67
|
-
.moreDot:hover {
|
|
68
|
-
background: var(--dsw-alias-brand-primary);
|
|
69
|
-
border-color: var(--dsw-alias-brand-primary);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
61
|
/* Question count, rendered just above the first dot (sits in the list gap). */
|
|
73
62
|
.count {
|
|
74
63
|
flex: none;
|
|
@@ -79,19 +68,6 @@
|
|
|
79
68
|
user-select: none;
|
|
80
69
|
}
|
|
81
70
|
|
|
82
|
-
/* Ellipsis shown while the full history is being expanded. */
|
|
83
|
-
.countLoading {
|
|
84
|
-
margin-left: 2px;
|
|
85
|
-
font-weight: 400;
|
|
86
|
-
color: var(--dsw-alias-brand-primary);
|
|
87
|
-
animation: qnPulse 1.2s ease-in-out infinite;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
@keyframes qnPulse {
|
|
91
|
-
0%, 100% { opacity: 0.4; }
|
|
92
|
-
50% { opacity: 1; }
|
|
93
|
-
}
|
|
94
|
-
|
|
95
71
|
/* The centered group: count + dot column, centered together (the list's auto
|
|
96
72
|
margins center it when short; it scrolls as a unit when tall). */
|
|
97
73
|
.dots {
|
|
@@ -127,3 +103,18 @@
|
|
|
127
103
|
word-break: break-word;
|
|
128
104
|
pointer-events: none;
|
|
129
105
|
}
|
|
106
|
+
|
|
107
|
+
/* Turn label line at the top of the tooltip ("Turn 32"). */
|
|
108
|
+
.tooltipTitle {
|
|
109
|
+
font-size: 11px;
|
|
110
|
+
font-weight: 600;
|
|
111
|
+
color: var(--dsw-alias-label-tertiary);
|
|
112
|
+
margin-bottom: 4px;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* One question text line; consecutive lines (multi-question turns) separate. */
|
|
116
|
+
.tooltipLine + .tooltipLine {
|
|
117
|
+
margin-top: 6px;
|
|
118
|
+
padding-top: 6px;
|
|
119
|
+
border-top: 1px solid var(--dsw-alias-border-l1);
|
|
120
|
+
}
|
package/src/core/nodes.ts
CHANGED
|
@@ -96,17 +96,3 @@ export function nearestRenderable(
|
|
|
96
96
|
}
|
|
97
97
|
return best
|
|
98
98
|
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Merge two question sets (full-history index + live loaded window) into one
|
|
102
|
-
* deduplicated, anchorSeq-ascending list. The window may hold questions that
|
|
103
|
-
* arrived after the index was built; the index may hold questions the window
|
|
104
|
-
* has not loaded yet — union on `key`, newest live copy wins per key.
|
|
105
|
-
*/
|
|
106
|
-
export function mergeQuestions(...sources: readonly (readonly QuestionNode[])[]): QuestionNode[] {
|
|
107
|
-
const byKey = new Map<string, QuestionNode>()
|
|
108
|
-
for (const source of sources) {
|
|
109
|
-
for (const node of source) byKey.set(node.key, node)
|
|
110
|
-
}
|
|
111
|
-
return [...byKey.values()].sort((a, b) => a.anchorSeq - b.anchorSeq)
|
|
112
|
-
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One user question recorded by the `questionIndex` session projection.
|
|
3
|
+
* Shared by the host fold (src/projection.ts) and the browser strip — the
|
|
4
|
+
* wire value is an array of these entries, in seq order.
|
|
5
|
+
*/
|
|
6
|
+
export interface QuestionEntry {
|
|
7
|
+
/** Turn that claimed this question (turn/start the event followed). */
|
|
8
|
+
turn: number
|
|
9
|
+
/** UserMessage id; the client derives the chat anchor key from it. */
|
|
10
|
+
id: string
|
|
11
|
+
/** Event seq of the user/message event (ordering + jump anchor seq). */
|
|
12
|
+
seq: number
|
|
13
|
+
/** Unix ms timestamp of the question. */
|
|
14
|
+
time: number
|
|
15
|
+
/** First text block of the question (hover tooltip body). */
|
|
16
|
+
text: string
|
|
17
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn-aligned dot model for the question-nav strip. Pure transforms over the
|
|
3
|
+
* `questionIndex` projection value plus the live chat window — no React, no
|
|
4
|
+
* DOM — so the grouping and merging rules are unit-testable in isolation.
|
|
5
|
+
*
|
|
6
|
+
* One dot per turn that claimed at least one user question; turns without a
|
|
7
|
+
* question (retry, goal continuation, cancelled empty turns) produce no dot,
|
|
8
|
+
* so dot turn labels may skip numbers but always match the Trajectory view.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { QuestionEntry } from './question-entry.ts'
|
|
12
|
+
import type { QuestionNode } from './nodes.ts'
|
|
13
|
+
|
|
14
|
+
/** The conversation Definition kind whose key a user question node uses. */
|
|
15
|
+
export const MESSAGE_DEFINITION_KIND = 'input-message'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The engine-owned stable chat key for a user question — mirrors
|
|
19
|
+
* `conversationContextKey('input-message', String(id))` from the DSH runtime
|
|
20
|
+
* (verified against that formula in the unit test).
|
|
21
|
+
*/
|
|
22
|
+
export function questionKey(id: unknown): string {
|
|
23
|
+
const kind = MESSAGE_DEFINITION_KIND
|
|
24
|
+
return `${kind.length}:${kind}${String(id)}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One strip dot: a turn's questions (grouped) or one ungrouped live question. */
|
|
28
|
+
export interface TurnDot {
|
|
29
|
+
/** Owning turn number; null for live questions the projection has not seen. */
|
|
30
|
+
readonly turn: number | null
|
|
31
|
+
/** Jump anchor: the chat key of the turn's FIRST question. */
|
|
32
|
+
readonly key: string
|
|
33
|
+
/** Anchor seq of the first question (ordering + jump target). */
|
|
34
|
+
readonly anchorSeq: number
|
|
35
|
+
/** Unix ms of the first question. */
|
|
36
|
+
readonly time: number
|
|
37
|
+
/** Every question text of this dot, in order (tooltip lists them all). */
|
|
38
|
+
readonly texts: readonly string[]
|
|
39
|
+
/** Chat keys of every question folded into this dot (live-merge dedupe). */
|
|
40
|
+
readonly memberKeys: readonly string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fold the projection's question list into one dot per turn. Entries arrive
|
|
45
|
+
* in event order; consecutive same-turn entries merge into a single dot whose
|
|
46
|
+
* anchor is the turn's first question.
|
|
47
|
+
*/
|
|
48
|
+
export function groupQuestionsByTurn(entries: readonly QuestionEntry[]): TurnDot[] {
|
|
49
|
+
const sorted = [...entries].sort((a, b) => a.seq - b.seq)
|
|
50
|
+
const dots: TurnDot[] = []
|
|
51
|
+
for (const entry of sorted) {
|
|
52
|
+
const key = questionKey(entry.id)
|
|
53
|
+
const last = dots.at(-1)
|
|
54
|
+
if (last !== undefined && last.turn === entry.turn) {
|
|
55
|
+
dots[dots.length - 1] = {
|
|
56
|
+
...last,
|
|
57
|
+
texts: [...last.texts, entry.text],
|
|
58
|
+
memberKeys: [...last.memberKeys, key],
|
|
59
|
+
}
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
dots.push({
|
|
63
|
+
turn: entry.turn,
|
|
64
|
+
key,
|
|
65
|
+
anchorSeq: entry.seq,
|
|
66
|
+
time: entry.time,
|
|
67
|
+
texts: [entry.text],
|
|
68
|
+
memberKeys: [key],
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
return dots
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Merge live-window questions the projection has not recorded yet (the brief
|
|
76
|
+
* window before the session/projection push frame lands). A live question
|
|
77
|
+
* whose key is already folded into a dot is dropped (the projected copy
|
|
78
|
+
* wins); the rest become single-question dots with `turn: null`, inserted in
|
|
79
|
+
* anchor-seq order so the strip stays strictly chronological.
|
|
80
|
+
*/
|
|
81
|
+
export function mergeLiveQuestions(
|
|
82
|
+
dots: readonly TurnDot[],
|
|
83
|
+
live: readonly QuestionNode[],
|
|
84
|
+
): TurnDot[] {
|
|
85
|
+
const known = new Set(dots.flatMap(dot => dot.memberKeys))
|
|
86
|
+
const extras: TurnDot[] = live
|
|
87
|
+
.filter(question => !known.has(question.key))
|
|
88
|
+
.map(question => ({
|
|
89
|
+
turn: null,
|
|
90
|
+
key: question.key,
|
|
91
|
+
anchorSeq: question.anchorSeq,
|
|
92
|
+
time: question.time,
|
|
93
|
+
texts: [question.text],
|
|
94
|
+
memberKeys: [question.key],
|
|
95
|
+
}))
|
|
96
|
+
if (extras.length === 0) return [...dots]
|
|
97
|
+
return [...dots, ...extras].sort((a, b) => a.anchorSeq - b.anchorSeq)
|
|
98
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Host
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Host half of the dsh-question-nav plugin — runs in the DSH host process.
|
|
3
|
+
* Registers the `questionIndex` session projection unit: the ordered list of
|
|
4
|
+
* user questions (each tagged with its turn), folded from the session event
|
|
5
|
+
* log by the projection registry, persisted by the projection cache, and
|
|
6
|
+
* delivered to the browser through the standard projection carriers (history
|
|
7
|
+
* tail-page baseline + session/projection push frames). The navigation UI
|
|
8
|
+
* itself lives in the browser half (src/client).
|
|
6
9
|
*/
|
|
7
10
|
import type { Context } from '@deepseek-ai/cordis'
|
|
11
|
+
import { questionIndexProjectionDefinition } from './projection.ts'
|
|
8
12
|
|
|
9
|
-
/**
|
|
10
|
-
export
|
|
13
|
+
/** Cordis plugin name. */
|
|
14
|
+
export const name = 'dsh-question-nav'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Register the `questionIndex` unit. The registry is an optional capability
|
|
18
|
+
* (absent in headless compositions), so registration rides `ctx.inject`:
|
|
19
|
+
* without it the host half simply contributes nothing and the browser strip
|
|
20
|
+
* falls back to live-window questions.
|
|
21
|
+
* @param ctx - plugin context.
|
|
22
|
+
*/
|
|
23
|
+
export function apply(ctx: Context): void {
|
|
24
|
+
ctx.inject(['sessionProjections'], (inner) => {
|
|
25
|
+
inner.sessionProjections.register(questionIndexProjectionDefinition)
|
|
26
|
+
})
|
|
27
|
+
}
|