@luziyang2026/dsh-question-nav 0.1.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.
package/lib/client.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","names":["useState","useRef","styles","createPortal"],"sources":["../src/client/QuestionNavStrip.tsx","../src/client/locales.ts","../src/core/nodes.ts","../src/core/jump.ts","../src/client/index.ts"],"sourcesContent":["/**\n * Question-nav minimap. Renders a vertical column of small round dots overlaid\n * on the LEFT edge of the conversation column (via the frame-wide\n * `shell.overlay` floating layer), vertically centered: one dot per user\n * question, enlarge on hover. The instant tooltip (a portal-rendered overlay,\n * no native-title delay) shows the question's full text; clicking a dot scrolls\n * the chat to that question.\n *\n * Data arrives through the four props shares: the framework `useSessions`\n * hook (current session), the registrant inject face (read/subscribe/jump),\n * and the bound locale translator.\n */\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react'\nimport { createPortal } from 'react-dom'\nimport type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls the ui-layout SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\nimport type { QuestionNode } from '../core/nodes.ts'\nimport type { JumpFailureCode } from '../core/jump.ts'\nimport type { QuestionNavKey } from './locales.ts'\nimport styles from './question-nav.module.css'\n\n/** Values the registrant inject face supplies (wired in src/client/index.ts). */\nexport interface QuestionNavInjected {\n /** Extract the user questions of a session (current loaded window). */\n readQuestions: (sessionId: SessionId) => QuestionNode[]\n /** Subscribe to the session list; returns an unsubscribe. */\n subscribeList: (cb: () => void) => () => void\n /** Subscribe to a session's content; returns an unsubscribe. */\n subscribeContent: (sessionId: SessionId, cb: () => void) => () => void\n /** Jump the chat to a question row. */\n jump: (sessionId: SessionId, key: string) => void\n}\n\ntype ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>\n\nconst FAILURE_HINTS: Record<JumpFailureCode, QuestionNavKey> = {\n VIEW_INACTIVE: 'jump.inactive',\n TARGET_HIDDEN: 'jump.hidden',\n NOT_FOUND: 'jump.notfound',\n TIMEOUT: 'jump.timeout',\n}\n\n/** Live position of the instant hover tooltip. */\ninterface TooltipState {\n text: string\n left: number\n top: number\n}\n\nfunction findConvRoot(): HTMLElement | null {\n return document.querySelector<HTMLElement>('[data-slot=\"conversation\"] > div[data-phase]')\n}\n\nexport function QuestionNavStrip(props: ComponentProps): React.JSX.Element | null {\n const current = props.useSessions((s) => s.current)\n const summary = props.useSessions((s) => (s.current === undefined ? undefined : s.byId[s.current]))\n const visible = current !== undefined && summary !== undefined && summary.blank !== true\n\n const [questions, setQuestions] = useState<QuestionNode[]>([])\n const [jumpingKey, setJumpingKey] = useState<string | null>(null)\n const [hint, setHint] = useState<string | null>(null)\n const [tooltip, setTooltip] = useState<TooltipState | null>(null)\n const panelRef = useRef<HTMLDivElement | null>(null)\n const hintTimerRef = useRef<number | null>(null)\n\n const showHint = (message: string): void => {\n setHint(message)\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n hintTimerRef.current = window.setTimeout(() => setHint(null), 1800)\n }\n\n // Refresh the question list whenever the current session or its content changes.\n useEffect(() => {\n if (!visible || current === undefined) {\n setQuestions([])\n return\n }\n const refresh = (): void => setQuestions(props.readQuestions(current))\n refresh()\n const unsubContent = props.subscribeContent(current, refresh)\n const unsubList = props.subscribeList(refresh)\n return () => {\n unsubContent()\n unsubList()\n }\n }, [visible, current, props])\n\n // Listen for jump-failure events and surface the hint.\n useEffect(() => {\n const onJumpFailed = (event: Event): void => {\n const code = (event as CustomEvent<JumpFailureCode>).detail\n showHint(props.t(FAILURE_HINTS[code] ?? 'jump.timeout'))\n }\n window.addEventListener('question-nav:jump-failed', onJumpFailed)\n return () => window.removeEventListener('question-nav:jump-failed', onJumpFailed)\n }, [props])\n\n // Anchor the minimap to the conversation column: position it at the left\n // edge of the conversation root and reserve a thin rail with padding-left.\n useLayoutEffect(() => {\n if (!visible) return\n let raf = 0\n let retries = 0\n const applyLayout = (): void => {\n const panel = panelRef.current\n if (panel === null) return\n const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null\n const convRoot = findConvRoot()\n if (frame === null || convRoot === null) return\n const frameRect = frame.getBoundingClientRect()\n const convRect = convRoot.getBoundingClientRect()\n if (convRect.height <= 0) {\n if (retries < 20) {\n retries += 1\n raf = requestAnimationFrame(applyLayout)\n }\n return\n }\n retries = 0\n panel.style.top = `${convRect.top - frameRect.top}px`\n panel.style.height = `${convRect.height}px`\n panel.style.left = `${convRect.left - frameRect.left}px`\n }\n applyLayout()\n raf = requestAnimationFrame(applyLayout)\n const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(applyLayout)\n const convRoot = findConvRoot()\n observer?.observe(convRoot ?? document.body, { box: 'border-box' })\n window.addEventListener('resize', applyLayout)\n return () => {\n if (raf !== 0) cancelAnimationFrame(raf)\n observer?.disconnect()\n window.removeEventListener('resize', applyLayout)\n }\n }, [visible])\n\n // Clear any pending hint timer on unmount.\n useEffect(() => () => {\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n }, [])\n\n if (!visible) return null\n\n const onJump = (node: QuestionNode): void => {\n if (current === undefined) return\n setJumpingKey(node.key)\n props.jump(current, node.key)\n window.setTimeout(() => setJumpingKey((k) => (k === node.key ? null : k)), 600)\n }\n\n const t = props.t\n\n return (\n <div ref={panelRef} className={styles.rail} data-question-nav=\"rail\">\n {hint !== null ? <div className={styles.hint} role=\"status\">{hint}</div> : null}\n <div className={styles.list}>\n {questions.length === 0 ? (\n <div className={styles.empty}>{t('strip.empty')}</div>\n ) : (\n <div className={styles.dots}>\n <span className={styles.count}>{questions.length}</span>\n {questions.map((node) => (\n <button\n key={node.key}\n className={jumpingKey === node.key ? `${styles.dot} ${styles.active}` : styles.dot}\n aria-label={node.text}\n onMouseEnter={(e) => {\n const r = e.currentTarget.getBoundingClientRect()\n setTooltip({ text: node.text, left: r.right + 10, top: r.top })\n }}\n onMouseLeave={() => setTooltip(null)}\n onClick={() => onJump(node)}\n />\n ))}\n </div>\n )}\n </div>\n {tooltip !== null\n ? createPortal(\n <div className={styles.tooltip} style={{ left: tooltip.left, top: tooltip.top }}>\n {tooltip.text}\n </div>,\n document.body,\n )\n : null}\n </div>\n )\n}\n","/**\n * Locale dictionaries for the question-nav surface (zh/en). Registered under\n * the `question-nav` namespace; keys are consumed through the bound translator.\n */\nexport const zh = {\n 'strip.empty': '本会话还没有提问',\n 'jump.inactive': '聊天视图未激活',\n 'jump.hidden': '目标无独立气泡,已定位到邻近内容',\n 'jump.notfound': '目标未加载或不存在(可能已压缩)',\n 'jump.timeout': '加载历史超时,可重试',\n} as const\n\nexport const en = {\n 'strip.empty': 'No questions in this session yet',\n 'jump.inactive': 'Chat view is not active',\n 'jump.hidden': 'No dedicated bubble; landed on nearby content',\n 'jump.notfound': 'Target not loaded or missing (maybe compacted)',\n 'jump.timeout': 'Timed out loading history; retry',\n} as const\n\nexport type QuestionNavKey = keyof typeof zh\n","/**\n * Pure node-indexing logic for the question-nav plugin. No React, no DOM, no\n * Cordis — every function here is a pure transform over chat-node data so it\n * can be unit-tested in isolation (and reused by the browser half).\n */\n\n/** One user question as shown in the strip and targeted by a jump. */\nexport interface QuestionNode {\n /** Chat anchor key, matches a `[data-chat-anchor-key]` row in the scrollport. */\n key: string\n /** Monotone anchor sequence for ordering and window-min detection. */\n anchorSeq: number\n /** Event seq of the user message. */\n seq: number\n /** Unix ms timestamp. */\n time: number\n /** Full question text — shown in the hover tooltip (not truncated). */\n text: string\n}\n\n/** Minimal shape a chat node must expose for indexing (structural, not SDK-bound). */\nexport interface ChatNodeLike {\n key: string\n anchorSeq: number\n visibility?: string\n kind?: string\n /** Kind-specific payload (a UserMessageNode for `user`/`steering`). */\n data?: unknown\n}\n\n/** Kinds counted as a user question (turn-opening and steering admissions). */\nexport const QUESTION_KINDS = ['user', 'steering'] as const\n\n/** Narrow `node.data` to the user-message payload we read. */\ninterface UserDataLike {\n content?: readonly { type?: string; text?: string }[]\n seq?: number\n time?: number\n}\n\nfunction userData(data: unknown): UserDataLike | undefined {\n if (typeof data !== 'object' || data === null) return undefined\n return data as UserDataLike\n}\n\n/** First text block of a user message; falls back to the raw first block. */\nexport function messageText(content: readonly { type?: string; text?: string }[] | undefined): string {\n if (content === undefined || content.length === 0) return ''\n const first = content[0]\n if (typeof first?.text === 'string') return first.text\n return ''\n}\n\n/** Extract the user questions from a chat-node window, ordered by anchorSeq. */\nexport function extractQuestions(nodes: Iterable<ChatNodeLike>): QuestionNode[] {\n const out: QuestionNode[] = []\n for (const node of nodes) {\n if (!QUESTION_KINDS.includes(node.kind as (typeof QUESTION_KINDS)[number])) continue\n const payload = userData(node.data)\n out.push({\n key: node.key,\n anchorSeq: node.anchorSeq,\n seq: payload?.seq ?? -1,\n time: payload?.time ?? 0,\n // Full question text: shown in the hover tooltip (not truncated).\n text: messageText(payload?.content),\n })\n }\n out.sort((a, b) => a.anchorSeq - b.anchorSeq)\n return out\n}\n\n/** Whether a node is actually rendered (visible rows only are scroll targets). */\nexport function isRenderable(node: ChatNodeLike): boolean {\n return node.visibility !== 'hidden'\n}\n\n/** The row of the window that renders the given key (exact match). */\nexport function findQuestionRow(nodes: Iterable<ChatNodeLike>, key: string): ChatNodeLike | null {\n for (const node of nodes) {\n if (node.key === key) return node\n }\n return null\n}\n\n/** Nearest renderable row for a key that is absent or hidden (compaction, windowing). */\nexport function nearestRenderable(\n nodes: Iterable<{ key: string; anchorSeq: number; visibility?: string }>,\n excludeKey: string | undefined,\n): { key: string; anchorSeq: number } | null {\n let best: { key: string; anchorSeq: number } | null = null\n for (const node of nodes) {\n if (node.visibility === 'hidden') continue\n if (node.key === excludeKey) continue\n if (best === null || node.anchorSeq < best.anchorSeq) best = { key: node.key, anchorSeq: node.anchorSeq }\n }\n return best\n}\n","/**\n * Jump-to-question orchestration. Pure-ish: takes injected ports (snapshot\n * read, loadOlder, DOM row lookup, view liveness, scroll, timers) so the\n * paging/timeout/fallback loop is unit-testable without a real browser or\n * session. The browser half wires these ports to ctx.sessions + the DOM.\n */\n\nimport { nearestRenderable } from './nodes.ts'\n\n/** The bits of a session snapshot the jump loop needs. */\nexport interface JumpSnapshot {\n openState: string\n hasMore: boolean\n loadingOlder: boolean\n /** Renderable chat rows as a key->renderable map (or iterable of rows). */\n rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>\n}\n\nexport interface JumpPorts {\n /** Read the current snapshot; undefined when the session/view is unavailable. */\n snapshot: () => JumpSnapshot | undefined\n /** Expand the window backwards; rejects/throws on failure. */\n loadOlder: () => Promise<void>\n /** True while the chat view is active (a `[data-chat-flow]` is mounted). */\n isViewActive: () => boolean\n /** Find the DOM row for a chat anchor key; null when not rendered. */\n findRow: (key: string) => HTMLElement | null\n /** Scroll a row into view at the top. */\n scrollIntoView: (row: HTMLElement) => void\n /** Monotonic ms clock. */\n now: () => number\n /** Async sleep. */\n sleep: (ms: number) => Promise<void>\n /** Report a terminal failure to the caller (for a hint). */\n report?: (code: JumpFailureCode, fallback?: boolean) => void\n}\n\nexport type JumpFailureCode = 'VIEW_INACTIVE' | 'TARGET_HIDDEN' | 'NOT_FOUND' | 'TIMEOUT'\n\nexport interface JumpResult {\n ok: boolean\n code?: JumpFailureCode\n /** True when we landed on a fallback row rather than the exact target. */\n fallback?: boolean\n}\n\nexport interface JumpOptions {\n /** Total wall-clock budget for loadOlder paging. */\n totalTimeoutMs?: number\n /** Max loadOlder pages before giving up. */\n maxPages?: number\n /** Poll interval for the row to render after it is known to be in the window. */\n rowWaitMs?: number\n /** Poll interval for state transitions (loadingOlder / openState). */\n pollMs?: number\n}\n\nconst DEFAULTS = {\n totalTimeoutMs: 15_000,\n maxPages: 100,\n rowWaitMs: 8_000,\n pollMs: 60,\n}\n\nfunction minAnchorSeq(rows: Iterable<{ anchorSeq: number }>): number | null {\n let min: number | null = null\n for (const row of rows) {\n if (min === null || row.anchorSeq < min) min = row.anchorSeq\n }\n return min\n}\n\nfunction renderable(rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>): { key: string; anchorSeq: number }[] {\n const out: { key: string; anchorSeq: number }[] = []\n for (const row of rows) {\n if (row.visibility === 'hidden') continue\n out.push({ key: row.key, anchorSeq: row.anchorSeq })\n }\n return out\n}\n\n/**\n * Jump to the row for `key`, paging older content until it is rendered (or the\n * budget is exhausted). Falls back to the nearest renderable row when the\n * exact row is hidden/absent.\n */\nexport async function jumpToQuestion(ports: JumpPorts, key: string, options: JumpOptions = {}): Promise<JumpResult> {\n const cfg = { ...DEFAULTS, ...options }\n const fail = (code: JumpFailureCode, fallback = false): JumpResult => {\n ports.report?.(code, fallback)\n return fallback ? { ok: false, code, fallback: true } : { ok: false, code }\n }\n\n if (!ports.isViewActive()) return fail('VIEW_INACTIVE')\n\n const deadline = ports.now() + cfg.totalTimeoutMs\n let pages = 0\n\n // Phase 1: page older until the key appears in the loaded window.\n while (true) {\n const snap = ports.snapshot()\n if (snap === undefined) return fail('VIEW_INACTIVE')\n const rows = renderable(snap.rows)\n if (rows.some((r) => r.key === key)) break\n if (snap.openState !== 'open') {\n if (snap.openState === 'error' || ports.now() > deadline) {\n return fail(snap.openState === 'error' ? 'VIEW_INACTIVE' : 'TIMEOUT')\n }\n await ports.sleep(cfg.pollMs)\n continue\n }\n if (snap.hasMore !== true) return fail('NOT_FOUND')\n if (pages >= cfg.maxPages || ports.now() > deadline) return fail('TIMEOUT')\n if (snap.loadingOlder) {\n await ports.sleep(cfg.pollMs)\n continue\n }\n const before = minAnchorSeq(rows)\n await ports.loadOlder()\n pages += 1\n const afterSnap = ports.snapshot()\n const after = minAnchorSeq(afterSnap === undefined ? [] : afterSnap.rows)\n if (after === null || (before !== null && after >= before)) return fail('NOT_FOUND')\n }\n\n // Phase 2: wait for the row to render, then scroll. Fall back if hidden.\n const waitedFor = async (rowKey: string): Promise<HTMLElement | null> => {\n for (let waited = 0; waited <= cfg.rowWaitMs; waited += cfg.pollMs) {\n if (!ports.isViewActive()) return null\n const row = ports.findRow(rowKey)\n if (row !== null) return row\n await ports.sleep(cfg.pollMs)\n }\n return null\n }\n\n const row = await waitedFor(key)\n if (row !== null) {\n ports.scrollIntoView(row)\n return { ok: true }\n }\n\n const snap = ports.snapshot()\n const fallback = nearestRenderable(snap === undefined ? [] : snap.rows, key)\n if (fallback !== null) {\n const fbRow = await waitedFor(fallback.key)\n if (fbRow !== null) {\n ports.scrollIntoView(fbRow)\n return fail('TARGET_HIDDEN', true)\n }\n }\n return fail('TARGET_HIDDEN', false)\n}\n","/**\n * Browser-half entry for the dsh-client-ui-question-nav plugin.\n *\n * Registers one surface into the frame-wide floating layer (`shell.overlay`):\n * a vertical strip on the right edge of the conversation column listing every\n * user question in the current session as a small button. Clicking a button\n * scrolls the chat to that question (paging older history when needed).\n *\n * Failure policy: nothing here throws at apply time — an external plugin must\n * never take the GUI down.\n */\nimport type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls ui-layout's SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\n// Type-only: pulls the locale plugin's Context merge (ctx.locale).\nimport type {} from '@deepseek-ai/dsh-client-locale/client'\nimport { QuestionNavStrip, type QuestionNavInjected } from './QuestionNavStrip.tsx'\nimport { en, zh, type QuestionNavKey } from './locales.ts'\nimport { extractQuestions } from '../core/nodes.ts'\nimport { jumpToQuestion, type JumpFailureCode, type JumpPorts } from '../core/jump.ts'\n\n/** Locale namespace this plugin owns. */\nconst NS = 'question-nav'\n\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n /** question-nav surface copy. */\n 'question-nav': QuestionNavKey\n }\n}\n\n/** Services required by this plugin. */\nexport const inject = ['slots', 'locale', 'sessions']\n\n/** Single-instance guard: a duplicated client injection must not mount twice. */\ndeclare global {\n // eslint-disable-next-line no-var\n var __dshQuestionNavApplied: boolean | undefined\n}\n\nfunction claimApply(): boolean {\n if (globalThis.__dshQuestionNavApplied === true) return false\n globalThis.__dshQuestionNavApplied = true\n return true\n}\n\nfunction releaseApply(): void {\n globalThis.__dshQuestionNavApplied = undefined\n}\n\n/** Map the session snapshot to the jump-loop port surface. */\nfunction jumpPortsFor(ctx: ClientContext, sessionId: SessionId): JumpPorts {\n return {\n snapshot: () => {\n const binding = ctx.sessions.binding(sessionId)\n const snap = binding?.session.getSnapshot()\n if (snap === undefined) return undefined\n return {\n openState: snap.openState,\n hasMore: snap.hasMore,\n loadingOlder: snap.loadingOlder,\n rows: snap.chat.nodes.values(),\n }\n },\n loadOlder: async () => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) throw new Error('session unavailable')\n await binding.session.loadOlder()\n },\n isViewActive: () => document.querySelector('[data-chat-flow]') !== null,\n findRow: (key: string) => {\n for (const candidate of Array.from(document.querySelectorAll<HTMLElement>('[data-chat-anchor-key]'))) {\n if (candidate.dataset.chatAnchorKey === key) return candidate\n }\n return null\n },\n scrollIntoView: (row) => row.scrollIntoView({ block: 'start' }),\n now: () => Date.now(),\n sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)),\n }\n}\n\nfunction createInject(ctx: ClientContext): QuestionNavInjected {\n return {\n readQuestions: (sessionId) => {\n const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot()\n if (snap === undefined) return []\n return extractQuestions(snap.chat.nodes.values())\n },\n subscribeList: (cb) => ctx.sessions.list.subscribe(cb),\n subscribeContent: (sessionId, cb) => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) return () => {}\n return binding.session.subscribe(cb)\n },\n jump: (sessionId, key) => {\n const ports = jumpPortsFor(ctx, sessionId)\n ports.report = (code: JumpFailureCode) => {\n // Surface the failure through the component via a DOM event the\n // strip listens for; simplest reliable cross-boundary channel here.\n window.dispatchEvent(new CustomEvent('question-nav:jump-failed', { detail: code }))\n }\n void jumpToQuestion(ports, key)\n },\n }\n}\n\n/**\n * Register the question-nav surface.\n * @param ctx - client root context.\n */\nexport function apply(ctx: ClientContext): void {\n if (!claimApply()) return\n ctx.effect(() => releaseApply, 'question-nav: apply claim')\n\n ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'question-nav: dictionaries')\n\n const injected = createInject(ctx)\n\n ctx.slots.inject('shell.overlay', () => ctx.slots.register({\n name: 'shell.overlay',\n id: 'question-nav',\n order: 900,\n locale: NS,\n inject: () => injected,\n }, QuestionNavStrip))\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"client.js","names":["useState","useRef","styles","createPortal","DEFAULTS","kind"],"sources":["../src/core/nodes.ts","../src/client/QuestionNavStrip.tsx","../src/client/locales.ts","../src/core/jump.ts","../src/core/history-index.ts","../src/client/index.ts"],"sourcesContent":["/**\n * Pure node-indexing logic for the question-nav plugin. No React, no DOM, no\n * Cordis — every function here is a pure transform over chat-node data so it\n * can be unit-tested in isolation (and reused by the browser half).\n */\n\n/** One user question as shown in the strip and targeted by a jump. */\nexport interface QuestionNode {\n /** Chat anchor key, matches a `[data-chat-anchor-key]` row in the scrollport. */\n key: string\n /** Monotone anchor sequence for ordering and window-min detection. */\n anchorSeq: number\n /** Event seq of the user message. */\n seq: number\n /** Unix ms timestamp. */\n time: number\n /** Full question text — shown in the hover tooltip (not truncated). */\n text: string\n}\n\n/** Minimal shape a chat node must expose for indexing (structural, not SDK-bound). */\nexport interface ChatNodeLike {\n key: string\n anchorSeq: number\n visibility?: string\n kind?: string\n /** Kind-specific payload (a UserMessageNode for `user`/`steering`). */\n data?: unknown\n}\n\n/** Kinds counted as a user question (turn-opening and steering admissions). */\nexport const QUESTION_KINDS = ['user', 'steering'] as const\n\n/** Narrow `node.data` to the user-message payload we read. */\ninterface UserDataLike {\n content?: readonly { type?: string; text?: string }[]\n seq?: number\n time?: number\n}\n\nfunction userData(data: unknown): UserDataLike | undefined {\n if (typeof data !== 'object' || data === null) return undefined\n return data as UserDataLike\n}\n\n/** First text block of a user message; falls back to the raw first block. */\nexport function messageText(content: readonly { type?: string; text?: string }[] | undefined): string {\n if (content === undefined || content.length === 0) return ''\n const first = content[0]\n if (typeof first?.text === 'string') return first.text\n return ''\n}\n\n/** Extract the user questions from a chat-node window, ordered by anchorSeq. */\nexport function extractQuestions(nodes: Iterable<ChatNodeLike>): QuestionNode[] {\n const out: QuestionNode[] = []\n for (const node of nodes) {\n if (!QUESTION_KINDS.includes(node.kind as (typeof QUESTION_KINDS)[number])) continue\n const payload = userData(node.data)\n out.push({\n key: node.key,\n anchorSeq: node.anchorSeq,\n seq: payload?.seq ?? -1,\n time: payload?.time ?? 0,\n // Full question text: shown in the hover tooltip (not truncated).\n text: messageText(payload?.content),\n })\n }\n out.sort((a, b) => a.anchorSeq - b.anchorSeq)\n return out\n}\n\n/** Whether a node is actually rendered (visible rows only are scroll targets). */\nexport function isRenderable(node: ChatNodeLike): boolean {\n return node.visibility !== 'hidden'\n}\n\n/** The row of the window that renders the given key (exact match). */\nexport function findQuestionRow(nodes: Iterable<ChatNodeLike>, key: string): ChatNodeLike | null {\n for (const node of nodes) {\n if (node.key === key) return node\n }\n return null\n}\n\n/** Nearest renderable row for a key that is absent or hidden (compaction, windowing). */\nexport function nearestRenderable(\n nodes: Iterable<{ key: string; anchorSeq: number; visibility?: string }>,\n excludeKey: string | undefined,\n): { key: string; anchorSeq: number } | null {\n let best: { key: string; anchorSeq: number } | null = null\n for (const node of nodes) {\n if (node.visibility === 'hidden') continue\n if (node.key === excludeKey) continue\n if (best === null || node.anchorSeq < best.anchorSeq) best = { key: node.key, anchorSeq: node.anchorSeq }\n }\n return best\n}\n\n/**\n * Merge two question sets (full-history index + live loaded window) into one\n * deduplicated, anchorSeq-ascending list. The window may hold questions that\n * arrived after the index was built; the index may hold questions the window\n * has not loaded yet — union on `key`, newest live copy wins per key.\n */\nexport function mergeQuestions(...sources: readonly (readonly QuestionNode[])[]): QuestionNode[] {\n const byKey = new Map<string, QuestionNode>()\n for (const source of sources) {\n for (const node of source) byKey.set(node.key, node)\n }\n return [...byKey.values()].sort((a, b) => a.anchorSeq - b.anchorSeq)\n}\n","/**\n * Question-nav minimap. Renders a vertical column of small round dots overlaid\n * on the LEFT edge of the conversation column (via the frame-wide\n * `shell.overlay` floating layer), vertically centered: one dot per user\n * question, enlarge on hover. The instant tooltip (a portal-rendered overlay,\n * no native-title delay) shows the question's full text; clicking a dot scrolls\n * the chat to that question.\n *\n * Index strategy (no render-window expansion): the dots cover the WHOLE\n * session history. The index is built from the raw `session.history` RPC via\n * the injected `fetchQuestionIndex` — the conversation's paged window is\n * untouched, so DSH's memory economy is preserved. The loaded window's live\n * questions are merged on top (for new messages arriving after the index was\n * built). Clicking a dot jumps through the existing paging loop, which calls\n * `loadOlder()` only until that specific page is in the window. If the index\n * safety budget is exhausted, a dimmed dashed \"load earlier\" dot appears above\n * the oldest question and continues the index on click.\n *\n * Data arrives through the four props shares: the framework `useSessions`\n * hook (current session), the registrant inject face (read/subscribe/jump/\n * fetch-index), and the bound locale translator.\n */\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react'\nimport { createPortal } from 'react-dom'\nimport type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls the ui-layout SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\nimport type { QuestionNode } from '../core/nodes.ts'\nimport { mergeQuestions } from '../core/nodes.ts'\nimport type { JumpFailureCode } from '../core/jump.ts'\nimport type { HistoryIndexOptions, HistoryIndexResult } from '../core/history-index.ts'\nimport type { QuestionNavKey } from './locales.ts'\nimport styles from './question-nav.module.css'\n\n/** Values the registrant inject face supplies (wired in src/client/index.ts). */\nexport interface QuestionNavInjected {\n /** Extract the user questions of a session's currently loaded window. */\n readQuestions: (sessionId: SessionId) => QuestionNode[]\n /** Subscribe to the session list; returns an unsubscribe. */\n subscribeList: (cb: () => void) => () => void\n /** Subscribe to a session's content; returns an unsubscribe. */\n subscribeContent: (sessionId: SessionId, cb: () => void) => () => void\n /** Jump the chat to a question row (pages the window on demand). */\n jump: (sessionId: SessionId, key: string) => void\n /** Build the full-session question index from the raw history RPC. */\n fetchQuestionIndex: (sessionId: SessionId, options?: HistoryIndexOptions) => Promise<HistoryIndexResult>\n}\n\ntype ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>\n\nconst FAILURE_HINTS: Record<JumpFailureCode, QuestionNavKey> = {\n VIEW_INACTIVE: 'jump.inactive',\n TARGET_HIDDEN: 'jump.hidden',\n NOT_FOUND: 'jump.notfound',\n TIMEOUT: 'jump.timeout',\n}\n\n/** Live position of the instant hover tooltip. */\ninterface TooltipState {\n text: string\n left: number\n top: number\n}\n\nfunction findConvRoot(): HTMLElement | null {\n return document.querySelector<HTMLElement>('[data-slot=\"conversation\"] > div[data-phase]')\n}\n\nexport function QuestionNavStrip(props: ComponentProps): React.JSX.Element | null {\n const current = props.useSessions((s) => s.current)\n const summary = props.useSessions((s) => (s.current === undefined ? undefined : s.byId[s.current]))\n const visible = current !== undefined && summary !== undefined && summary.blank !== true\n\n const [questions, setQuestions] = useState<QuestionNode[]>([])\n const [jumpingKey, setJumpingKey] = useState<string | null>(null)\n const [hint, setHint] = useState<string | null>(null)\n const [tooltip, setTooltip] = useState<TooltipState | null>(null)\n const [loadingIndex, setLoadingIndex] = useState(false)\n const [moreAvailable, setMoreAvailable] = useState(false)\n const panelRef = useRef<HTMLDivElement | null>(null)\n const hintTimerRef = useRef<number | null>(null)\n /** Full-history index from the raw RPC (per current session). */\n const indexRef = useRef<QuestionNode[]>([])\n /** Next beforeSeq to resume from when the index budget was exhausted. */\n const nextBeforeSeqRef = useRef<number | undefined>(undefined)\n /** Abort controller for the in-flight index build. */\n const indexAbortRef = useRef<AbortController | null>(null)\n /** Session whose index build is in flight, to avoid duplicate loops. */\n const buildingSessionRef = useRef<SessionId | null>(null)\n\n const showHint = (message: string): void => {\n setHint(message)\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n hintTimerRef.current = window.setTimeout(() => setHint(null), 1800)\n }\n\n // Build the full-history index on show; refresh the live window on content\n // change and merge both into the dot list.\n useEffect(() => {\n if (!visible || current === undefined) {\n indexRef.current = []\n nextBeforeSeqRef.current = undefined\n indexAbortRef.current?.abort()\n indexAbortRef.current = null\n buildingSessionRef.current = null\n setQuestions([])\n setLoadingIndex(false)\n setMoreAvailable(false)\n return\n }\n const sessionId = current\n // Reset the per-session index: this effect re-runs on session change.\n indexRef.current = []\n nextBeforeSeqRef.current = undefined\n const refresh = (): void => {\n const windowQuestions = props.readQuestions(sessionId)\n setQuestions(mergeQuestions(indexRef.current, windowQuestions))\n }\n const startBuild = (options?: HistoryIndexOptions): void => {\n buildingSessionRef.current = sessionId\n const controller = new AbortController()\n indexAbortRef.current = controller\n setLoadingIndex(true)\n setMoreAvailable(false)\n props.fetchQuestionIndex(sessionId, { ...options, signal: controller.signal })\n .then((result) => {\n if (buildingSessionRef.current !== sessionId) return\n indexRef.current = mergeQuestions(result.questions, indexRef.current)\n nextBeforeSeqRef.current = result.nextBeforeSeq\n setMoreAvailable(result.code === 'BUDGET' && result.nextBeforeSeq !== undefined)\n refresh()\n })\n .finally(() => {\n if (buildingSessionRef.current === sessionId) {\n setLoadingIndex(false)\n if (indexAbortRef.current === controller) indexAbortRef.current = null\n buildingSessionRef.current = null\n }\n })\n }\n refresh()\n startBuild()\n const unsubContent = props.subscribeContent(sessionId, refresh)\n const unsubList = props.subscribeList(refresh)\n return () => {\n indexAbortRef.current?.abort()\n indexAbortRef.current = null\n unsubContent()\n unsubList()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [visible, current, props])\n\n // Listen for jump-failure events and surface the hint.\n useEffect(() => {\n const onJumpFailed = (event: Event): void => {\n const code = (event as CustomEvent<JumpFailureCode>).detail\n showHint(props.t(FAILURE_HINTS[code] ?? 'jump.timeout'))\n }\n window.addEventListener('question-nav:jump-failed', onJumpFailed)\n return () => window.removeEventListener('question-nav:jump-failed', onJumpFailed)\n }, [props])\n\n // Anchor the minimap to the conversation column: position it at the left\n // edge of the conversation root and reserve a thin rail with padding-left.\n useLayoutEffect(() => {\n if (!visible) return\n let raf = 0\n let retries = 0\n const applyLayout = (): void => {\n const panel = panelRef.current\n if (panel === null) return\n const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null\n const convRoot = findConvRoot()\n if (frame === null || convRoot === null) return\n const frameRect = frame.getBoundingClientRect()\n const convRect = convRoot.getBoundingClientRect()\n if (convRect.height <= 0) {\n if (retries < 20) {\n retries += 1\n raf = requestAnimationFrame(applyLayout)\n }\n return\n }\n retries = 0\n panel.style.top = `${convRect.top - frameRect.top}px`\n panel.style.height = `${convRect.height}px`\n panel.style.left = `${convRect.left - frameRect.left}px`\n }\n applyLayout()\n raf = requestAnimationFrame(applyLayout)\n const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(applyLayout)\n const convRoot = findConvRoot()\n observer?.observe(convRoot ?? document.body, { box: 'border-box' })\n window.addEventListener('resize', applyLayout)\n return () => {\n if (raf !== 0) cancelAnimationFrame(raf)\n observer?.disconnect()\n window.removeEventListener('resize', applyLayout)\n }\n }, [visible])\n\n // Clear any pending hint timer on unmount.\n useEffect(() => () => {\n if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)\n }, [])\n\n if (!visible) return null\n\n const onJump = (node: QuestionNode): void => {\n if (current === undefined) return\n setJumpingKey(node.key)\n props.jump(current, node.key)\n window.setTimeout(() => setJumpingKey((k) => (k === node.key ? null : k)), 600)\n }\n\n const onLoadMore = (): void => {\n if (current === undefined || nextBeforeSeqRef.current === undefined) return\n setMoreAvailable(false)\n setLoadingIndex(true)\n props.fetchQuestionIndex(current, { startBeforeSeq: nextBeforeSeqRef.current })\n .then((result) => {\n if (current === undefined) return\n indexRef.current = mergeQuestions(result.questions, indexRef.current)\n nextBeforeSeqRef.current = result.nextBeforeSeq\n setMoreAvailable(result.code === 'BUDGET' && result.nextBeforeSeq !== undefined)\n setQuestions(mergeQuestions(indexRef.current, props.readQuestions(current)))\n })\n .finally(() => setLoadingIndex(false))\n }\n\n const t = props.t\n\n return (\n <div ref={panelRef} className={styles.rail} data-question-nav=\"rail\">\n {hint !== null ? <div className={styles.hint} role=\"status\">{hint}</div> : null}\n <div className={styles.list}>\n {questions.length === 0 ? (\n <div className={styles.empty}>{loadingIndex ? t('strip.loadingAll') : t('strip.empty')}</div>\n ) : (\n <div className={styles.dots}>\n <span className={styles.count}>\n {questions.length}\n {loadingIndex ? <span className={styles.countLoading}>{t('strip.loadingSuffix')}</span> : null}\n </span>\n {moreAvailable && !loadingIndex ? (\n <button\n className={`${styles.dot} ${styles.moreDot}`}\n aria-label={t('strip.loadEarlier')}\n title={t('strip.loadEarlier')}\n onMouseEnter={(e) => {\n const r = e.currentTarget.getBoundingClientRect()\n setTooltip({ text: t('strip.loadEarlier'), left: r.right + 10, top: r.top })\n }}\n onMouseLeave={() => setTooltip(null)}\n onClick={onLoadMore}\n />\n ) : null}\n {questions.map((node) => (\n <button\n key={node.key}\n className={jumpingKey === node.key ? `${styles.dot} ${styles.active}` : styles.dot}\n aria-label={node.text}\n onMouseEnter={(e) => {\n const r = e.currentTarget.getBoundingClientRect()\n setTooltip({ text: node.text, left: r.right + 10, top: r.top })\n }}\n onMouseLeave={() => setTooltip(null)}\n onClick={() => onJump(node)}\n />\n ))}\n </div>\n )}\n </div>\n {tooltip !== null\n ? createPortal(\n <div className={styles.tooltip} style={{ left: tooltip.left, top: tooltip.top }}>\n {tooltip.text}\n </div>,\n document.body,\n )\n : null}\n </div>\n )\n}\n","/**\n * Locale dictionaries for the question-nav surface (zh/en). Registered under\n * the `question-nav` namespace; keys are consumed through the bound translator.\n */\nexport const zh = {\n 'strip.empty': '本会话还没有提问',\n 'strip.loadingAll': '正在加载全部历史…',\n 'strip.loadingSuffix': '…',\n 'strip.loadEarlier': '加载更早的问题',\n 'jump.inactive': '聊天视图未激活',\n 'jump.hidden': '目标无独立气泡,已定位到邻近内容',\n 'jump.notfound': '目标未加载或不存在(可能已压缩)',\n 'jump.timeout': '加载历史超时,可重试',\n} as const\n\nexport const en = {\n 'strip.empty': 'No questions in this session yet',\n 'strip.loadingAll': 'Loading full history…',\n 'strip.loadingSuffix': '…',\n 'strip.loadEarlier': 'Load earlier questions',\n 'jump.inactive': 'Chat view is not active',\n 'jump.hidden': 'No dedicated bubble; landed on nearby content',\n 'jump.notfound': 'Target not loaded or missing (maybe compacted)',\n 'jump.timeout': 'Timed out loading history; retry',\n} as const\n\nexport type QuestionNavKey = keyof typeof zh\n","/**\n * Jump-to-question orchestration. Pure-ish: takes injected ports (snapshot\n * read, loadOlder, DOM row lookup, view liveness, scroll, timers) so the\n * paging/timeout/fallback loop is unit-testable without a real browser or\n * session. The browser half wires these ports to ctx.sessions + the DOM.\n */\n\nimport { nearestRenderable } from './nodes.ts'\n\n/** The bits of a session snapshot the jump loop needs. */\nexport interface JumpSnapshot {\n openState: string\n hasMore: boolean\n loadingOlder: boolean\n /** Renderable chat rows as a key->renderable map (or iterable of rows). */\n rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>\n}\n\nexport interface JumpPorts {\n /** Read the current snapshot; undefined when the session/view is unavailable. */\n snapshot: () => JumpSnapshot | undefined\n /** Expand the window backwards; rejects/throws on failure. */\n loadOlder: () => Promise<void>\n /** True while the chat view is active (a `[data-chat-flow]` is mounted). */\n isViewActive: () => boolean\n /** Find the DOM row for a chat anchor key; null when not rendered. */\n findRow: (key: string) => HTMLElement | null\n /** Scroll a row into view at the top. */\n scrollIntoView: (row: HTMLElement) => void\n /** Monotonic ms clock. */\n now: () => number\n /** Async sleep. */\n sleep: (ms: number) => Promise<void>\n /** Report a terminal failure to the caller (for a hint). */\n report?: (code: JumpFailureCode, fallback?: boolean) => void\n}\n\nexport type JumpFailureCode = 'VIEW_INACTIVE' | 'TARGET_HIDDEN' | 'NOT_FOUND' | 'TIMEOUT'\n\nexport interface JumpResult {\n ok: boolean\n code?: JumpFailureCode\n /** True when we landed on a fallback row rather than the exact target. */\n fallback?: boolean\n}\n\nexport interface JumpOptions {\n /** Total wall-clock budget for loadOlder paging. */\n totalTimeoutMs?: number\n /** Max loadOlder pages before giving up. */\n maxPages?: number\n /** Poll interval for the row to render after it is known to be in the window. */\n rowWaitMs?: number\n /** Poll interval for state transitions (loadingOlder / openState). */\n pollMs?: number\n}\n\nconst DEFAULTS = {\n totalTimeoutMs: 15_000,\n maxPages: 100,\n rowWaitMs: 8_000,\n pollMs: 60,\n}\n\nfunction minAnchorSeq(rows: Iterable<{ anchorSeq: number }>): number | null {\n let min: number | null = null\n for (const row of rows) {\n if (min === null || row.anchorSeq < min) min = row.anchorSeq\n }\n return min\n}\n\nfunction renderable(rows: Iterable<{ key: string; anchorSeq: number; visibility?: string }>): { key: string; anchorSeq: number }[] {\n const out: { key: string; anchorSeq: number }[] = []\n for (const row of rows) {\n if (row.visibility === 'hidden') continue\n out.push({ key: row.key, anchorSeq: row.anchorSeq })\n }\n return out\n}\n\n/**\n * Jump to the row for `key`, paging older content until it is rendered (or the\n * budget is exhausted). Falls back to the nearest renderable row when the\n * exact row is hidden/absent.\n */\nexport async function jumpToQuestion(ports: JumpPorts, key: string, options: JumpOptions = {}): Promise<JumpResult> {\n const cfg = { ...DEFAULTS, ...options }\n const fail = (code: JumpFailureCode, fallback = false): JumpResult => {\n ports.report?.(code, fallback)\n return fallback ? { ok: false, code, fallback: true } : { ok: false, code }\n }\n\n if (!ports.isViewActive()) return fail('VIEW_INACTIVE')\n\n const deadline = ports.now() + cfg.totalTimeoutMs\n let pages = 0\n\n // Phase 1: page older until the key appears in the loaded window.\n while (true) {\n const snap = ports.snapshot()\n if (snap === undefined) return fail('VIEW_INACTIVE')\n const rows = renderable(snap.rows)\n if (rows.some((r) => r.key === key)) break\n if (snap.openState !== 'open') {\n if (snap.openState === 'error' || ports.now() > deadline) {\n return fail(snap.openState === 'error' ? 'VIEW_INACTIVE' : 'TIMEOUT')\n }\n await ports.sleep(cfg.pollMs)\n continue\n }\n if (snap.hasMore !== true) return fail('NOT_FOUND')\n if (pages >= cfg.maxPages || ports.now() > deadline) return fail('TIMEOUT')\n if (snap.loadingOlder) {\n await ports.sleep(cfg.pollMs)\n continue\n }\n const before = minAnchorSeq(rows)\n await ports.loadOlder()\n pages += 1\n const afterSnap = ports.snapshot()\n const after = minAnchorSeq(afterSnap === undefined ? [] : afterSnap.rows)\n if (after === null || (before !== null && after >= before)) return fail('NOT_FOUND')\n }\n\n // Phase 2: wait for the row to render, then scroll. Fall back if hidden.\n const waitedFor = async (rowKey: string): Promise<HTMLElement | null> => {\n for (let waited = 0; waited <= cfg.rowWaitMs; waited += cfg.pollMs) {\n if (!ports.isViewActive()) return null\n const row = ports.findRow(rowKey)\n if (row !== null) return row\n await ports.sleep(cfg.pollMs)\n }\n return null\n }\n\n const row = await waitedFor(key)\n if (row !== null) {\n ports.scrollIntoView(row)\n return { ok: true }\n }\n\n const snap = ports.snapshot()\n const fallback = nearestRenderable(snap === undefined ? [] : snap.rows, key)\n if (fallback !== null) {\n const fbRow = await waitedFor(fallback.key)\n if (fbRow !== null) {\n ports.scrollIntoView(fbRow)\n return fail('TARGET_HIDDEN', true)\n }\n }\n return fail('TARGET_HIDDEN', false)\n}\n","/**\n * Question-index builder over the raw session history RPC.\n *\n * DSH pages the rendered conversation window on purpose (memory economy):\n * `chat.nodes` only ever holds the loaded window, and force-expanding it\n * (repeated `loadOlder()`) materializes + renders the whole log — the exact\n * cost DSH's paging exists to avoid. This module instead builds a lightweight\n * index of every user question by paging the RAW history RPC (`session.history`\n * with `beforeSeq`), which reads the host log without touching the render\n * window at all. Only `{key, seq, time, text}` per question is retained.\n *\n * The chat anchor key is derived deterministically from the event — it equals\n * `conversationContextKey('input-message', String(event.data.id))` — so the\n * dots can target rows that are not loaded yet, and a click then pages the\n * window on demand (see `jump.ts`).\n *\n * Pure-ish: takes injected ports (one raw history page read, clocks) so it is\n * unit-testable without a browser or a live session.\n */\n\nimport type { QuestionNode } from './nodes.ts'\nimport { messageText } from './nodes.ts'\n\n/** Minimal shape of a raw history event (structural, not SDK-bound). */\nexport interface RawEventLike {\n type: string\n seq: number\n time: number\n surfaceOp?: unknown\n data?: {\n id?: unknown\n source?: { kind?: string; plugin?: string }\n content?: readonly { type?: string; text?: string }[]\n }\n}\n\n/** The conversation Definition kind whose key a user question node uses. */\nexport const MESSAGE_DEFINITION_KIND = 'input-message'\n\n/**\n * The engine-owned stable chat key for a user question event — mirrors\n * `conversationContextKey('input-message', String(id))` from the DSH runtime\n * (verified against it in the unit test).\n */\nexport function questionKey(id: unknown): string {\n const kind = MESSAGE_DEFINITION_KIND\n return `${kind.length}:${kind}${String(id)}`\n}\n\n/**\n * Whether a raw event is one user question the strip should index.\n * Mirrors the DSH `messageDefinition` match + `start` classification:\n * an append-origin `user/message` with a human (`user`) source. Replacement\n * copies (compaction checkpoints, `source.kind === 'plugin'`) and injected\n * context (`source.kind !== 'user'`) are excluded.\n */\nexport function isQuestionEvent(event: RawEventLike): boolean {\n if (event.type !== 'user/message') return false\n if (event.surfaceOp !== 'append') return false\n return event.data?.source?.kind === 'user'\n}\n\n/** Map one raw question event to a strip question node, or null when not one. */\nexport function questionFromEvent(event: RawEventLike): QuestionNode | null {\n if (!isQuestionEvent(event)) return null\n return {\n key: questionKey(event.data?.id),\n anchorSeq: event.seq,\n seq: event.seq,\n time: event.time,\n text: messageText(event.data?.content),\n }\n}\n\nexport interface HistoryIndexPorts {\n /**\n * Read one raw history page. `beforeSeq` is exclusive (events with seq <\n * beforeSeq); `undefined` reads the newest page. Resolves undefined when\n * the page is unavailable (session gone / transport error).\n */\n history: (\n beforeSeq: number | undefined,\n maxMessages: number,\n ) => Promise<{ events: readonly { event: RawEventLike }[]; hasMore: boolean } | undefined>\n /** Monotonic ms clock. */\n now: () => number\n}\n\nexport interface HistoryIndexOptions {\n /** Raw messages per page (default 100). */\n maxMessages?: number\n /** Max pages before giving up (default 200 => 20k messages). */\n maxPages?: number\n /** Total wall-clock budget (default 30s). */\n totalTimeoutMs?: number\n /** Abort the build; checked every iteration. */\n signal?: AbortSignal\n /** Resume from a previous `nextBeforeSeq` instead of the newest page. */\n startBeforeSeq?: number\n}\n\nexport type HistoryIndexCode = 'COMPLETE' | 'BUDGET' | 'TIMEOUT' | 'UNAVAILABLE' | 'CANCELLED'\n\nexport interface HistoryIndexResult {\n ok: boolean\n code: HistoryIndexCode\n /** Questions collected so far, ascending by anchorSeq. */\n questions: QuestionNode[]\n /** Page count actually read. */\n pages: number\n /** Where to continue (exclusive) when stopped early; undefined when COMPLETE. */\n nextBeforeSeq: number | undefined\n}\n\nconst DEFAULTS = {\n maxMessages: 100,\n maxPages: 200,\n totalTimeoutMs: 30_000,\n}\n\nfunction minSeq(events: readonly { event: RawEventLike }[]): number | undefined {\n let min: number | undefined\n for (const { event } of events) {\n if (min === undefined || event.seq < min) min = event.seq\n }\n return min\n}\n\n/**\n * Page the raw session history backward, collecting every user question into a\n * lightweight index. Never touches the render window.\n */\nexport async function buildQuestionIndex(\n ports: HistoryIndexPorts,\n options: HistoryIndexOptions = {},\n): Promise<HistoryIndexResult> {\n const cfg = { ...DEFAULTS, ...options }\n const deadline = ports.now() + cfg.totalTimeoutMs\n const questions: QuestionNode[] = []\n let beforeSeq: number | undefined = cfg.startBeforeSeq\n let pages = 0\n\n const cancelled = (): boolean => cfg.signal?.aborted === true\n\n while (true) {\n if (cancelled()) return { ok: false, code: 'CANCELLED', questions, pages, nextBeforeSeq: beforeSeq }\n if (ports.now() > deadline) return { ok: false, code: 'TIMEOUT', questions, pages, nextBeforeSeq: beforeSeq }\n if (pages >= cfg.maxPages) return { ok: false, code: 'BUDGET', questions, pages, nextBeforeSeq: beforeSeq }\n\n const page = await ports.history(beforeSeq, cfg.maxMessages)\n if (page === undefined) {\n // Transient: retry a little, then give up with what we have.\n if (pages === 0) return { ok: false, code: 'UNAVAILABLE', questions, pages, nextBeforeSeq: beforeSeq }\n return { ok: true, code: 'COMPLETE', questions, pages, nextBeforeSeq: undefined }\n }\n\n for (const { event } of page.events) {\n const question = questionFromEvent(event)\n if (question !== null) questions.push(question)\n }\n\n if (!page.hasMore) {\n questions.sort((a, b) => a.anchorSeq - b.anchorSeq)\n return { ok: true, code: 'COMPLETE', questions, pages, nextBeforeSeq: undefined }\n }\n\n const next = minSeq(page.events)\n if (next === undefined) {\n // Empty page with hasMore true is anomalous; stop cleanly.\n questions.sort((a, b) => a.anchorSeq - b.anchorSeq)\n return { ok: true, code: 'COMPLETE', questions, pages, nextBeforeSeq: undefined }\n }\n beforeSeq = next\n pages += 1\n }\n}\n","/**\n * Browser-half entry for the dsh-question-nav plugin.\n *\n * Registers one surface into the frame-wide floating layer (`shell.overlay`):\n * a vertical strip on the LEFT edge of the conversation column listing every\n * user question in the current session as a small button. Clicking a button\n * scrolls the chat to that question.\n *\n * The strip indexes the WHOLE session history WITHOUT expanding DSH's paged\n * render window: it pages the raw `session.history` RPC (read-only, no render\n * cost) and derives each question's chat anchor key from the event. Only when\n * a dot is clicked does the jump loop call `loadOlder()` to bring that\n * specific page into the window — so the conversation's memory economy is\n * preserved.\n *\n * Failure policy: nothing here throws at apply time — an external plugin must\n * never take the GUI down.\n */\nimport type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'\nimport type { SessionId } from '@deepseek-ai/dsh-client-connection/client'\nimport type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'\n// Type-only: pulls ui-layout's SlotMap merge ('shell.overlay').\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\n// Type-only: pulls the locale plugin's Context merge (ctx.locale).\nimport type {} from '@deepseek-ai/dsh-client-locale/client'\nimport { QuestionNavStrip, type QuestionNavInjected } from './QuestionNavStrip.tsx'\nimport { en, zh, type QuestionNavKey } from './locales.ts'\nimport { extractQuestions } from '../core/nodes.ts'\nimport { jumpToQuestion, type JumpFailureCode, type JumpPorts } from '../core/jump.ts'\nimport { buildQuestionIndex, type HistoryIndexOptions, type HistoryIndexResult, type RawEventLike } from '../core/history-index.ts'\n\n/** Locale namespace this plugin owns. */\nconst NS = 'question-nav'\n\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n /** question-nav surface copy. */\n 'question-nav': QuestionNavKey\n }\n}\n\n/** Services required by this plugin. */\nexport const inject = ['slots', 'locale', 'sessions', 'connection']\n\n/** Single-instance guard: a duplicated client injection must not mount twice. */\ndeclare global {\n // eslint-disable-next-line no-var\n var __dshQuestionNavApplied: boolean | undefined\n}\n\nfunction claimApply(): boolean {\n if (globalThis.__dshQuestionNavApplied === true) return false\n globalThis.__dshQuestionNavApplied = true\n return true\n}\n\nfunction releaseApply(): void {\n globalThis.__dshQuestionNavApplied = undefined\n}\n\n/** Map the session snapshot to the jump-loop port surface. */\nfunction jumpPortsFor(ctx: ClientContext, sessionId: SessionId): JumpPorts {\n return {\n snapshot: () => {\n const binding = ctx.sessions.binding(sessionId)\n const snap = binding?.session.getSnapshot()\n if (snap === undefined) return undefined\n return {\n openState: snap.openState,\n hasMore: snap.hasMore,\n loadingOlder: snap.loadingOlder,\n rows: snap.chat.nodes.values(),\n }\n },\n loadOlder: async () => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) throw new Error('session unavailable')\n await binding.session.loadOlder()\n },\n isViewActive: () => document.querySelector('[data-chat-flow]') !== null,\n findRow: (key: string) => {\n for (const candidate of Array.from(document.querySelectorAll<HTMLElement>('[data-chat-anchor-key]'))) {\n if (candidate.dataset.chatAnchorKey === key) return candidate\n }\n return null\n },\n scrollIntoView: (row) => row.scrollIntoView({ block: 'start' }),\n now: () => Date.now(),\n sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)),\n }\n}\n\n/** Resolve the connection handle (shared API client) as other DSH plugins do. */\nfunction connectionOf(ctx: ClientContext): ConnectionHandle {\n return ctx.get('connection') as ConnectionHandle\n}\n\n/**\n * One raw history page, mapped to the pure `buildQuestionIndex` port shape.\n * `beforeSeq` is exclusive; `undefined` reads the newest page. Returns\n * undefined when the page is unavailable so the builder stops cleanly. The\n * SDK's `SessionEvent` is cast to the structural `RawEventLike` at this\n * boundary (the index reader only touches type/seq/time/surfaceOp/data).\n */\nasync function rawHistoryPage(\n ctx: ClientContext,\n sessionId: SessionId,\n beforeSeq: number | undefined,\n maxMessages: number,\n): Promise<{ events: readonly { event: RawEventLike }[]; hasMore: boolean } | undefined> {\n const { api } = connectionOf(ctx)\n const { result } = await api.sessions.history({ sessionId, beforeSeq, maxMessages })\n if (!result.ok) return undefined\n return {\n events: result.value.events.map((entry) => ({ event: entry.event as unknown as RawEventLike })),\n hasMore: result.value.hasMore,\n }\n}\n\n/** Build the full-session question index from the raw history RPC (no render). */\nfunction buildIndexFor(\n ctx: ClientContext,\n sessionId: SessionId,\n options: HistoryIndexOptions = {},\n): Promise<HistoryIndexResult> {\n return buildQuestionIndex({\n history: (beforeSeq, maxMessages) => rawHistoryPage(ctx, sessionId, beforeSeq, maxMessages),\n now: () => Date.now(),\n }, options)\n}\n\nfunction createInject(ctx: ClientContext): QuestionNavInjected {\n return {\n readQuestions: (sessionId) => {\n const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot()\n if (snap === undefined) return []\n return extractQuestions(snap.chat.nodes.values())\n },\n subscribeList: (cb) => ctx.sessions.list.subscribe(cb),\n subscribeContent: (sessionId, cb) => {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) return () => {}\n return binding.session.subscribe(cb)\n },\n jump: (sessionId, key) => {\n const ports = jumpPortsFor(ctx, sessionId)\n ports.report = (code: JumpFailureCode) => {\n // Surface the failure through the component via a DOM event the\n // strip listens for; simplest reliable cross-boundary channel here.\n window.dispatchEvent(new CustomEvent('question-nav:jump-failed', { detail: code }))\n }\n void jumpToQuestion(ports, key)\n },\n fetchQuestionIndex: (sessionId, options) => buildIndexFor(ctx, sessionId, options),\n }\n}\n\n/**\n * Register the question-nav surface.\n * @param ctx - client root context.\n */\nexport function apply(ctx: ClientContext): void {\n if (!claimApply()) return\n ctx.effect(() => releaseApply, 'question-nav: apply claim')\n\n ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'question-nav: dictionaries')\n\n const injected = createInject(ctx)\n\n ctx.slots.inject('shell.overlay', () => ctx.slots.register({\n name: 'shell.overlay',\n id: 'question-nav',\n order: 900,\n locale: NS,\n inject: () => injected,\n }, QuestionNavStrip))\n}\n"],"mappings":"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"}
@@ -1,16 +1,19 @@
1
1
  import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
2
2
  import type { SessionId } from '@deepseek-ai/dsh-client-connection/client';
3
3
  import type { QuestionNode } from '../core/nodes.ts';
4
+ import type { HistoryIndexOptions, HistoryIndexResult } from '../core/history-index.ts';
4
5
  /** Values the registrant inject face supplies (wired in src/client/index.ts). */
5
6
  export interface QuestionNavInjected {
6
- /** Extract the user questions of a session (current loaded window). */
7
+ /** Extract the user questions of a session's currently loaded window. */
7
8
  readQuestions: (sessionId: SessionId) => QuestionNode[];
8
9
  /** Subscribe to the session list; returns an unsubscribe. */
9
10
  subscribeList: (cb: () => void) => () => void;
10
11
  /** Subscribe to a session's content; returns an unsubscribe. */
11
12
  subscribeContent: (sessionId: SessionId, cb: () => void) => () => void;
12
- /** Jump the chat to a question row. */
13
+ /** Jump the chat to a question row (pages the window on demand). */
13
14
  jump: (sessionId: SessionId, key: string) => void;
15
+ /** Build the full-session question index from the raw history RPC. */
16
+ fetchQuestionIndex: (sessionId: SessionId, options?: HistoryIndexOptions) => Promise<HistoryIndexResult>;
14
17
  }
15
18
  type ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>;
16
19
  export declare function QuestionNavStrip(props: ComponentProps): React.JSX.Element | null;
@@ -1,10 +1,17 @@
1
1
  /**
2
- * Browser-half entry for the dsh-client-ui-question-nav plugin.
2
+ * Browser-half entry for the dsh-question-nav plugin.
3
3
  *
4
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
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).
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.
8
15
  *
9
16
  * Failure policy: nothing here throws at apply time — an external plugin must
10
17
  * never take the GUI down.
@@ -4,6 +4,9 @@
4
4
  */
5
5
  export declare const zh: {
6
6
  readonly 'strip.empty': "本会话还没有提问";
7
+ readonly 'strip.loadingAll': "正在加载全部历史…";
8
+ readonly 'strip.loadingSuffix': "…";
9
+ readonly 'strip.loadEarlier': "加载更早的问题";
7
10
  readonly 'jump.inactive': "聊天视图未激活";
8
11
  readonly 'jump.hidden': "目标无独立气泡,已定位到邻近内容";
9
12
  readonly 'jump.notfound': "目标未加载或不存在(可能已压缩)";
@@ -11,6 +14,9 @@ export declare const zh: {
11
14
  };
12
15
  export declare const en: {
13
16
  readonly 'strip.empty': "No questions in this session yet";
17
+ readonly 'strip.loadingAll': "Loading full history…";
18
+ readonly 'strip.loadingSuffix': "…";
19
+ readonly 'strip.loadEarlier': "Load earlier questions";
14
20
  readonly 'jump.inactive': "Chat view is not active";
15
21
  readonly 'jump.hidden': "No dedicated bubble; landed on nearby content";
16
22
  readonly 'jump.notfound': "Target not loaded or missing (maybe compacted)";
@@ -0,0 +1,99 @@
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
+ import type { QuestionNode } from './nodes.ts';
21
+ /** Minimal shape of a raw history event (structural, not SDK-bound). */
22
+ export interface RawEventLike {
23
+ type: string;
24
+ seq: number;
25
+ time: number;
26
+ surfaceOp?: unknown;
27
+ data?: {
28
+ id?: unknown;
29
+ source?: {
30
+ kind?: string;
31
+ plugin?: string;
32
+ };
33
+ content?: readonly {
34
+ type?: string;
35
+ text?: string;
36
+ }[];
37
+ };
38
+ }
39
+ /** The conversation Definition kind whose key a user question node uses. */
40
+ export declare const MESSAGE_DEFINITION_KIND = "input-message";
41
+ /**
42
+ * The engine-owned stable chat key for a user question event — mirrors
43
+ * `conversationContextKey('input-message', String(id))` from the DSH runtime
44
+ * (verified against it in the unit test).
45
+ */
46
+ export declare function questionKey(id: unknown): string;
47
+ /**
48
+ * Whether a raw event is one user question the strip should index.
49
+ * Mirrors the DSH `messageDefinition` match + `start` classification:
50
+ * an append-origin `user/message` with a human (`user`) source. Replacement
51
+ * copies (compaction checkpoints, `source.kind === 'plugin'`) and injected
52
+ * context (`source.kind !== 'user'`) are excluded.
53
+ */
54
+ export declare function isQuestionEvent(event: RawEventLike): boolean;
55
+ /** Map one raw question event to a strip question node, or null when not one. */
56
+ export declare function questionFromEvent(event: RawEventLike): QuestionNode | null;
57
+ export interface HistoryIndexPorts {
58
+ /**
59
+ * Read one raw history page. `beforeSeq` is exclusive (events with seq <
60
+ * beforeSeq); `undefined` reads the newest page. Resolves undefined when
61
+ * the page is unavailable (session gone / transport error).
62
+ */
63
+ history: (beforeSeq: number | undefined, maxMessages: number) => Promise<{
64
+ events: readonly {
65
+ event: RawEventLike;
66
+ }[];
67
+ hasMore: boolean;
68
+ } | undefined>;
69
+ /** Monotonic ms clock. */
70
+ now: () => number;
71
+ }
72
+ export interface HistoryIndexOptions {
73
+ /** Raw messages per page (default 100). */
74
+ maxMessages?: number;
75
+ /** Max pages before giving up (default 200 => 20k messages). */
76
+ maxPages?: number;
77
+ /** Total wall-clock budget (default 30s). */
78
+ totalTimeoutMs?: number;
79
+ /** Abort the build; checked every iteration. */
80
+ signal?: AbortSignal;
81
+ /** Resume from a previous `nextBeforeSeq` instead of the newest page. */
82
+ startBeforeSeq?: number;
83
+ }
84
+ export type HistoryIndexCode = 'COMPLETE' | 'BUDGET' | 'TIMEOUT' | 'UNAVAILABLE' | 'CANCELLED';
85
+ export interface HistoryIndexResult {
86
+ ok: boolean;
87
+ code: HistoryIndexCode;
88
+ /** Questions collected so far, ascending by anchorSeq. */
89
+ questions: QuestionNode[];
90
+ /** Page count actually read. */
91
+ pages: number;
92
+ /** Where to continue (exclusive) when stopped early; undefined when COMPLETE. */
93
+ nextBeforeSeq: number | undefined;
94
+ }
95
+ /**
96
+ * Page the raw session history backward, collecting every user question into a
97
+ * lightweight index. Never touches the render window.
98
+ */
99
+ export declare function buildQuestionIndex(ports: HistoryIndexPorts, options?: HistoryIndexOptions): Promise<HistoryIndexResult>;
@@ -0,0 +1,53 @@
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
+ export interface LoadAllSnapshot {
14
+ openState: string;
15
+ hasMore: boolean;
16
+ loadingOlder: boolean;
17
+ }
18
+ export interface LoadAllPorts {
19
+ /** Read the current session snapshot; undefined when unavailable. */
20
+ snapshot: () => LoadAllSnapshot | undefined;
21
+ /** Expand the window backwards by one page (may preserve scroll). */
22
+ loadOlder: () => Promise<void>;
23
+ /** True while the chat view is active (a `[data-chat-flow]` is mounted). */
24
+ isViewActive: () => boolean;
25
+ /** Monotonic ms clock. */
26
+ now: () => number;
27
+ /** Async sleep. */
28
+ sleep: (ms: number) => Promise<void>;
29
+ }
30
+ export interface LoadAllOptions {
31
+ /** Max older pages to fetch before giving up (default 400). */
32
+ maxPages?: number;
33
+ /** Total wall-clock budget for the whole expansion (default 60s). */
34
+ totalTimeoutMs?: number;
35
+ /** Poll interval for open/loading transitions (default 60ms). */
36
+ pollMs?: number;
37
+ /** Abort the expansion; checked every iteration. */
38
+ signal?: AbortSignal;
39
+ }
40
+ export type LoadAllCode = 'COMPLETE' | 'VIEW_INACTIVE' | 'NOT_OPEN' | 'BUDGET' | 'TIMEOUT' | 'CANCELLED';
41
+ export interface LoadAllResult {
42
+ ok: boolean;
43
+ code: LoadAllCode;
44
+ /** Number of `loadOlder` pages actually fetched. */
45
+ pages: number;
46
+ }
47
+ /**
48
+ * Expand the session window backwards until the earliest history is loaded.
49
+ * Waits while the session is still opening; aborts on cancellation, budget or
50
+ * timeout. Safe to re-enter: once `hasMore` is false the loop returns
51
+ * immediately with `COMPLETE`.
52
+ */
53
+ export declare function loadAllOlder(ports: LoadAllPorts, options?: LoadAllOptions): Promise<LoadAllResult>;
@@ -47,3 +47,10 @@ export declare function nearestRenderable(nodes: Iterable<{
47
47
  key: string;
48
48
  anchorSeq: number;
49
49
  } | null;
50
+ /**
51
+ * Merge two question sets (full-history index + live loaded window) into one
52
+ * deduplicated, anchorSeq-ascending list. The window may hold questions that
53
+ * arrived after the index was built; the index may hold questions the window
54
+ * has not loaded yet — union on `key`, newest live copy wins per key.
55
+ */
56
+ export declare function mergeQuestions(...sources: readonly (readonly QuestionNode[])[]): QuestionNode[];
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.1.0",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.7.0",
7
7
  "engines": {
@@ -6,9 +6,19 @@
6
6
  * no native-title delay) shows the question's full text; clicking a dot scrolls
7
7
  * the chat to that question.
8
8
  *
9
+ * Index strategy (no render-window expansion): the dots cover the WHOLE
10
+ * session history. The index is built from the raw `session.history` RPC via
11
+ * the injected `fetchQuestionIndex` — the conversation's paged window is
12
+ * untouched, so DSH's memory economy is preserved. The loaded window's live
13
+ * questions are merged on top (for new messages arriving after the index was
14
+ * built). Clicking a dot jumps through the existing paging loop, which calls
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.
18
+ *
9
19
  * Data arrives through the four props shares: the framework `useSessions`
10
- * hook (current session), the registrant inject face (read/subscribe/jump),
11
- * and the bound locale translator.
20
+ * hook (current session), the registrant inject face (read/subscribe/jump/
21
+ * fetch-index), and the bound locale translator.
12
22
  */
13
23
  import { useEffect, useLayoutEffect, useRef, useState } from 'react'
14
24
  import { createPortal } from 'react-dom'
@@ -17,20 +27,24 @@ import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
17
27
  // Type-only: pulls the ui-layout SlotMap merge ('shell.overlay').
18
28
  import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
19
29
  import type { QuestionNode } from '../core/nodes.ts'
30
+ import { mergeQuestions } from '../core/nodes.ts'
20
31
  import type { JumpFailureCode } from '../core/jump.ts'
32
+ import type { HistoryIndexOptions, HistoryIndexResult } from '../core/history-index.ts'
21
33
  import type { QuestionNavKey } from './locales.ts'
22
34
  import styles from './question-nav.module.css'
23
35
 
24
36
  /** Values the registrant inject face supplies (wired in src/client/index.ts). */
25
37
  export interface QuestionNavInjected {
26
- /** Extract the user questions of a session (current loaded window). */
38
+ /** Extract the user questions of a session's currently loaded window. */
27
39
  readQuestions: (sessionId: SessionId) => QuestionNode[]
28
40
  /** Subscribe to the session list; returns an unsubscribe. */
29
41
  subscribeList: (cb: () => void) => () => void
30
42
  /** Subscribe to a session's content; returns an unsubscribe. */
31
43
  subscribeContent: (sessionId: SessionId, cb: () => void) => () => void
32
- /** Jump the chat to a question row. */
44
+ /** Jump the chat to a question row (pages the window on demand). */
33
45
  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>
34
48
  }
35
49
 
36
50
  type ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>
@@ -62,8 +76,18 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
62
76
  const [jumpingKey, setJumpingKey] = useState<string | null>(null)
63
77
  const [hint, setHint] = useState<string | null>(null)
64
78
  const [tooltip, setTooltip] = useState<TooltipState | null>(null)
79
+ const [loadingIndex, setLoadingIndex] = useState(false)
80
+ const [moreAvailable, setMoreAvailable] = useState(false)
65
81
  const panelRef = useRef<HTMLDivElement | null>(null)
66
82
  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)
67
91
 
68
92
  const showHint = (message: string): void => {
69
93
  setHint(message)
@@ -71,20 +95,61 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
71
95
  hintTimerRef.current = window.setTimeout(() => setHint(null), 1800)
72
96
  }
73
97
 
74
- // Refresh the question list whenever the current session or its content changes.
98
+ // Build the full-history index on show; refresh the live window on content
99
+ // change and merge both into the dot list.
75
100
  useEffect(() => {
76
101
  if (!visible || current === undefined) {
102
+ indexRef.current = []
103
+ nextBeforeSeqRef.current = undefined
104
+ indexAbortRef.current?.abort()
105
+ indexAbortRef.current = null
106
+ buildingSessionRef.current = null
77
107
  setQuestions([])
108
+ setLoadingIndex(false)
109
+ setMoreAvailable(false)
78
110
  return
79
111
  }
80
- const refresh = (): void => setQuestions(props.readQuestions(current))
112
+ const sessionId = current
113
+ // Reset the per-session index: this effect re-runs on session change.
114
+ indexRef.current = []
115
+ nextBeforeSeqRef.current = undefined
116
+ const refresh = (): void => {
117
+ const windowQuestions = props.readQuestions(sessionId)
118
+ setQuestions(mergeQuestions(indexRef.current, windowQuestions))
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
+ })
141
+ }
81
142
  refresh()
82
- const unsubContent = props.subscribeContent(current, refresh)
143
+ startBuild()
144
+ const unsubContent = props.subscribeContent(sessionId, refresh)
83
145
  const unsubList = props.subscribeList(refresh)
84
146
  return () => {
147
+ indexAbortRef.current?.abort()
148
+ indexAbortRef.current = null
85
149
  unsubContent()
86
150
  unsubList()
87
151
  }
152
+ // eslint-disable-next-line react-hooks/exhaustive-deps
88
153
  }, [visible, current, props])
89
154
 
90
155
  // Listen for jump-failure events and surface the hint.
@@ -150,6 +215,21 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
150
215
  window.setTimeout(() => setJumpingKey((k) => (k === node.key ? null : k)), 600)
151
216
  }
152
217
 
218
+ const onLoadMore = (): void => {
219
+ if (current === undefined || nextBeforeSeqRef.current === undefined) return
220
+ setMoreAvailable(false)
221
+ setLoadingIndex(true)
222
+ props.fetchQuestionIndex(current, { startBeforeSeq: nextBeforeSeqRef.current })
223
+ .then((result) => {
224
+ if (current === undefined) return
225
+ indexRef.current = mergeQuestions(result.questions, indexRef.current)
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))
231
+ }
232
+
153
233
  const t = props.t
154
234
 
155
235
  return (
@@ -157,10 +237,26 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
157
237
  {hint !== null ? <div className={styles.hint} role="status">{hint}</div> : null}
158
238
  <div className={styles.list}>
159
239
  {questions.length === 0 ? (
160
- <div className={styles.empty}>{t('strip.empty')}</div>
240
+ <div className={styles.empty}>{loadingIndex ? t('strip.loadingAll') : t('strip.empty')}</div>
161
241
  ) : (
162
242
  <div className={styles.dots}>
163
- <span className={styles.count}>{questions.length}</span>
243
+ <span className={styles.count}>
244
+ {questions.length}
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}
164
260
  {questions.map((node) => (
165
261
  <button
166
262
  key={node.key}