@luziyang2026/dsh-question-nav 0.2.0 → 0.3.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.
@@ -4,15 +4,21 @@
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
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.
7
+ * scrolls the chat to that question.
8
+ *
9
+ * The strip indexes the WHOLE session history WITHOUT expanding DSH's paged
10
+ * render window: it pages the raw `session.history` RPC (read-only, no render
11
+ * cost) and derives each question's chat anchor key from the event. Only when
12
+ * a dot is clicked does the jump loop call `loadOlder()` to bring that
13
+ * specific page into the window — so the conversation's memory economy is
14
+ * preserved.
10
15
  *
11
16
  * Failure policy: nothing here throws at apply time — an external plugin must
12
17
  * never take the GUI down.
13
18
  */
14
19
  import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
15
20
  import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
21
+ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
16
22
  // Type-only: pulls ui-layout's SlotMap merge ('shell.overlay').
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).
@@ -21,7 +27,7 @@ import { QuestionNavStrip, type QuestionNavInjected } from './QuestionNavStrip.t
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'
30
+ import { buildQuestionIndex, type HistoryIndexOptions, type HistoryIndexResult, type RawEventLike } from '../core/history-index.ts'
25
31
 
26
32
  /** Locale namespace this plugin owns. */
27
33
  const NS = 'question-nav'
@@ -34,7 +40,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
34
40
  }
35
41
 
36
42
  /** Services required by this plugin. */
37
- export const inject = ['slots', 'locale', 'sessions']
43
+ export const inject = ['slots', 'locale', 'sessions', 'connection']
38
44
 
39
45
  /** Single-instance guard: a duplicated client injection must not mount twice. */
40
46
  declare global {
@@ -84,50 +90,43 @@ function jumpPortsFor(ctx: ClientContext, sessionId: SessionId): JumpPorts {
84
90
  }
85
91
  }
86
92
 
87
- /** Resolve the active conversation scrollport (or null when not mounted). */
88
- function scrollport(): HTMLElement | null {
89
- return document.querySelector<HTMLElement>('[data-conversation-scroll]')
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
90
96
  }
91
97
 
92
98
  /**
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.
99
+ * One raw history page, mapped to the pure `buildQuestionIndex` port shape.
100
+ * `beforeSeq` is exclusive; `undefined` reads the newest page. Returns
101
+ * undefined when the page is unavailable so the builder stops cleanly. The
102
+ * SDK's `SessionEvent` is cast to the structural `RawEventLike` at this
103
+ * boundary (the index reader only touches type/seq/time/surfaceOp/data).
97
104
  */
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] {
105
+ async function rawHistoryPage(
106
+ ctx: ClientContext,
107
+ sessionId: SessionId,
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
114
114
  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)),
115
+ events: result.value.events.map((entry) => ({ event: entry.event as unknown as RawEventLike })),
116
+ hasMore: result.value.hasMore,
125
117
  }
126
118
  }
127
119
 
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)
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)
131
130
  }
132
131
 
133
132
  function createInject(ctx: ClientContext): QuestionNavInjected {
@@ -152,7 +151,7 @@ function createInject(ctx: ClientContext): QuestionNavInjected {
152
151
  }
153
152
  void jumpToQuestion(ports, key)
154
153
  },
155
- loadAllOlder: (sessionId, options) => loadAllFor(ctx, sessionId, options),
154
+ fetchQuestionIndex: (sessionId, options) => buildIndexFor(ctx, sessionId, options),
156
155
  }
157
156
  }
