@luziyang2026/dsh-question-nav 0.2.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@luziyang2026/dsh-question-nav",
3
3
  "description": "In-session question navigator for the DSH web GUI: a vertical minimap of round dots overlaid on the left edge of the conversation column, one dot per user question — hover enlarges and shows the full question text, click jumps to that message.",
4
- "version": "0.2.0",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.7.0",
7
7
  "engines": {
@@ -28,7 +28,6 @@
28
28
  "client": {
29
29
  "inject": [
30
30
  "@deepseek-ai/dsh-client-runtime",
31
- "@deepseek-ai/dsh-client-connection",
32
31
  "@deepseek-ai/dsh-client-ui-slots",
33
32
  "@deepseek-ai/dsh-client-ui-layout",
34
33
  "@deepseek-ai/dsh-client-ui-conversation",
@@ -38,12 +37,12 @@
38
37
  }
39
38
  },
40
39
  "scripts": {
41
- "build": "tsc -p tsconfig.build.json && tsdown",
40
+ "build": "tsc -p tsconfig.build.json && tsc -p tsconfig.build-client.json && tsdown",
42
41
  "prepare": "tsdown",
43
42
  "prepublishOnly": "pnpm typecheck && pnpm test && pnpm build",
44
43
  "watch": "tsdown --watch",
45
44
  "test": "vitest run",
46
- "typecheck": "tsc --noEmit"
45
+ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.host.json"
47
46
  },
48
47
  "peerDependencies": {
49
48
  "react": "^18.2.0",
@@ -57,6 +56,8 @@
57
56
  "@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.1",
58
57
  "@deepseek-ai/dsh-client-ui-layout": "^0.1.1-rc.1",
59
58
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.1",
59
+ "@deepseek-ai/dsh-session": "0.1.1-rc.1",
60
+ "@deepseek-ai/dsh-session-projection": "0.1.1-rc.1",
60
61
  "@testing-library/dom": "^10.4.1",
61
62
  "@testing-library/react": "^16.3.2",
62
63
  "@types/node": "^22.20.0",
@@ -84,5 +85,8 @@
84
85
  "repository": {
85
86
  "type": "git",
86
87
  "url": "https://github.com/AbelKeithsun/dsh-question-nav.git"
88
+ },
89
+ "dependencies": {
90
+ "zod": "^4.4.3"
87
91
  }
88
92
  }
@@ -1,20 +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 user
5
- * question, enlarge on hover. The instant tooltip (a portal-rendered overlay,
6
- * no native-title delay) shows the question's full text; clicking a dot scrolls
7
- * the chat to that question.
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
- * Dots index the WHOLE session history, not just the currently loaded window:
10
- * on show, the strip auto-expands older pages (`loadAllOlder`) so questions
11
- * that still sit behind DSH's "load older" button are surfaced too. While the
12
- * expansion is running the count shows a "…" affordance; if the safety budget
13
- * is exhausted a dimmed "load earlier" dot appears above the oldest question.
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.
14
17
  *
15
- * Data arrives through the four props shares: the framework `useSessions`
16
- * hook (current session), the registrant inject face (read/subscribe/jump/
17
- * load-all), and the bound locale translator.
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.
18
21
  */
19
22
  import { useEffect, useLayoutEffect, useRef, useState } from 'react'
20
23
  import { createPortal } from 'react-dom'
@@ -23,23 +26,32 @@ import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
23
26
  // Type-only: pulls the ui-layout SlotMap merge ('shell.overlay').
24
27
  import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
25
28
  import type { QuestionNode } from '../core/nodes.ts'
29
+ import type { QuestionEntry } from '../core/question-entry.ts'
30
+ import { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'
26
31
  import type { JumpFailureCode } from '../core/jump.ts'
27
- import type { LoadAllOptions, LoadAllResult } from '../core/load-all.ts'
28
32
  import type { QuestionNavKey } from './locales.ts'
29
33
  import styles from './question-nav.module.css'
30
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
+
31
43
  /** Values the registrant inject face supplies (wired in src/client/index.ts). */
32
44
  export interface QuestionNavInjected {
33
- /** Extract the user questions of a session (current loaded window). */
45
+ /** Extract the user questions of a session's currently loaded window. */
34
46
  readQuestions: (sessionId: SessionId) => QuestionNode[]
35
47
  /** Subscribe to the session list; returns an unsubscribe. */
36
48
  subscribeList: (cb: () => void) => () => void
37
49
  /** Subscribe to a session's content; returns an unsubscribe. */
38
50
  subscribeContent: (sessionId: SessionId, cb: () => void) => () => void
39
- /** Jump the chat to a question row. */
51
+ /** The session's `questionIndex` projection face, when the host unit is registered. */
52
+ questionProjection: (sessionId: SessionId) => ObservableFace | undefined
53
+ /** Jump the chat to a question row (pages the window on demand). */
40
54
  jump: (sessionId: SessionId, key: string) => void
41
- /** Expand the session history until every question is loaded. */
42
- loadAllOlder: (sessionId: SessionId, options?: LoadAllOptions) => Promise<LoadAllResult>
43
55
  }
44
56
 
45
57
  type ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>
@@ -53,11 +65,25 @@ const FAILURE_HINTS: Record<JumpFailureCode, QuestionNavKey> = {
53
65
 
54
66
  /** Live position of the instant hover tooltip. */
55
67
  interface TooltipState {
56
- text: string
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[]
57
72
  left: number
58
73
  top: number
59
74
  }
60
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
+
61
87
  function findConvRoot(): HTMLElement | null {
62
88
  return document.querySelector<HTMLElement>('[data-slot="conversation"] > div[data-phase]')
63
89
  }
@@ -67,18 +93,12 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
67
93
  const summary = props.useSessions((s) => (s.current === undefined ? undefined : s.byId[s.current]))
68
94
  const visible = current !== undefined && summary !== undefined && summary.blank !== true
69
95
 
70
- const [questions, setQuestions] = useState<QuestionNode[]>([])
96
+ const [dots, setDots] = useState<TurnDot[]>([])
71
97
  const [jumpingKey, setJumpingKey] = useState<string | null>(null)
72
98
  const [hint, setHint] = useState<string | null>(null)
73
99
  const [tooltip, setTooltip] = useState<TooltipState | null>(null)
74
- const [loadingAll, setLoadingAll] = useState(false)
75
- const [moreAvailable, setMoreAvailable] = useState(false)
76
100
  const panelRef = useRef<HTMLDivElement | null>(null)
77
101
  const hintTimerRef = useRef<number | null>(null)
78
- /** Abort controller for the in-flight expansion (cancelled on session change). */
79
- const loadAllAbortRef = useRef<AbortController | null>(null)
80
- /** Session whose expansion is already running, to avoid duplicate loops. */
81
- const loadingAllSessionRef = useRef<SessionId | null>(null)
82
102
 
83
103
  const showHint = (message: string): void => {
84
104
  setHint(message)
@@ -86,52 +106,29 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
86
106
  hintTimerRef.current = window.setTimeout(() => setHint(null), 1800)
87
107
  }
88
108
 
89
- // Refresh the question list whenever the current session or its content changes.
109
+ // Recompute the dot list from the projection + live window; subscribe to
110
+ // the projection push frames, session content, and the session list.
90
111
  useEffect(() => {
91
112
  if (!visible || current === undefined) {
92
- setQuestions([])
113
+ setDots([])
93
114
  return
94
115
  }
95
- const refresh = (): void => setQuestions(props.readQuestions(current))
116
+ const sessionId = current
117
+ const face = props.questionProjection(sessionId)
118
+ const refresh = (): void => {
119
+ const grouped = groupQuestionsByTurn(projectionEntries(face))
120
+ setDots(mergeLiveQuestions(grouped, props.readQuestions(sessionId)))
121
+ }
96
122
  refresh()
97
- const unsubContent = props.subscribeContent(current, refresh)
123
+ const unsubProjection = face?.subscribe(refresh) ?? (() => {})
124
+ const unsubContent = props.subscribeContent(sessionId, refresh)
98
125
  const unsubList = props.subscribeList(refresh)
99
126
  return () => {
127
+ unsubProjection()
100
128
  unsubContent()
101
129
  unsubList()
102
130
  }
103
- }, [visible, current, props])
104
-
105
- // Auto-expand the full history so collapsed older questions surface as dots.
106
- // Runs once per session; the session notifier drives the list refresh above.
107
- useEffect(() => {
108
- if (!visible || current === undefined) {
109
- loadAllAbortRef.current?.abort()
110
- loadAllAbortRef.current = null
111
- loadingAllSessionRef.current = null
112
- setLoadingAll(false)
113
- setMoreAvailable(false)
114
- return
115
- }
116
- if (loadingAllSessionRef.current === current) return
117
- loadingAllSessionRef.current = current
118
- const controller = new AbortController()
119
- loadAllAbortRef.current = controller
120
- setLoadingAll(true)
121
- setMoreAvailable(false)
122
- props.loadAllOlder(current, { signal: controller.signal })
123
- .then((result) => {
124
- // Budget exhausted but more history still exists: offer "load earlier".
125
- setMoreAvailable(result.code === 'BUDGET' && !result.ok)
126
- })
127
- .finally(() => {
128
- setLoadingAll(false)
129
- if (loadAllAbortRef.current === controller) loadAllAbortRef.current = null
130
- loadingAllSessionRef.current = null
131
- })
132
- return () => {
133
- controller.abort()
134
- }
131
+ // eslint-disable-next-line react-hooks/exhaustive-deps
135
132
  }, [visible, current, props])
136
133
 
137
134
  // Listen for jump-failure events and surface the hint.
@@ -190,22 +187,21 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
190
187
 
191
188
  if (!visible) return null
192
189
 
193
- const onJump = (node: QuestionNode): void => {
190
+ const onJump = (dot: TurnDot): void => {
194
191
  if (current === undefined) return
195
- setJumpingKey(node.key)
196
- props.jump(current, node.key)
197
- window.setTimeout(() => setJumpingKey((k) => (k === node.key ? null : k)), 600)
192
+ setJumpingKey(dot.key)
193
+ props.jump(current, dot.key)
194
+ window.setTimeout(() => setJumpingKey((k) => (k === dot.key ? null : k)), 600)
198
195
  }
199
196
 
200
- const onLoadMore = (): void => {
201
- if (current === undefined) return
202
- setMoreAvailable(false)
203
- setLoadingAll(true)
204
- props.loadAllOlder(current)
205
- .then((result) => {
206
- setMoreAvailable(result.code === 'BUDGET' && !result.ok)
207
- })
208
- .finally(() => setLoadingAll(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
+ })
209
205
  }
210
206
 
211
207
  const t = props.t
@@ -214,38 +210,19 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
214
210
  <div ref={panelRef} className={styles.rail} data-question-nav="rail">
215
211
  {hint !== null ? <div className={styles.hint} role="status">{hint}</div> : null}
216
212
  <div className={styles.list}>
217
- {questions.length === 0 ? (
218
- <div className={styles.empty}>{loadingAll ? t('strip.loadingAll') : t('strip.empty')}</div>
213
+ {dots.length === 0 ? (
214
+ <div className={styles.empty}>{t('strip.empty')}</div>
219
215
  ) : (
220
216
  <div className={styles.dots}>
221
- <span className={styles.count}>
222
- {questions.length}
223
- {loadingAll ? <span className={styles.countLoading}>{t('strip.loadingSuffix')}</span> : null}
224
- </span>
225
- {moreAvailable && !loadingAll ? (
226
- <button
227
- className={`${styles.dot} ${styles.moreDot}`}
228
- aria-label={t('strip.loadEarlier')}
229
- title={t('strip.loadEarlier')}
230
- onMouseEnter={(e) => {
231
- const r = e.currentTarget.getBoundingClientRect()
232
- setTooltip({ text: t('strip.loadEarlier'), left: r.right + 10, top: r.top })
233
- }}
234
- onMouseLeave={() => setTooltip(null)}
235
- onClick={onLoadMore}
236
- />
237
- ) : null}
238
- {questions.map((node) => (
217
+ <span className={styles.count}>{dots.length}</span>
218
+ {dots.map((dot) => (
239
219
  <button
240
- key={node.key}
241
- className={jumpingKey === node.key ? `${styles.dot} ${styles.active}` : styles.dot}
242
- aria-label={node.text}
243
- onMouseEnter={(e) => {
244
- const r = e.currentTarget.getBoundingClientRect()
245
- setTooltip({ text: node.text, left: r.right + 10, top: r.top })
246
- }}
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)}
247
224
  onMouseLeave={() => setTooltip(null)}
248
- onClick={() => onJump(node)}
225
+ onClick={() => onJump(dot)}
249
226
  />
250
227
  ))}
251
228
  </div>
@@ -254,7 +231,10 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
254
231
  {tooltip !== null
255
232
  ? createPortal(
256
233
  <div className={styles.tooltip} style={{ left: tooltip.left, top: tooltip.top }}>
257
- {tooltip.text}
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
+ ))}
258
238
  </div>,
259
239
  document.body,
260
240
  )
@@ -3,10 +3,16 @@
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. Clicking a button
7
- * scrolls the chat to that question (paging older history when needed). The
8
- * strip auto-expands the whole session history so even collapsed older
9
- * questions are surfaced as dots.
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 `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.
10
16
  *
11
17
  * Failure policy: nothing here throws at apply time — an external plugin must
12
18
  * never take the GUI down.
@@ -17,11 +23,10 @@ import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
17
23
  import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
18
24
  // Type-only: pulls the locale plugin's Context merge (ctx.locale).
19
25
  import type {} from '@deepseek-ai/dsh-client-locale/client'
20
- import { QuestionNavStrip, type QuestionNavInjected } from './QuestionNavStrip.tsx'
26
+ import { QuestionNavStrip, type ObservableFace, type QuestionNavInjected } from './QuestionNavStrip.tsx'
21
27
  import { en, zh, type QuestionNavKey } from './locales.ts'
22
28
  import { extractQuestions } from '../core/nodes.ts'
23
29
  import { jumpToQuestion, type JumpFailureCode, type JumpPorts } from '../core/jump.ts'
24
- import { loadAllOlder, type LoadAllOptions, type LoadAllResult } from '../core/load-all.ts'
25
30
 
26
31
  /** Locale namespace this plugin owns. */
27
32
  const NS = 'question-nav'
@@ -84,52 +89,20 @@ function jumpPortsFor(ctx: ClientContext, sessionId: SessionId): JumpPorts {
84
89
  }
85
90
  }
86
91
 
87
- /** Resolve the active conversation scrollport (or null when not mounted). */
88
- function scrollport(): HTMLElement | null {
89
- return document.querySelector<HTMLElement>('[data-conversation-scroll]')
90
- }
91
-
92
92
  /**
93
- * One backward page that preserves the reader's scroll position. DSH's own
94
- * "load older" button arms a paging anchor; a programmatic `loadOlder()` does
95
- * not, so without this compensation prepended content would push the visible
96
- * rows down. We restore by the exact growth of the scrollHeight.
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.
97
96
  */
98
- async function pagedLoadOlder(ctx: ClientContext, sessionId: SessionId): Promise<void> {
99
- const binding = ctx.sessions.binding(sessionId)
100
- if (binding === undefined) return
101
- const port = scrollport()
102
- const beforeHeight = port?.scrollHeight ?? 0
103
- const beforeTop = port?.scrollTop ?? 0
104
- await binding.session.loadOlder()
105
- if (port === null) return
106
- // Let React commit the prepend before measuring the new height.
107
- await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))
108
- const delta = port.scrollHeight - beforeHeight
109
- if (delta > 0) port.scrollTop = beforeTop + delta
110
- }
111
-
112
- /** Map the session to the load-all port surface. */
113
- function loadAllPortsFor(ctx: ClientContext, sessionId: SessionId): Parameters<typeof loadAllOlder>[0] {
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
- snapshot: () => {
116
- const binding = ctx.sessions.binding(sessionId)
117
- const snap = binding?.session.getSnapshot()
118
- if (snap === undefined) return undefined
119
- return { openState: snap.openState, hasMore: snap.hasMore, loadingOlder: snap.loadingOlder }
120
- },
121
- loadOlder: () => pagedLoadOlder(ctx, sessionId),
122
- isViewActive: () => document.querySelector('[data-chat-flow]') !== null,
123
- now: () => Date.now(),
124
- sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)),
101
+ getSnapshot: () => face.getSnapshot(),
102
+ subscribe: (listener) => face.subscribe(listener),
125
103
  }
126
104
  }
127
105
 
128
- /** Expand the whole session history so every question becomes a dot. */
129
- function loadAllFor(ctx: ClientContext, sessionId: SessionId, options: LoadAllOptions = {}): Promise<LoadAllResult> {
130
- return loadAllOlder(loadAllPortsFor(ctx, sessionId), options)
131
- }
132
-
133
106
  function createInject(ctx: ClientContext): QuestionNavInjected {
134
107
  return {
135
108
  readQuestions: (sessionId) => {
@@ -143,6 +116,7 @@ function createInject(ctx: ClientContext): QuestionNavInjected {
143
116
  if (binding === undefined) return () => {}
144
117
  return binding.session.subscribe(cb)
145
118
  },
119
+ questionProjection: (sessionId) => questionProjectionOf(ctx, sessionId),
146
120
  jump: (sessionId, key) => {
147
121
  const ports = jumpPortsFor(ctx, sessionId)
148
122
  ports.report = (code: JumpFailureCode) => {
@@ -152,7 +126,6 @@ function createInject(ctx: ClientContext): QuestionNavInjected {
152
126
  }
153
127
  void jumpToQuestion(ports, key)
154
128
  },
155
- loadAllOlder: (sessionId, options) => loadAllFor(ctx, sessionId, options),
156
129
  }
157
130
  }
158
131
 
@@ -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
+ }
@@ -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
+ }