@luziyang2026/dsh-question-nav 0.1.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.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Browser-half entry for the dsh-client-ui-question-nav plugin.
3
+ *
4
+ * Registers one surface into the frame-wide floating layer (`shell.overlay`):
5
+ * a vertical strip on the right 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).
8
+ *
9
+ * Failure policy: nothing here throws at apply time — an external plugin must
10
+ * never take the GUI down.
11
+ */
12
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
13
+ import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
14
+ // Type-only: pulls ui-layout's SlotMap merge ('shell.overlay').
15
+ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
16
+ // Type-only: pulls the locale plugin's Context merge (ctx.locale).
17
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
18
+ import { QuestionNavStrip, type QuestionNavInjected } from './QuestionNavStrip.tsx'
19
+ import { en, zh, type QuestionNavKey } from './locales.ts'
20
+ import { extractQuestions } from '../core/nodes.ts'
21
+ import { jumpToQuestion, type JumpFailureCode, type JumpPorts } from '../core/jump.ts'
22
+
23
+ /** Locale namespace this plugin owns. */
24
+ const NS = 'question-nav'
25
+
26
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
27
+ interface LocaleNamespaceMap {
28
+ /** question-nav surface copy. */
29
+ 'question-nav': QuestionNavKey
30
+ }
31
+ }
32
+
33
+ /** Services required by this plugin. */
34
+ export const inject = ['slots', 'locale', 'sessions']
35
+
36
+ /** Single-instance guard: a duplicated client injection must not mount twice. */
37
+ declare global {
38
+ // eslint-disable-next-line no-var
39
+ var __dshQuestionNavApplied: boolean | undefined
40
+ }
41
+
42
+ function claimApply(): boolean {
43
+ if (globalThis.__dshQuestionNavApplied === true) return false
44
+ globalThis.__dshQuestionNavApplied = true
45
+ return true
46
+ }
47
+
48
+ function releaseApply(): void {
49
+ globalThis.__dshQuestionNavApplied = undefined
50
+ }
51
+
52
+ /** Map the session snapshot to the jump-loop port surface. */
53
+ function jumpPortsFor(ctx: ClientContext, sessionId: SessionId): JumpPorts {
54
+ return {
55
+ snapshot: () => {
56
+ const binding = ctx.sessions.binding(sessionId)
57
+ const snap = binding?.session.getSnapshot()
58
+ if (snap === undefined) return undefined
59
+ return {
60
+ openState: snap.openState,
61
+ hasMore: snap.hasMore,
62
+ loadingOlder: snap.loadingOlder,
63
+ rows: snap.chat.nodes.values(),
64
+ }
65
+ },
66
+ loadOlder: async () => {
67
+ const binding = ctx.sessions.binding(sessionId)
68
+ if (binding === undefined) throw new Error('session unavailable')
69
+ await binding.session.loadOlder()
70
+ },
71
+ isViewActive: () => document.querySelector('[data-chat-flow]') !== null,
72
+ findRow: (key: string) => {
73
+ for (const candidate of Array.from(document.querySelectorAll<HTMLElement>('[data-chat-anchor-key]'))) {
74
+ if (candidate.dataset.chatAnchorKey === key) return candidate
75
+ }
76
+ return null
77
+ },
78
+ scrollIntoView: (row) => row.scrollIntoView({ block: 'start' }),
79
+ now: () => Date.now(),
80
+ sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)),
81
+ }
82
+ }
83
+
84
+ function createInject(ctx: ClientContext): QuestionNavInjected {
85
+ return {
86
+ readQuestions: (sessionId) => {
87
+ const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot()
88
+ if (snap === undefined) return []
89
+ return extractQuestions(snap.chat.nodes.values())
90
+ },
91
+ subscribeList: (cb) => ctx.sessions.list.subscribe(cb),
92
+ subscribeContent: (sessionId, cb) => {
93
+ const binding = ctx.sessions.binding(sessionId)
94
+ if (binding === undefined) return () => {}
95
+ return binding.session.subscribe(cb)
96
+ },
97
+ jump: (sessionId, key) => {
98
+ const ports = jumpPortsFor(ctx, sessionId)
99
+ ports.report = (code: JumpFailureCode) => {
100
+ // Surface the failure through the component via a DOM event the
101
+ // strip listens for; simplest reliable cross-boundary channel here.
102
+ window.dispatchEvent(new CustomEvent('question-nav:jump-failed', { detail: code }))
103
+ }
104
+ void jumpToQuestion(ports, key)
105
+ },
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Register the question-nav surface.
111
+ * @param ctx - client root context.
112
+ */
113
+ export function apply(ctx: ClientContext): void {
114
+ if (!claimApply()) return
115
+ ctx.effect(() => releaseApply, 'question-nav: apply claim')
116
+
117
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'question-nav: dictionaries')
118
+
119
+ const injected = createInject(ctx)
120
+
121
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
122
+ name: 'shell.overlay',
123
+ id: 'question-nav',
124
+ order: 900,
125
+ locale: NS,
126
+ inject: () => injected,
127
+ }, QuestionNavStrip))
128
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Locale dictionaries for the question-nav surface (zh/en). Registered under
3
+ * the `question-nav` namespace; keys are consumed through the bound translator.
4
+ */
5
+ export const zh = {
6
+ 'strip.empty': '本会话还没有提问',
7
+ 'jump.inactive': '聊天视图未激活',
8
+ 'jump.hidden': '目标无独立气泡,已定位到邻近内容',
9
+ 'jump.notfound': '目标未加载或不存在(可能已压缩)',
10
+ 'jump.timeout': '加载历史超时,可重试',
11
+ } as const
12
+
13
+ export const en = {
14
+ 'strip.empty': 'No questions in this session yet',
15
+ 'jump.inactive': 'Chat view is not active',
16
+ 'jump.hidden': 'No dedicated bubble; landed on nearby content',
17
+ 'jump.notfound': 'Target not loaded or missing (maybe compacted)',
18
+ 'jump.timeout': 'Timed out loading history; retry',
19
+ } as const
20
+
21
+ export type QuestionNavKey = keyof typeof zh
@@ -0,0 +1,105 @@
1
+ .rail {
2
+ position: absolute;
3
+ top: 0;
4
+ bottom: 0;
5
+ left: 0;
6
+ z-index: 1;
7
+ width: 44px;
8
+ display: flex;
9
+ flex-direction: column;
10
+ align-items: center;
11
+ box-sizing: border-box;
12
+ background: transparent;
13
+ /* Overlay: let pointer events pass through to the conversation beyond the
14
+ dots, so the embedded minimap never blocks the chat. */
15
+ pointer-events: none;
16
+ }
17
+
18
+ /* Vertically center the dot column when it is short; scroll from the top when
19
+ it is tall (auto margins collapse to 0 on overflow, so nothing clips). */
20
+ .list {
21
+ flex: 1;
22
+ min-height: 0;
23
+ width: 100%;
24
+ overflow-y: auto;
25
+ overflow-x: hidden;
26
+ display: flex;
27
+ flex-direction: column;
28
+ align-items: center;
29
+ gap: 6px;
30
+ padding: 8px 0;
31
+ scrollbar-width: thin;
32
+ }
33
+ .list > *:first-child {
34
+ margin-top: auto;
35
+ }
36
+ .list > *:last-child {
37
+ margin-bottom: auto;
38
+ }
39
+
40
+ .dot {
41
+ flex: none;
42
+ pointer-events: auto;
43
+ width: 8px;
44
+ height: 8px;
45
+ border-radius: 50%;
46
+ padding: 0;
47
+ border: none;
48
+ background: var(--dsw-alias-border-l3);
49
+ cursor: pointer;
50
+ transition: transform 120ms ease, background 120ms ease;
51
+ }
52
+ .dot:hover {
53
+ transform: scale(2);
54
+ background: var(--dsw-alias-brand-primary);
55
+ }
56
+ .dot.active {
57
+ transform: scale(1.6);
58
+ background: var(--dsw-alias-brand-primary);
59
+ }
60
+
61
+ /* Question count, rendered just above the first dot (sits in the list gap). */
62
+ .count {
63
+ flex: none;
64
+ font-size: 10px;
65
+ line-height: 1;
66
+ font-weight: 600;
67
+ color: var(--dsw-alias-label-tertiary);
68
+ user-select: none;
69
+ }
70
+
71
+ /* The centered group: count + dot column, centered together (the list's auto
72
+ margins center it when short; it scrolls as a unit when tall). */
73
+ .dots {
74
+ display: flex;
75
+ flex-direction: column;
76
+ align-items: center;
77
+ gap: 6px;
78
+ }
79
+
80
+ .empty {
81
+ padding: 10px 4px;
82
+ font-size: 11px;
83
+ color: var(--dsw-alias-label-tertiary);
84
+ text-align: center;
85
+ word-break: break-word;
86
+ }
87
+
88
+ /* Instant hover tooltip (rendered in a portal to document.body, so it is never
89
+ clipped by the rail's scroll container). */
90
+ .tooltip {
91
+ position: fixed;
92
+ z-index: 20;
93
+ max-width: 420px;
94
+ background: var(--dsw-alias-bg-layer-1);
95
+ border: 1px solid var(--dsw-alias-border-l1);
96
+ border-radius: 8px;
97
+ padding: 8px 12px;
98
+ font-size: 13px;
99
+ line-height: 18px;
100
+ color: var(--dsw-alias-label-primary);
101
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
102
+ white-space: normal;
103
+ word-break: break-word;
104
+ pointer-events: none;
105
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Jump-to-question orchestration. Pure-ish: takes injected ports (snapshot
3
+ * read, loadOlder, DOM row lookup, view liveness, scroll, timers) so the
4
+ * paging/timeout/fallback loop is unit-testable without a real browser or
5
+ * session. The browser half wires these ports to ctx.sessions + the DOM.
6
+ */
7
+
8
+ import { nearestRenderable } from './nodes.ts'
9
+
10
+ /** The bits of a session snapshot the jump loop needs. */
11
+ export interface JumpSnapshot {
12
+ openState: string
13
+ hasMore: boolean
14
+ loadingOlder: boolean
15
+ /** Renderable chat rows as a key->renderable map (or iterable of rows). */
16
+ rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>
17
+ }
18
+
19
+ export interface JumpPorts {
20
+ /** Read the current snapshot; undefined when the session/view is unavailable. */
21
+ snapshot: () => JumpSnapshot | undefined
22
+ /** Expand the window backwards; rejects/throws on failure. */
23
+ loadOlder: () => Promise<void>
24
+ /** True while the chat view is active (a `[data-chat-flow]` is mounted). */
25
+ isViewActive: () => boolean
26
+ /** Find the DOM row for a chat anchor key; null when not rendered. */
27
+ findRow: (key: string) => HTMLElement | null
28
+ /** Scroll a row into view at the top. */
29
+ scrollIntoView: (row: HTMLElement) => void
30
+ /** Monotonic ms clock. */
31
+ now: () => number
32
+ /** Async sleep. */
33
+ sleep: (ms: number) => Promise<void>
34
+ /** Report a terminal failure to the caller (for a hint). */
35
+ report?: (code: JumpFailureCode, fallback?: boolean) => void
36
+ }
37
+
38
+ export type JumpFailureCode = 'VIEW_INACTIVE' | 'TARGET_HIDDEN' | 'NOT_FOUND' | 'TIMEOUT'
39
+
40
+ export interface JumpResult {
41
+ ok: boolean
42
+ code?: JumpFailureCode
43
+ /** True when we landed on a fallback row rather than the exact target. */
44
+ fallback?: boolean
45
+ }
46
+
47
+ export interface JumpOptions {
48
+ /** Total wall-clock budget for loadOlder paging. */
49
+ totalTimeoutMs?: number
50
+ /** Max loadOlder pages before giving up. */
51
+ maxPages?: number
52
+ /** Poll interval for the row to render after it is known to be in the window. */
53
+ rowWaitMs?: number
54
+ /** Poll interval for state transitions (loadingOlder / openState). */
55
+ pollMs?: number
56
+ }
57
+
58
+ const DEFAULTS = {
59
+ totalTimeoutMs: 15_000,
60
+ maxPages: 100,
61
+ rowWaitMs: 8_000,
62
+ pollMs: 60,
63
+ }
64
+
65
+ function minAnchorSeq(rows: Iterable<{ anchorSeq: number }>): number | null {
66
+ let min: number | null = null
67
+ for (const row of rows) {
68
+ if (min === null || row.anchorSeq < min) min = row.anchorSeq
69
+ }
70
+ return min
71
+ }
72
+
73
+ function renderable(rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>): { key: string; anchorSeq: number }[] {
74
+ const out: { key: string; anchorSeq: number }[] = []
75
+ for (const row of rows) {
76
+ if (row.visibility === 'hidden') continue
77
+ out.push({ key: row.key, anchorSeq: row.anchorSeq })
78
+ }
79
+ return out
80
+ }
81
+
82
+ /**
83
+ * Jump to the row for `key`, paging older content until it is rendered (or the
84
+ * budget is exhausted). Falls back to the nearest renderable row when the
85
+ * exact row is hidden/absent.
86
+ */
87
+ export async function jumpToQuestion(ports: JumpPorts, key: string, options: JumpOptions = {}): Promise<JumpResult> {
88
+ const cfg = { ...DEFAULTS, ...options }
89
+ const fail = (code: JumpFailureCode, fallback = false): JumpResult => {
90
+ ports.report?.(code, fallback)
91
+ return fallback ? { ok: false, code, fallback: true } : { ok: false, code }
92
+ }
93
+
94
+ if (!ports.isViewActive()) return fail('VIEW_INACTIVE')
95
+
96
+ const deadline = ports.now() + cfg.totalTimeoutMs
97
+ let pages = 0
98
+
99
+ // Phase 1: page older until the key appears in the loaded window.
100
+ while (true) {
101
+ const snap = ports.snapshot()
102
+ if (snap === undefined) return fail('VIEW_INACTIVE')
103
+ const rows = renderable(snap.rows)
104
+ if (rows.some((r) => r.key === key)) break
105
+ if (snap.openState !== 'open') {
106
+ if (snap.openState === 'error' || ports.now() > deadline) {
107
+ return fail(snap.openState === 'error' ? 'VIEW_INACTIVE' : 'TIMEOUT')
108
+ }
109
+ await ports.sleep(cfg.pollMs)
110
+ continue
111
+ }
112
+ if (snap.hasMore !== true) return fail('NOT_FOUND')
113
+ if (pages >= cfg.maxPages || ports.now() > deadline) return fail('TIMEOUT')
114
+ if (snap.loadingOlder) {
115
+ await ports.sleep(cfg.pollMs)
116
+ continue
117
+ }
118
+ const before = minAnchorSeq(rows)
119
+ await ports.loadOlder()
120
+ pages += 1
121
+ const afterSnap = ports.snapshot()
122
+ const after = minAnchorSeq(afterSnap === undefined ? [] : afterSnap.rows)
123
+ if (after === null || (before !== null && after >= before)) return fail('NOT_FOUND')
124
+ }
125
+
126
+ // Phase 2: wait for the row to render, then scroll. Fall back if hidden.
127
+ const waitedFor = async (rowKey: string): Promise<HTMLElement | null> => {
128
+ for (let waited = 0; waited <= cfg.rowWaitMs; waited += cfg.pollMs) {
129
+ if (!ports.isViewActive()) return null
130
+ const row = ports.findRow(rowKey)
131
+ if (row !== null) return row
132
+ await ports.sleep(cfg.pollMs)
133
+ }
134
+ return null
135
+ }
136
+
137
+ const row = await waitedFor(key)
138
+ if (row !== null) {
139
+ ports.scrollIntoView(row)
140
+ return { ok: true }
141
+ }
142
+
143
+ const snap = ports.snapshot()
144
+ const fallback = nearestRenderable(snap === undefined ? [] : snap.rows, key)
145
+ if (fallback !== null) {
146
+ const fbRow = await waitedFor(fallback.key)
147
+ if (fbRow !== null) {
148
+ ports.scrollIntoView(fbRow)
149
+ return fail('TARGET_HIDDEN', true)
150
+ }
151
+ }
152
+ return fail('TARGET_HIDDEN', false)
153
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Pure node-indexing logic for the question-nav plugin. No React, no DOM, no
3
+ * Cordis — every function here is a pure transform over chat-node data so it
4
+ * can be unit-tested in isolation (and reused by the browser half).
5
+ */
6
+
7
+ /** One user question as shown in the strip and targeted by a jump. */
8
+ export interface QuestionNode {
9
+ /** Chat anchor key, matches a `[data-chat-anchor-key]` row in the scrollport. */
10
+ key: string
11
+ /** Monotone anchor sequence for ordering and window-min detection. */
12
+ anchorSeq: number
13
+ /** Event seq of the user message. */
14
+ seq: number
15
+ /** Unix ms timestamp. */
16
+ time: number
17
+ /** Full question text — shown in the hover tooltip (not truncated). */
18
+ text: string
19
+ }
20
+
21
+ /** Minimal shape a chat node must expose for indexing (structural, not SDK-bound). */
22
+ export interface ChatNodeLike {
23
+ key: string
24
+ anchorSeq: number
25
+ visibility?: string
26
+ kind?: string
27
+ /** Kind-specific payload (a UserMessageNode for `user`/`steering`). */
28
+ data?: unknown
29
+ }
30
+
31
+ /** Kinds counted as a user question (turn-opening and steering admissions). */
32
+ export const QUESTION_KINDS = ['user', 'steering'] as const
33
+
34
+ /** Narrow `node.data` to the user-message payload we read. */
35
+ interface UserDataLike {
36
+ content?: readonly { type?: string; text?: string }[]
37
+ seq?: number
38
+ time?: number
39
+ }
40
+
41
+ function userData(data: unknown): UserDataLike | undefined {
42
+ if (typeof data !== 'object' || data === null) return undefined
43
+ return data as UserDataLike
44
+ }
45
+
46
+ /** First text block of a user message; falls back to the raw first block. */
47
+ export function messageText(content: readonly { type?: string; text?: string }[] | undefined): string {
48
+ if (content === undefined || content.length === 0) return ''
49
+ const first = content[0]
50
+ if (typeof first?.text === 'string') return first.text
51
+ return ''
52
+ }
53
+
54
+ /** Extract the user questions from a chat-node window, ordered by anchorSeq. */
55
+ export function extractQuestions(nodes: Iterable<ChatNodeLike>): QuestionNode[] {
56
+ const out: QuestionNode[] = []
57
+ for (const node of nodes) {
58
+ if (!QUESTION_KINDS.includes(node.kind as (typeof QUESTION_KINDS)[number])) continue
59
+ const payload = userData(node.data)
60
+ out.push({
61
+ key: node.key,
62
+ anchorSeq: node.anchorSeq,
63
+ seq: payload?.seq ?? -1,
64
+ time: payload?.time ?? 0,
65
+ // Full question text: shown in the hover tooltip (not truncated).
66
+ text: messageText(payload?.content),
67
+ })
68
+ }
69
+ out.sort((a, b) => a.anchorSeq - b.anchorSeq)
70
+ return out
71
+ }
72
+
73
+ /** Whether a node is actually rendered (visible rows only are scroll targets). */
74
+ export function isRenderable(node: ChatNodeLike): boolean {
75
+ return node.visibility !== 'hidden'
76
+ }
77
+
78
+ /** The row of the window that renders the given key (exact match). */
79
+ export function findQuestionRow(nodes: Iterable<ChatNodeLike>, key: string): ChatNodeLike | null {
80
+ for (const node of nodes) {
81
+ if (node.key === key) return node
82
+ }
83
+ return null
84
+ }
85
+
86
+ /** Nearest renderable row for a key that is absent or hidden (compaction, windowing). */
87
+ export function nearestRenderable(
88
+ nodes: Iterable<{ key: string; anchorSeq: number; visibility?: string }>,
89
+ excludeKey: string | undefined,
90
+ ): { key: string; anchorSeq: number } | null {
91
+ let best: { key: string; anchorSeq: number } | null = null
92
+ for (const node of nodes) {
93
+ if (node.visibility === 'hidden') continue
94
+ if (node.key === excludeKey) continue
95
+ if (best === null || node.anchorSeq < best.anchorSeq) best = { key: node.key, anchorSeq: node.anchorSeq }
96
+ }
97
+ return best
98
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Host loader entry for the dsh-client-ui-question-nav plugin — runs in the
3
+ * DSH host process. The plugin is browser-only: the row in cordis.patch.yml
4
+ * mounts this no-op half so the loader sees a real cordis plugin, while the
5
+ * actual UI lives in the browser half (src/client).
6
+ */
7
+ import type { Context } from '@deepseek-ai/cordis'
8
+
9
+ /** Apply the host half (no host behavior for this plugin). */
10
+ export function apply(_ctx: Context): void {}