158
157
 
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Question-index builder over the raw session history RPC.
3
+ *
4
+ * DSH pages the rendered conversation window on purpose (memory economy):
5
+ * `chat.nodes` only ever holds the loaded window, and force-expanding it
6
+ * (repeated `loadOlder()`) materializes + renders the whole log — the exact
7
+ * cost DSH's paging exists to avoid. This module instead builds a lightweight
8
+ * index of every user question by paging the RAW history RPC (`session.history`
9
+ * with `beforeSeq`), which reads the host log without touching the render
10
+ * window at all. Only `{key, seq, time, text}` per question is retained.
11
+ *
12
+ * The chat anchor key is derived deterministically from the event — it equals
13
+ * `conversationContextKey('input-message', String(event.data.id))` — so the
14
+ * dots can target rows that are not loaded yet, and a click then pages the
15
+ * window on demand (see `jump.ts`).
16
+ *
17
+ * Pure-ish: takes injected ports (one raw history page read, clocks) so it is
18
+ * unit-testable without a browser or a live session.
19
+ */
20
+
21
+ import type { QuestionNode } from './nodes.ts'
22
+ import { messageText } from './nodes.ts'
23
+
24
+ /** Minimal shape of a raw history event (structural, not SDK-bound). */
25
+ export interface RawEventLike {
26
+ type: string
27
+ seq: number
28
+ time: number
29
+ surfaceOp?: unknown
30
+ data?: {
31
+ id?: unknown
32
+ source?: { kind?: string; plugin?: string }
33
+ content?: readonly { type?: string; text?: string }[]
34
+ }
35
+ }
36
+
37
+ /** The conversation Definition kind whose key a user question node uses. */
38
+ export const MESSAGE_DEFINITION_KIND = 'input-message'
39
+
40
+ /**
41
+ * The engine-owned stable chat key for a user question event — mirrors
42
+ * `conversationContextKey('input-message', String(id))` from the DSH runtime
43
+ * (verified against it in the unit test).
44
+ */
45
+ export function questionKey(id: unknown): string {
46
+ const kind = MESSAGE_DEFINITION_KIND
47
+ return `${kind.length}:${kind}${String(id)}`
48
+ }
49
+
50
+ /**
51
+ * Whether a raw event is one user question the strip should index.
52
+ * Mirrors the DSH `messageDefinition` match + `start` classification:
53
+ * an append-origin `user/message` with a human (`user`) source. Replacement
54
+ * copies (compaction checkpoints, `source.kind === 'plugin'`) and injected
55
+ * context (`source.kind !== 'user'`) are excluded.
56
+ */
57
+ export function isQuestionEvent(event: RawEventLike): boolean {
58
+ if (event.type !== 'user/message') return false
59
+ if (event.surfaceOp !== 'append') return false
60
+ return event.data?.source?.kind === 'user'
61
+ }
62
+
63
+ /** Map one raw question event to a strip question node, or null when not one. */
64
+ export function questionFromEvent(event: RawEventLike): QuestionNode | null {
65
+ if (!isQuestionEvent(event)) return null
66
+ return {
67
+ key: questionKey(event.data?.id),
68
+ anchorSeq: event.seq,
69
+ seq: event.seq,
70
+ time: event.time,
71
+ text: messageText(event.data?.content),
72
+ }
73
+ }
74
+
75
+ export interface HistoryIndexPorts {
76
+ /**
77
+ * Read one raw history page. `beforeSeq` is exclusive (events with seq <
78
+ * beforeSeq); `undefined` reads the newest page. Resolves undefined when
79
+ * the page is unavailable (session gone / transport error).
80
+ */
81
+ history: (
82
+ beforeSeq: number | undefined,
83
+ maxMessages: number,
84
+ ) => Promise<{ events: readonly { event: RawEventLike }[]; hasMore: boolean } | undefined>
85
+ /** Monotonic ms clock. */
86
+ now: () => number
87
+ }
88
+
89
+ export interface HistoryIndexOptions {
90
+ /** Raw messages per page (default 100). */
91
+ maxMessages?: number
92
+ /** Max pages before giving up (default 200 => 20k messages). */
93
+ maxPages?: number
94
+ /** Total wall-clock budget (default 30s). */
95
+ totalTimeoutMs?: number
96
+ /** Abort the build; checked every iteration. */
97
+ signal?: AbortSignal
98
+ /** Resume from a previous `nextBeforeSeq` instead of the newest page. */
99
+ startBeforeSeq?: number
100
+ }
101
+
102
+ export type HistoryIndexCode = 'COMPLETE' | 'BUDGET' | 'TIMEOUT' | 'UNAVAILABLE' | 'CANCELLED'
103
+
104
+ export interface HistoryIndexResult {
105
+ ok: boolean
106
+ code: HistoryIndexCode
107
+ /** Questions collected so far, ascending by anchorSeq. */
108
+ questions: QuestionNode[]
109
+ /** Page count actually read. */
110
+ pages: number
111
+ /** Where to continue (exclusive) when stopped early; undefined when COMPLETE. */
112
+ nextBeforeSeq: number | undefined
113
+ }
114
+
115
+ const DEFAULTS = {
116
+ maxMessages: 100,
117
+ maxPages: 200,
118
+ totalTimeoutMs: 30_000,
119
+ }
120
+
121
+ function minSeq(events: readonly { event: RawEventLike }[]): number | undefined {
122
+ let min: number | undefined
123
+ for (const { event } of events) {
124
+ if (min === undefined || event.seq < min) min = event.seq
125
+ }
126
+ return min
127
+ }
128
+
129
+ /**
130
+ * Page the raw session history backward, collecting every user question into a
131
+ * lightweight index. Never touches the render window.
132
+ */
133
+ export async function buildQuestionIndex(
134
+ ports: HistoryIndexPorts,
135
+ options: HistoryIndexOptions = {},
136
+ ): Promise<HistoryIndexResult> {
137
+ const cfg = { ...DEFAULTS, ...options }
138
+ const deadline = ports.now() + cfg.totalTimeoutMs
139
+ const questions: QuestionNode[] = []
140
+ let beforeSeq: number | undefined = cfg.startBeforeSeq
141
+ let pages = 0
142
+
143
+ const cancelled = (): boolean => cfg.signal?.aborted === true
144
+
145
+ while (true) {
146
+ if (cancelled()) return { ok: false, code: 'CANCELLED', questions, pages, nextBeforeSeq: beforeSeq }
147
+ if (ports.now() > deadline) return { ok: false, code: 'TIMEOUT', questions, pages, nextBeforeSeq: beforeSeq }
148
+ if (pages >= cfg.maxPages) return { ok: false, code: 'BUDGET', questions, pages, nextBeforeSeq: beforeSeq }
149
+
150
+ const page = await ports.history(beforeSeq, cfg.maxMessages)
151
+ if (page === undefined) {
152
+ // Transient: retry a little, then give up with what we have.
153
+ if (pages === 0) return { ok: false, code: 'UNAVAILABLE', questions, pages, nextBeforeSeq: beforeSeq }
154
+ return { ok: true, code: 'COMPLETE', questions, pages, nextBeforeSeq: undefined }
155
+ }
156
+
157
+ for (const { event } of page.events) {
158
+ const question = questionFromEvent(event)
159
+ if (question !== null) questions.push(question)
160
+ }
161
+
162
+ if (!page.hasMore) {
163
+ questions.sort((a, b) => a.anchorSeq - b.anchorSeq)
164
+ return { ok: true, code: 'COMPLETE', questions, pages, nextBeforeSeq: undefined }
165
+ }
166
+
167
+ const next = minSeq(page.events)
168
+ if (next === undefined) {
169
+ // Empty page with hasMore true is anomalous; stop cleanly.
170
+ questions.sort((a, b) => a.anchorSeq - b.anchorSeq)
171
+ return { ok: true, code: 'COMPLETE', questions, pages, nextBeforeSeq: undefined }
172
+ }
173
+ beforeSeq = next
174
+ pages += 1
175
+ }
176
+ }
package/src/core/nodes.ts CHANGED
@@ -96,3 +96,17 @@ 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
+ }
@@ -1,97 +0,0 @@
1
- /**
2
- * Load-all orchestration for the question-nav strip.
3
- *
4
- * DSH sessions page history in fixed-size chunks: `chat.nodes` only ever holds
5
- * the currently loaded window, and questions that still sit behind the "load
6
- * older" button are invisible to the strip until the window is expanded
7
- * backwards. This loop pages `loadOlder()` until `hasMore` is false (the whole
8
- * history is materialized), so every user question becomes a dot.
9
- *
10
- * Pure-ish: takes injected ports (snapshot read, one paged loadOlder, view
11
- * liveness, clocks) so it is unit-testable without a browser or session.
12
- */
13
-
14
- export interface LoadAllSnapshot {
15
- openState: string
16
- hasMore: boolean
17
- loadingOlder: boolean
18
- }
19
-
20
- export interface LoadAllPorts {
21
- /** Read the current session snapshot; undefined when unavailable. */
22
- snapshot: () => LoadAllSnapshot | undefined
23
- /** Expand the window backwards by one page (may preserve scroll). */
24
- loadOlder: () => Promise<void>
25
- /** True while the chat view is active (a `[data-chat-flow]` is mounted). */
26
- isViewActive: () => boolean
27
- /** Monotonic ms clock. */
28
- now: () => number
29
- /** Async sleep. */
30
- sleep: (ms: number) => Promise<void>
31
- }
32
-
33
- export interface LoadAllOptions {
34
- /** Max older pages to fetch before giving up (default 400). */
35
- maxPages?: number
36
- /** Total wall-clock budget for the whole expansion (default 60s). */
37
- totalTimeoutMs?: number
38
- /** Poll interval for open/loading transitions (default 60ms). */
39
- pollMs?: number
40
- /** Abort the expansion; checked every iteration. */
41
- signal?: AbortSignal
42
- }
43
-
44
- export type LoadAllCode =
45
- | 'COMPLETE'
46
- | 'VIEW_INACTIVE'
47
- | 'NOT_OPEN'
48
- | 'BUDGET'
49
- | 'TIMEOUT'
50
- | 'CANCELLED'
51
-
52
- export interface LoadAllResult {
53
- ok: boolean
54
- code: LoadAllCode
55
- /** Number of `loadOlder` pages actually fetched. */
56
- pages: number
57
- }
58
-
59
- const DEFAULTS = {
60
- maxPages: 400,
61
- totalTimeoutMs: 60_000,
62
- pollMs: 60,
63
- }
64
-
65
- /**
66
- * Expand the session window backwards until the earliest history is loaded.
67
- * Waits while the session is still opening; aborts on cancellation, budget or
68
- * timeout. Safe to re-enter: once `hasMore` is false the loop returns
69
- * immediately with `COMPLETE`.
70
- */
71
- export async function loadAllOlder(ports: LoadAllPorts, options: LoadAllOptions = {}): Promise<LoadAllResult> {
72
- const cfg = { ...DEFAULTS, ...options }
73
- const deadline = ports.now() + cfg.totalTimeoutMs
74
- let pages = 0
75
-
76
- const cancelled = (): boolean => cfg.signal?.aborted === true
77
-
78
- while (true) {
79
- if (cancelled()) return { ok: false, code: 'CANCELLED', pages }
80
- if (!ports.isViewActive()) return { ok: false, code: 'VIEW_INACTIVE', pages }
81
- const snap = ports.snapshot()
82
- if (snap === undefined) return { ok: false, code: 'VIEW_INACTIVE', pages }
83
- if (snap.openState === 'error') return { ok: false, code: 'NOT_OPEN', pages }
84
- // Nothing older left: the whole history is in the window.
85
- if (snap.hasMore !== true) return { ok: true, code: 'COMPLETE', pages }
86
- if (pages >= cfg.maxPages) return { ok: false, code: 'BUDGET', pages }
87
- if (ports.now() > deadline) return { ok: false, code: 'TIMEOUT', pages }
88
- // Wait while the session is still opening or a page is already in flight
89
- // (a user-initiated "load older" click shares this same gate).
90
- if (snap.openState !== 'open' || snap.loadingOlder) {
91
- await ports.sleep(cfg.pollMs)
92
- continue
93
- }
94
- await ports.loadOlder()
95
- pages += 1
96
- }
97
- }