@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.
- package/LICENSE +29 -0
- package/README.md +76 -0
- package/README.zh.md +67 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +450 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +5 -0
- package/lib/types/client/QuestionNavStrip.d.ts +17 -0
- package/lib/types/client/index.d.ts +30 -0
- package/lib/types/client/locales.d.ts +19 -0
- package/lib/types/core/jump.d.ts +59 -0
- package/lib/types/core/nodes.d.ts +49 -0
- package/lib/types/index.d.ts +9 -0
- package/package.json +88 -0
- package/src/client/QuestionNavStrip.tsx +190 -0
- package/src/client/css-modules.d.ts +4 -0
- package/src/client/index.ts +128 -0
- package/src/client/locales.ts +21 -0
- package/src/client/question-nav.module.css +105 -0
- package/src/core/jump.ts +153 -0
- package/src/core/nodes.ts +98 -0
- package/src/index.ts +10 -0
|
@@ -0,0 +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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"}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
|
|
2
|
+
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client';
|
|
3
|
+
import type { QuestionNode } from '../core/nodes.ts';
|
|
4
|
+
/** Values the registrant inject face supplies (wired in src/client/index.ts). */
|
|
5
|
+
export interface QuestionNavInjected {
|
|
6
|
+
/** Extract the user questions of a session (current loaded window). */
|
|
7
|
+
readQuestions: (sessionId: SessionId) => QuestionNode[];
|
|
8
|
+
/** Subscribe to the session list; returns an unsubscribe. */
|
|
9
|
+
subscribeList: (cb: () => void) => () => void;
|
|
10
|
+
/** Subscribe to a session's content; returns an unsubscribe. */
|
|
11
|
+
subscribeContent: (sessionId: SessionId, cb: () => void) => () => void;
|
|
12
|
+
/** Jump the chat to a question row. */
|
|
13
|
+
jump: (sessionId: SessionId, key: string) => void;
|
|
14
|
+
}
|
|
15
|
+
type ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>;
|
|
16
|
+
export declare function QuestionNavStrip(props: ComponentProps): React.JSX.Element | null;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
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 QuestionNavKey } from './locales.ts';
|
|
14
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
15
|
+
interface LocaleNamespaceMap {
|
|
16
|
+
/** question-nav surface copy. */
|
|
17
|
+
'question-nav': QuestionNavKey;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Services required by this plugin. */
|
|
21
|
+
export declare const inject: string[];
|
|
22
|
+
/** Single-instance guard: a duplicated client injection must not mount twice. */
|
|
23
|
+
declare global {
|
|
24
|
+
var __dshQuestionNavApplied: boolean | undefined;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Register the question-nav surface.
|
|
28
|
+
* @param ctx - client root context.
|
|
29
|
+
*/
|
|
30
|
+
export declare function apply(ctx: ClientContext): void;
|
|
@@ -0,0 +1,19 @@
|
|
|
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 declare const zh: {
|
|
6
|
+
readonly 'strip.empty': "本会话还没有提问";
|
|
7
|
+
readonly 'jump.inactive': "聊天视图未激活";
|
|
8
|
+
readonly 'jump.hidden': "目标无独立气泡,已定位到邻近内容";
|
|
9
|
+
readonly 'jump.notfound': "目标未加载或不存在(可能已压缩)";
|
|
10
|
+
readonly 'jump.timeout': "加载历史超时,可重试";
|
|
11
|
+
};
|
|
12
|
+
export declare const en: {
|
|
13
|
+
readonly 'strip.empty': "No questions in this session yet";
|
|
14
|
+
readonly 'jump.inactive': "Chat view is not active";
|
|
15
|
+
readonly 'jump.hidden': "No dedicated bubble; landed on nearby content";
|
|
16
|
+
readonly 'jump.notfound': "Target not loaded or missing (maybe compacted)";
|
|
17
|
+
readonly 'jump.timeout': "Timed out loading history; retry";
|
|
18
|
+
};
|
|
19
|
+
export type QuestionNavKey = keyof typeof zh;
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
/** The bits of a session snapshot the jump loop needs. */
|
|
8
|
+
export interface JumpSnapshot {
|
|
9
|
+
openState: string;
|
|
10
|
+
hasMore: boolean;
|
|
11
|
+
loadingOlder: boolean;
|
|
12
|
+
/** Renderable chat rows as a key->renderable map (or iterable of rows). */
|
|
13
|
+
rows: Iterable<{
|
|
14
|
+
key: string;
|
|
15
|
+
anchorSeq: number;
|
|
16
|
+
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
|
+
export type JumpFailureCode = 'VIEW_INACTIVE' | 'TARGET_HIDDEN' | 'NOT_FOUND' | 'TIMEOUT';
|
|
38
|
+
export interface JumpResult {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
code?: JumpFailureCode;
|
|
41
|
+
/** True when we landed on a fallback row rather than the exact target. */
|
|
42
|
+
fallback?: boolean;
|
|
43
|
+
}
|
|
44
|
+
export interface JumpOptions {
|
|
45
|
+
/** Total wall-clock budget for loadOlder paging. */
|
|
46
|
+
totalTimeoutMs?: number;
|
|
47
|
+
/** Max loadOlder pages before giving up. */
|
|
48
|
+
maxPages?: number;
|
|
49
|
+
/** Poll interval for the row to render after it is known to be in the window. */
|
|
50
|
+
rowWaitMs?: number;
|
|
51
|
+
/** Poll interval for state transitions (loadingOlder / openState). */
|
|
52
|
+
pollMs?: number;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Jump to the row for `key`, paging older content until it is rendered (or the
|
|
56
|
+
* budget is exhausted). Falls back to the nearest renderable row when the
|
|
57
|
+
* exact row is hidden/absent.
|
|
58
|
+
*/
|
|
59
|
+
export declare function jumpToQuestion(ports: JumpPorts, key: string, options?: JumpOptions): Promise<JumpResult>;
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
/** One user question as shown in the strip and targeted by a jump. */
|
|
7
|
+
export interface QuestionNode {
|
|
8
|
+
/** Chat anchor key, matches a `[data-chat-anchor-key]` row in the scrollport. */
|
|
9
|
+
key: string;
|
|
10
|
+
/** Monotone anchor sequence for ordering and window-min detection. */
|
|
11
|
+
anchorSeq: number;
|
|
12
|
+
/** Event seq of the user message. */
|
|
13
|
+
seq: number;
|
|
14
|
+
/** Unix ms timestamp. */
|
|
15
|
+
time: number;
|
|
16
|
+
/** Full question text — shown in the hover tooltip (not truncated). */
|
|
17
|
+
text: string;
|
|
18
|
+
}
|
|
19
|
+
/** Minimal shape a chat node must expose for indexing (structural, not SDK-bound). */
|
|
20
|
+
export interface ChatNodeLike {
|
|
21
|
+
key: string;
|
|
22
|
+
anchorSeq: number;
|
|
23
|
+
visibility?: string;
|
|
24
|
+
kind?: string;
|
|
25
|
+
/** Kind-specific payload (a UserMessageNode for `user`/`steering`). */
|
|
26
|
+
data?: unknown;
|
|
27
|
+
}
|
|
28
|
+
/** Kinds counted as a user question (turn-opening and steering admissions). */
|
|
29
|
+
export declare const QUESTION_KINDS: readonly ["user", "steering"];
|
|
30
|
+
/** First text block of a user message; falls back to the raw first block. */
|
|
31
|
+
export declare function messageText(content: readonly {
|
|
32
|
+
type?: string;
|
|
33
|
+
text?: string;
|
|
34
|
+
}[] | undefined): string;
|
|
35
|
+
/** Extract the user questions from a chat-node window, ordered by anchorSeq. */
|
|
36
|
+
export declare function extractQuestions(nodes: Iterable<ChatNodeLike>): QuestionNode[];
|
|
37
|
+
/** Whether a node is actually rendered (visible rows only are scroll targets). */
|
|
38
|
+
export declare function isRenderable(node: ChatNodeLike): boolean;
|
|
39
|
+
/** The row of the window that renders the given key (exact match). */
|
|
40
|
+
export declare function findQuestionRow(nodes: Iterable<ChatNodeLike>, key: string): ChatNodeLike | null;
|
|
41
|
+
/** Nearest renderable row for a key that is absent or hidden (compaction, windowing). */
|
|
42
|
+
export declare function nearestRenderable(nodes: Iterable<{
|
|
43
|
+
key: string;
|
|
44
|
+
anchorSeq: number;
|
|
45
|
+
visibility?: string;
|
|
46
|
+
}>, excludeKey: string | undefined): {
|
|
47
|
+
key: string;
|
|
48
|
+
anchorSeq: number;
|
|
49
|
+
} | null;
|
|
@@ -0,0 +1,9 @@
|
|
|
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
|
+
/** Apply the host half (no host behavior for this plugin). */
|
|
9
|
+
export declare function apply(_ctx: Context): void;
|
package/package.json
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@luziyang2026/dsh-question-nav",
|
|
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",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"packageManager": "pnpm@11.7.0",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
9
|
+
},
|
|
10
|
+
"main": "lib/index.js",
|
|
11
|
+
"types": "lib/types/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./lib/types/index.d.ts",
|
|
15
|
+
"default": "./lib/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./client": {
|
|
18
|
+
"types": "./lib/types/client/index.d.ts",
|
|
19
|
+
"default": "./lib/client.js"
|
|
20
|
+
},
|
|
21
|
+
"./src/*": "./src/*",
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"dsh": {
|
|
25
|
+
"bundle": {
|
|
26
|
+
"patch": "./cordis.patch.yml"
|
|
27
|
+
},
|
|
28
|
+
"client": {
|
|
29
|
+
"inject": [
|
|
30
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
31
|
+
"@deepseek-ai/dsh-client-connection",
|
|
32
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
33
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
34
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
35
|
+
"@deepseek-ai/dsh-client-locale"
|
|
36
|
+
],
|
|
37
|
+
"platform": "web"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc -p tsconfig.build.json && tsdown",
|
|
42
|
+
"prepare": "tsdown",
|
|
43
|
+
"prepublishOnly": "pnpm typecheck && pnpm test && pnpm build",
|
|
44
|
+
"watch": "tsdown --watch",
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"typecheck": "tsc --noEmit"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"react": "^18.2.0",
|
|
50
|
+
"react-dom": "^18.2.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
54
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.1-rc.1",
|
|
55
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.1-rc.1",
|
|
56
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.1",
|
|
57
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.1",
|
|
58
|
+
"@deepseek-ai/dsh-client-ui-layout": "^0.1.1-rc.1",
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.1",
|
|
60
|
+
"@testing-library/dom": "^10.4.1",
|
|
61
|
+
"@testing-library/react": "^16.3.2",
|
|
62
|
+
"@types/node": "^22.20.0",
|
|
63
|
+
"@types/react": "~18.3.1",
|
|
64
|
+
"@types/react-dom": "~18.3.0",
|
|
65
|
+
"jsdom": "29.1.1",
|
|
66
|
+
"lightningcss": "^1.32.0",
|
|
67
|
+
"react": "^18.2.0",
|
|
68
|
+
"react-dom": "^18.2.0",
|
|
69
|
+
"tsdown": "^0.22.2",
|
|
70
|
+
"typescript": "^6.0.3",
|
|
71
|
+
"vite-tsconfig-paths": "^6.1.1",
|
|
72
|
+
"vitest": "^4.1.8"
|
|
73
|
+
},
|
|
74
|
+
"files": [
|
|
75
|
+
"lib",
|
|
76
|
+
"src",
|
|
77
|
+
"cordis.patch.yml",
|
|
78
|
+
"LICENSE",
|
|
79
|
+
"README.md",
|
|
80
|
+
"README.zh.md",
|
|
81
|
+
"README.i18n.yaml"
|
|
82
|
+
],
|
|
83
|
+
"license": "BSD-3-Clause",
|
|
84
|
+
"repository": {
|
|
85
|
+
"type": "git",
|
|
86
|
+
"url": "https://github.com/AbelKeithsun/dsh-question-nav.git"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Question-nav minimap. Renders a vertical column of small round dots overlaid
|
|
3
|
+
* on the LEFT edge of the conversation column (via the frame-wide
|
|
4
|
+
* `shell.overlay` floating layer), vertically centered: one dot per user
|
|
5
|
+
* question, enlarge on hover. The instant tooltip (a portal-rendered overlay,
|
|
6
|
+
* no native-title delay) shows the question's full text; clicking a dot scrolls
|
|
7
|
+
* the chat to that question.
|
|
8
|
+
*
|
|
9
|
+
* 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.
|
|
12
|
+
*/
|
|
13
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
14
|
+
import { createPortal } from 'react-dom'
|
|
15
|
+
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
16
|
+
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
|
17
|
+
// Type-only: pulls the ui-layout SlotMap merge ('shell.overlay').
|
|
18
|
+
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
|
19
|
+
import type { QuestionNode } from '../core/nodes.ts'
|
|
20
|
+
import type { JumpFailureCode } from '../core/jump.ts'
|
|
21
|
+
import type { QuestionNavKey } from './locales.ts'
|
|
22
|
+
import styles from './question-nav.module.css'
|
|
23
|
+
|
|
24
|
+
/** Values the registrant inject face supplies (wired in src/client/index.ts). */
|
|
25
|
+
export interface QuestionNavInjected {
|
|
26
|
+
/** Extract the user questions of a session (current loaded window). */
|
|
27
|
+
readQuestions: (sessionId: SessionId) => QuestionNode[]
|
|
28
|
+
/** Subscribe to the session list; returns an unsubscribe. */
|
|
29
|
+
subscribeList: (cb: () => void) => () => void
|
|
30
|
+
/** Subscribe to a session's content; returns an unsubscribe. */
|
|
31
|
+
subscribeContent: (sessionId: SessionId, cb: () => void) => () => void
|
|
32
|
+
/** Jump the chat to a question row. */
|
|
33
|
+
jump: (sessionId: SessionId, key: string) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>
|
|
37
|
+
|
|
38
|
+
const FAILURE_HINTS: Record<JumpFailureCode, QuestionNavKey> = {
|
|
39
|
+
VIEW_INACTIVE: 'jump.inactive',
|
|
40
|
+
TARGET_HIDDEN: 'jump.hidden',
|
|
41
|
+
NOT_FOUND: 'jump.notfound',
|
|
42
|
+
TIMEOUT: 'jump.timeout',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Live position of the instant hover tooltip. */
|
|
46
|
+
interface TooltipState {
|
|
47
|
+
text: string
|
|
48
|
+
left: number
|
|
49
|
+
top: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function findConvRoot(): HTMLElement | null {
|
|
53
|
+
return document.querySelector<HTMLElement>('[data-slot="conversation"] > div[data-phase]')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | null {
|
|
57
|
+
const current = props.useSessions((s) => s.current)
|
|
58
|
+
const summary = props.useSessions((s) => (s.current === undefined ? undefined : s.byId[s.current]))
|
|
59
|
+
const visible = current !== undefined && summary !== undefined && summary.blank !== true
|
|
60
|
+
|
|
61
|
+
const [questions, setQuestions] = useState<QuestionNode[]>([])
|
|
62
|
+
const [jumpingKey, setJumpingKey] = useState<string | null>(null)
|
|
63
|
+
const [hint, setHint] = useState<string | null>(null)
|
|
64
|
+
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
|
|
65
|
+
const panelRef = useRef<HTMLDivElement | null>(null)
|
|
66
|
+
const hintTimerRef = useRef<number | null>(null)
|
|
67
|
+
|
|
68
|
+
const showHint = (message: string): void => {
|
|
69
|
+
setHint(message)
|
|
70
|
+
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)
|
|
71
|
+
hintTimerRef.current = window.setTimeout(() => setHint(null), 1800)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Refresh the question list whenever the current session or its content changes.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!visible || current === undefined) {
|
|
77
|
+
setQuestions([])
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const refresh = (): void => setQuestions(props.readQuestions(current))
|
|
81
|
+
refresh()
|
|
82
|
+
const unsubContent = props.subscribeContent(current, refresh)
|
|
83
|
+
const unsubList = props.subscribeList(refresh)
|
|
84
|
+
return () => {
|
|
85
|
+
unsubContent()
|
|
86
|
+
unsubList()
|
|
87
|
+
}
|
|
88
|
+
}, [visible, current, props])
|
|
89
|
+
|
|
90
|
+
// Listen for jump-failure events and surface the hint.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const onJumpFailed = (event: Event): void => {
|
|
93
|
+
const code = (event as CustomEvent<JumpFailureCode>).detail
|
|
94
|
+
showHint(props.t(FAILURE_HINTS[code] ?? 'jump.timeout'))
|
|
95
|
+
}
|
|
96
|
+
window.addEventListener('question-nav:jump-failed', onJumpFailed)
|
|
97
|
+
return () => window.removeEventListener('question-nav:jump-failed', onJumpFailed)
|
|
98
|
+
}, [props])
|
|
99
|
+
|
|
100
|
+
// Anchor the minimap to the conversation column: position it at the left
|
|
101
|
+
// edge of the conversation root and reserve a thin rail with padding-left.
|
|
102
|
+
useLayoutEffect(() => {
|
|
103
|
+
if (!visible) return
|
|
104
|
+
let raf = 0
|
|
105
|
+
let retries = 0
|
|
106
|
+
const applyLayout = (): void => {
|
|
107
|
+
const panel = panelRef.current
|
|
108
|
+
if (panel === null) return
|
|
109
|
+
const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null
|
|
110
|
+
const convRoot = findConvRoot()
|
|
111
|
+
if (frame === null || convRoot === null) return
|
|
112
|
+
const frameRect = frame.getBoundingClientRect()
|
|
113
|
+
const convRect = convRoot.getBoundingClientRect()
|
|
114
|
+
if (convRect.height <= 0) {
|
|
115
|
+
if (retries < 20) {
|
|
116
|
+
retries += 1
|
|
117
|
+
raf = requestAnimationFrame(applyLayout)
|
|
118
|
+
}
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
retries = 0
|
|
122
|
+
panel.style.top = `${convRect.top - frameRect.top}px`
|
|
123
|
+
panel.style.height = `${convRect.height}px`
|
|
124
|
+
panel.style.left = `${convRect.left - frameRect.left}px`
|
|
125
|
+
}
|
|
126
|
+
applyLayout()
|
|
127
|
+
raf = requestAnimationFrame(applyLayout)
|
|
128
|
+
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(applyLayout)
|
|
129
|
+
const convRoot = findConvRoot()
|
|
130
|
+
observer?.observe(convRoot ?? document.body, { box: 'border-box' })
|
|
131
|
+
window.addEventListener('resize', applyLayout)
|
|
132
|
+
return () => {
|
|
133
|
+
if (raf !== 0) cancelAnimationFrame(raf)
|
|
134
|
+
observer?.disconnect()
|
|
135
|
+
window.removeEventListener('resize', applyLayout)
|
|
136
|
+
}
|
|
137
|
+
}, [visible])
|
|
138
|
+
|
|
139
|
+
// Clear any pending hint timer on unmount.
|
|
140
|
+
useEffect(() => () => {
|
|
141
|
+
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)
|
|
142
|
+
}, [])
|
|
143
|
+
|
|
144
|
+
if (!visible) return null
|
|
145
|
+
|
|
146
|
+
const onJump = (node: QuestionNode): void => {
|
|
147
|
+
if (current === undefined) return
|
|
148
|
+
setJumpingKey(node.key)
|
|
149
|
+
props.jump(current, node.key)
|
|
150
|
+
window.setTimeout(() => setJumpingKey((k) => (k === node.key ? null : k)), 600)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const t = props.t
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div ref={panelRef} className={styles.rail} data-question-nav="rail">
|
|
157
|
+
{hint !== null ? <div className={styles.hint} role="status">{hint}</div> : null}
|
|
158
|
+
<div className={styles.list}>
|
|
159
|
+
{questions.length === 0 ? (
|
|
160
|
+
<div className={styles.empty}>{t('strip.empty')}</div>
|
|
161
|
+
) : (
|
|
162
|
+
<div className={styles.dots}>
|
|
163
|
+
<span className={styles.count}>{questions.length}</span>
|
|
164
|
+
{questions.map((node) => (
|
|
165
|
+
<button
|
|
166
|
+
key={node.key}
|
|
167
|
+
className={jumpingKey === node.key ? `${styles.dot} ${styles.active}` : styles.dot}
|
|
168
|
+
aria-label={node.text}
|
|
169
|
+
onMouseEnter={(e) => {
|
|
170
|
+
const r = e.currentTarget.getBoundingClientRect()
|
|
171
|
+
setTooltip({ text: node.text, left: r.right + 10, top: r.top })
|
|
172
|
+
}}
|
|
173
|
+
onMouseLeave={() => setTooltip(null)}
|
|
174
|
+
onClick={() => onJump(node)}
|
|
175
|
+
/>
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
{tooltip !== null
|
|
181
|
+
? createPortal(
|
|
182
|
+
<div className={styles.tooltip} style={{ left: tooltip.left, top: tooltip.top }}>
|
|
183
|
+
{tooltip.text}
|
|
184
|
+
</div>,
|
|
185
|
+
document.body,
|
|
186
|
+
)
|
|
187
|
+
: null}
|
|
188
|
+
</div>
|
|
189
|
+
)
|
|
190
|
+
}
|