@luziyang2026/dsh-question-nav 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/README.zh.md +7 -4
- package/lib/client.js +142 -225
- package/lib/client.js.map +1 -1
- package/lib/index.js +93 -3
- package/lib/types/client/QuestionNavStrip.d.ts +11 -5
- package/lib/types/client/index.d.ts +10 -4
- package/lib/types/client/locales.d.ts +0 -6
- package/lib/types/core/history-index.d.ts +99 -0
- package/lib/types/core/question-entry.d.ts +17 -0
- package/lib/types/core/turn-dots.d.ts +48 -0
- package/lib/types/index.d.ts +17 -6
- package/lib/types/projection.d.ts +38 -0
- package/package.json +8 -4
- package/src/client/QuestionNavStrip.tsx +83 -103
- package/src/client/index.ts +20 -47
- package/src/client/locales.ts +0 -6
- package/src/client/question-nav.module.css +15 -24
- package/src/core/question-entry.ts +17 -0
- package/src/core/turn-dots.ts +98 -0
- package/src/index.ts +23 -6
- package/src/projection.ts +98 -0
- package/src/core/load-all.ts +0 -97
package/lib/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","names":["useState","useRef","styles","createPortal","DEFAULTS"],"sources":["../src/client/QuestionNavStrip.tsx","../src/client/locales.ts","../src/core/nodes.ts","../src/core/jump.ts","../src/core/load-all.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 * Dots index the WHOLE session history, not just the currently loaded window:\n * on show, the strip auto-expands older pages (`loadAllOlder`) so questions\n * that still sit behind DSH's \"load older\" button are surfaced too. While the\n * expansion is running the count shows a \"…\" affordance; if the safety budget\n * is exhausted a dimmed \"load earlier\" dot appears above the oldest 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 * load-all), 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 { LoadAllOptions, LoadAllResult } from '../core/load-all.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 /** Expand the session history until every question is loaded. */\n loadAllOlder: (sessionId: SessionId, options?: LoadAllOptions) => Promise<LoadAllResult>\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 [loadingAll, setLoadingAll] = useState(false)\n const [moreAvailable, setMoreAvailable] = useState(false)\n const panelRef = useRef<HTMLDivElement | null>(null)\n const hintTimerRef = useRef<number | null>(null)\n /** Abort controller for the in-flight expansion (cancelled on session change). */\n const loadAllAbortRef = useRef<AbortController | null>(null)\n /** Session whose expansion is already running, to avoid duplicate loops. */\n const loadingAllSessionRef = 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 // 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 // Auto-expand the full history so collapsed older questions surface as dots.\n // Runs once per session; the session notifier drives the list refresh above.\n useEffect(() => {\n if (!visible || current === undefined) {\n loadAllAbortRef.current?.abort()\n loadAllAbortRef.current = null\n loadingAllSessionRef.current = null\n setLoadingAll(false)\n setMoreAvailable(false)\n return\n }\n if (loadingAllSessionRef.current === current) return\n loadingAllSessionRef.current = current\n const controller = new AbortController()\n loadAllAbortRef.current = controller\n setLoadingAll(true)\n setMoreAvailable(false)\n props.loadAllOlder(current, { signal: controller.signal })\n .then((result) => {\n // Budget exhausted but more history still exists: offer \"load earlier\".\n setMoreAvailable(result.code === 'BUDGET' && !result.ok)\n })\n .finally(() => {\n setLoadingAll(false)\n if (loadAllAbortRef.current === controller) loadAllAbortRef.current = null\n loadingAllSessionRef.current = null\n })\n return () => {\n controller.abort()\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 onLoadMore = (): void => {\n if (current === undefined) return\n setMoreAvailable(false)\n setLoadingAll(true)\n props.loadAllOlder(current)\n .then((result) => {\n setMoreAvailable(result.code === 'BUDGET' && !result.ok)\n })\n .finally(() => setLoadingAll(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}>{loadingAll ? t('strip.loadingAll') : t('strip.empty')}</div>\n ) : (\n <div className={styles.dots}>\n <span className={styles.count}>\n {questions.length}\n {loadingAll ? <span className={styles.countLoading}>{t('strip.loadingSuffix')}</span> : null}\n </span>\n {moreAvailable && !loadingAll ? (\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 * 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 * Load-all orchestration for the question-nav strip.\n *\n * DSH sessions page history in fixed-size chunks: `chat.nodes` only ever holds\n * the currently loaded window, and questions that still sit behind the \"load\n * older\" button are invisible to the strip until the window is expanded\n * backwards. This loop pages `loadOlder()` until `hasMore` is false (the whole\n * history is materialized), so every user question becomes a dot.\n *\n * Pure-ish: takes injected ports (snapshot read, one paged loadOlder, view\n * liveness, clocks) so it is unit-testable without a browser or session.\n */\n\nexport interface LoadAllSnapshot {\n openState: string\n hasMore: boolean\n loadingOlder: boolean\n}\n\nexport interface LoadAllPorts {\n /** Read the current session snapshot; undefined when unavailable. */\n snapshot: () => LoadAllSnapshot | undefined\n /** Expand the window backwards by one page (may preserve scroll). */\n loadOlder: () => Promise<void>\n /** True while the chat view is active (a `[data-chat-flow]` is mounted). */\n isViewActive: () => boolean\n /** Monotonic ms clock. */\n now: () => number\n /** Async sleep. */\n sleep: (ms: number) => Promise<void>\n}\n\nexport interface LoadAllOptions {\n /** Max older pages to fetch before giving up (default 400). */\n maxPages?: number\n /** Total wall-clock budget for the whole expansion (default 60s). */\n totalTimeoutMs?: number\n /** Poll interval for open/loading transitions (default 60ms). */\n pollMs?: number\n /** Abort the expansion; checked every iteration. */\n signal?: AbortSignal\n}\n\nexport type LoadAllCode =\n | 'COMPLETE'\n | 'VIEW_INACTIVE'\n | 'NOT_OPEN'\n | 'BUDGET'\n | 'TIMEOUT'\n | 'CANCELLED'\n\nexport interface LoadAllResult {\n ok: boolean\n code: LoadAllCode\n /** Number of `loadOlder` pages actually fetched. */\n pages: number\n}\n\nconst DEFAULTS = {\n maxPages: 400,\n totalTimeoutMs: 60_000,\n pollMs: 60,\n}\n\n/**\n * Expand the session window backwards until the earliest history is loaded.\n * Waits while the session is still opening; aborts on cancellation, budget or\n * timeout. Safe to re-enter: once `hasMore` is false the loop returns\n * immediately with `COMPLETE`.\n */\nexport async function loadAllOlder(ports: LoadAllPorts, options: LoadAllOptions = {}): Promise<LoadAllResult> {\n const cfg = { ...DEFAULTS, ...options }\n const deadline = ports.now() + cfg.totalTimeoutMs\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', pages }\n if (!ports.isViewActive()) return { ok: false, code: 'VIEW_INACTIVE', pages }\n const snap = ports.snapshot()\n if (snap === undefined) return { ok: false, code: 'VIEW_INACTIVE', pages }\n if (snap.openState === 'error') return { ok: false, code: 'NOT_OPEN', pages }\n // Nothing older left: the whole history is in the window.\n if (snap.hasMore !== true) return { ok: true, code: 'COMPLETE', pages }\n if (pages >= cfg.maxPages) return { ok: false, code: 'BUDGET', pages }\n if (ports.now() > deadline) return { ok: false, code: 'TIMEOUT', pages }\n // Wait while the session is still opening or a page is already in flight\n // (a user-initiated \"load older\" click shares this same gate).\n if (snap.openState !== 'open' || snap.loadingOlder) {\n await ports.sleep(cfg.pollMs)\n continue\n }\n await ports.loadOlder()\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 (paging older history when needed). The\n * strip auto-expands the whole session history so even collapsed older\n * questions are surfaced as dots.\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'\nimport { loadAllOlder, type LoadAllOptions, type LoadAllResult } from '../core/load-all.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\n/** Resolve the active conversation scrollport (or null when not mounted). */\nfunction scrollport(): HTMLElement | null {\n return document.querySelector<HTMLElement>('[data-conversation-scroll]')\n}\n\n/**\n * One backward page that preserves the reader's scroll position. DSH's own\n * \"load older\" button arms a paging anchor; a programmatic `loadOlder()` does\n * not, so without this compensation prepended content would push the visible\n * rows down. We restore by the exact growth of the scrollHeight.\n */\nasync function pagedLoadOlder(ctx: ClientContext, sessionId: SessionId): Promise<void> {\n const binding = ctx.sessions.binding(sessionId)\n if (binding === undefined) return\n const port = scrollport()\n const beforeHeight = port?.scrollHeight ?? 0\n const beforeTop = port?.scrollTop ?? 0\n await binding.session.loadOlder()\n if (port === null) return\n // Let React commit the prepend before measuring the new height.\n await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))\n const delta = port.scrollHeight - beforeHeight\n if (delta > 0) port.scrollTop = beforeTop + delta\n}\n\n/** Map the session to the load-all port surface. */\nfunction loadAllPortsFor(ctx: ClientContext, sessionId: SessionId): Parameters<typeof loadAllOlder>[0] {\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 { openState: snap.openState, hasMore: snap.hasMore, loadingOlder: snap.loadingOlder }\n },\n loadOlder: () => pagedLoadOlder(ctx, sessionId),\n isViewActive: () => document.querySelector('[data-chat-flow]') !== null,\n now: () => Date.now(),\n sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)),\n }\n}\n\n/** Expand the whole session history so every question becomes a dot. */\nfunction loadAllFor(ctx: ClientContext, sessionId: SessionId, options: LoadAllOptions = {}): Promise<LoadAllResult> {\n return loadAllOlder(loadAllPortsFor(ctx, sessionId), 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 loadAllOlder: (sessionId, options) => loadAllFor(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
|
+
{"version":3,"file":"client.js","names":["kind","useState","useRef","styles","createPortal"],"sources":["../src/core/turn-dots.ts","../src/client/QuestionNavStrip.tsx","../src/client/locales.ts","../src/core/nodes.ts","../src/core/jump.ts","../src/client/index.ts"],"sourcesContent":["/**\n * Turn-aligned dot model for the question-nav strip. Pure transforms over the\n * `questionIndex` projection value plus the live chat window — no React, no\n * DOM — so the grouping and merging rules are unit-testable in isolation.\n *\n * One dot per turn that claimed at least one user question; turns without a\n * question (retry, goal continuation, cancelled empty turns) produce no dot,\n * so dot turn labels may skip numbers but always match the Trajectory view.\n */\n\nimport type { QuestionEntry } from './question-entry.ts'\nimport type { QuestionNode } from './nodes.ts'\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 — mirrors\n * `conversationContextKey('input-message', String(id))` from the DSH runtime\n * (verified against that formula 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/** One strip dot: a turn's questions (grouped) or one ungrouped live question. */\nexport interface TurnDot {\n /** Owning turn number; null for live questions the projection has not seen. */\n readonly turn: number | null\n /** Jump anchor: the chat key of the turn's FIRST question. */\n readonly key: string\n /** Anchor seq of the first question (ordering + jump target). */\n readonly anchorSeq: number\n /** Unix ms of the first question. */\n readonly time: number\n /** Every question text of this dot, in order (tooltip lists them all). */\n readonly texts: readonly string[]\n /** Chat keys of every question folded into this dot (live-merge dedupe). */\n readonly memberKeys: readonly string[]\n}\n\n/**\n * Fold the projection's question list into one dot per turn. Entries arrive\n * in event order; consecutive same-turn entries merge into a single dot whose\n * anchor is the turn's first question.\n */\nexport function groupQuestionsByTurn(entries: readonly QuestionEntry[]): TurnDot[] {\n const sorted = [...entries].sort((a, b) => a.seq - b.seq)\n const dots: TurnDot[] = []\n for (const entry of sorted) {\n const key = questionKey(entry.id)\n const last = dots.at(-1)\n if (last !== undefined && last.turn === entry.turn) {\n dots[dots.length - 1] = {\n ...last,\n texts: [...last.texts, entry.text],\n memberKeys: [...last.memberKeys, key],\n }\n continue\n }\n dots.push({\n turn: entry.turn,\n key,\n anchorSeq: entry.seq,\n time: entry.time,\n texts: [entry.text],\n memberKeys: [key],\n })\n }\n return dots\n}\n\n/**\n * Merge live-window questions the projection has not recorded yet (the brief\n * window before the session/projection push frame lands). A live question\n * whose key is already folded into a dot is dropped (the projected copy\n * wins); the rest become single-question dots with `turn: null`, inserted in\n * anchor-seq order so the strip stays strictly chronological.\n */\nexport function mergeLiveQuestions(\n dots: readonly TurnDot[],\n live: readonly QuestionNode[],\n): TurnDot[] {\n const known = new Set(dots.flatMap(dot => dot.memberKeys))\n const extras: TurnDot[] = live\n .filter(question => !known.has(question.key))\n .map(question => ({\n turn: null,\n key: question.key,\n anchorSeq: question.anchorSeq,\n time: question.time,\n texts: [question.text],\n memberKeys: [question.key],\n }))\n if (extras.length === 0) return [...dots]\n return [...dots, ...extras].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 turn that\n * claimed at least one user question — strictly aligned with the Trajectory\n * view's turn numbering (turns without a question produce no dot). Hover\n * enlarges a dot and shows an instant tooltip (portal-rendered, no native\n * delay) with the turn label and the turn's question text(s); clicking jumps\n * the chat to that turn's first question.\n *\n * Data source: the host-folded `questionIndex` session projection (whole\n * history, persisted host-side, pushed live through session/projection\n * frames) read through the injected `questionProjection` face, plus the live\n * chat window's questions merged on top for the brief window before a\n * just-sent question lands in the projection. No render-window expansion, no\n * client-side history paging.\n *\n * Data arrives through the props shares: the framework `useSessions` hook\n * (current session), the registrant inject face (read/subscribe/project/\n * jump), 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 { QuestionEntry } from '../core/question-entry.ts'\nimport { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'\nimport type { JumpFailureCode } from '../core/jump.ts'\nimport type { QuestionNavKey } from './locales.ts'\nimport styles from './question-nav.module.css'\n\n/** Minimal observable shape of a session projection face. */\nexport interface ObservableFace {\n /** Current projection value (unknown — validated structurally at read). */\n getSnapshot: () => unknown\n /** Subscribe to value changes; returns an unsubscribe. */\n subscribe: (listener: () => void) => () => void\n}\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 /** The session's `questionIndex` projection face, when the host unit is registered. */\n questionProjection: (sessionId: SessionId) => ObservableFace | undefined\n /** Jump the chat to a question row (pages the window on demand). */\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 /** Turn label line (e.g. \"Turn 32\"); null for ungrouped live questions. */\n title: string | null\n /** Question text lines (one per question folded into the dot). */\n lines: readonly string[]\n left: number\n top: number\n}\n\n/** Read the projection face value as a question-entry list (structural guard). */\nfunction projectionEntries(face: ObservableFace | undefined): QuestionEntry[] {\n const value = face?.getSnapshot()\n if (!Array.isArray(value)) return []\n return value.filter((item): item is QuestionEntry =>\n typeof item === 'object' && item !== null\n && typeof (item as QuestionEntry).id === 'string'\n && typeof (item as QuestionEntry).seq === 'number'\n && typeof (item as QuestionEntry).turn === '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 [dots, setDots] = useState<TurnDot[]>([])\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 // Recompute the dot list from the projection + live window; subscribe to\n // the projection push frames, session content, and the session list.\n useEffect(() => {\n if (!visible || current === undefined) {\n setDots([])\n return\n }\n const sessionId = current\n const face = props.questionProjection(sessionId)\n const refresh = (): void => {\n const grouped = groupQuestionsByTurn(projectionEntries(face))\n setDots(mergeLiveQuestions(grouped, props.readQuestions(sessionId)))\n }\n refresh()\n const unsubProjection = face?.subscribe(refresh) ?? (() => {})\n const unsubContent = props.subscribeContent(sessionId, refresh)\n const unsubList = props.subscribeList(refresh)\n return () => {\n unsubProjection()\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 = (dot: TurnDot): void => {\n if (current === undefined) return\n setJumpingKey(dot.key)\n props.jump(current, dot.key)\n window.setTimeout(() => setJumpingKey((k) => (k === dot.key ? null : k)), 600)\n }\n\n const openTooltip = (dot: TurnDot, target: HTMLElement): void => {\n const r = target.getBoundingClientRect()\n setTooltip({\n title: dot.turn === null ? null : `Turn ${dot.turn}`,\n lines: dot.texts,\n left: r.right + 10,\n top: r.top,\n })\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 {dots.length === 0 ? (\n <div className={styles.empty}>{t('strip.empty')}</div>\n ) : (\n <div className={styles.dots}>\n <span className={styles.count}>{dots.length}</span>\n {dots.map((dot) => (\n <button\n key={dot.key}\n className={jumpingKey === dot.key ? `${styles.dot} ${styles.active}` : styles.dot}\n aria-label={dot.texts[0] ?? ''}\n onMouseEnter={(e) => openTooltip(dot, e.currentTarget)}\n onMouseLeave={() => setTooltip(null)}\n onClick={() => onJump(dot)}\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.title !== null ? <div className={styles.tooltipTitle}>{tooltip.title}</div> : null}\n {tooltip.lines.map((line, index) => (\n <div key={index} className={styles.tooltipLine}>{line}</div>\n ))}\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-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, one dot per turn.\n * Clicking a button scrolls the chat to that turn's first question.\n *\n * The dots are driven by the host-folded `questionIndex` session projection\n * (registered by the plugin's host half): the projection registry folds the\n * WHOLE event log without touching the chat's paged render window, the\n * projection cache persists it, and the standard carriers (history tail-page\n * baseline + session/projection push frames) keep it live. Live-window\n * questions not yet recorded by the projection are merged on top so a\n * just-sent question appears immediately.\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 ObservableFace, 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\n/**\n * The session's `questionIndex` projection face (getSnapshot + subscribe).\n * Undefined when the session is not bound or the host unit is not registered\n * (e.g. a headless composition) — the strip then shows live-window dots only.\n */\nfunction questionProjectionOf(ctx: ClientContext, sessionId: SessionId): ObservableFace | undefined {\n const face = ctx.sessions.binding(sessionId)?.session.projections.faceOf('questionIndex')\n if (face === undefined) return undefined\n return {\n getSnapshot: () => face.getSnapshot(),\n subscribe: (listener) => face.subscribe(listener),\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 questionProjection: (sessionId) => questionProjectionOf(ctx, sessionId),\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
CHANGED
|
@@ -1,5 +1,95 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
//#region src/projection.ts
|
|
3
|
+
/**
|
|
4
|
+
* The `questionIndex` session projection unit: a pure fold of the session
|
|
5
|
+
* event log into the ordered list of user questions, each tagged with the
|
|
6
|
+
* turn that claimed it. Registered on `ctx.sessionProjections` by the host
|
|
7
|
+
* half (src/index.ts); persistence, replay, and client delivery are the
|
|
8
|
+
* projection seam's (session-projection-cache checkpoints the state, the
|
|
9
|
+
* api-proxy carriers seed + push the wire view).
|
|
10
|
+
*
|
|
11
|
+
* Fold rules mirror the chat messageDefinition classification: an
|
|
12
|
+
* append-origin `user/message` with a human (`user`) source is a question;
|
|
13
|
+
* replacement copies (compaction checkpoints) and injected context are not.
|
|
14
|
+
* Turns come from `turn/start` boundaries, so retry/goal-continuation turns
|
|
15
|
+
* without a question simply produce no entry — dots may skip turn numbers,
|
|
16
|
+
* staying exactly aligned with the Trajectory view's turn labels.
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-question-nav/projection
|
|
19
|
+
*/
|
|
20
|
+
const questionEntrySchema = z.object({
|
|
21
|
+
turn: z.number().int().nonnegative(),
|
|
22
|
+
id: z.string(),
|
|
23
|
+
seq: z.number().int().nonnegative(),
|
|
24
|
+
time: z.number().nonnegative(),
|
|
25
|
+
text: z.string()
|
|
26
|
+
}).strict();
|
|
27
|
+
/** Validates persisted rows after their `ver` gate (the unit's input boundary). */
|
|
28
|
+
const questionIndexStateSchema = z.object({
|
|
29
|
+
turn: z.number().int().nonnegative(),
|
|
30
|
+
questions: z.array(questionEntrySchema)
|
|
31
|
+
}).strict();
|
|
32
|
+
/** Validates the wire payload before it leaves the host. */
|
|
33
|
+
const questionIndexViewSchema = z.array(questionEntrySchema);
|
|
34
|
+
/** First text block of a user message; empty string when absent. */
|
|
35
|
+
function messageText(content) {
|
|
36
|
+
if (content === void 0 || content.length === 0) return "";
|
|
37
|
+
const first = content[0];
|
|
38
|
+
return typeof first?.text === "string" ? first.text : "";
|
|
39
|
+
}
|
|
40
|
+
/** The `questionIndex` unit registered on `ctx.sessionProjections`. */
|
|
41
|
+
const questionIndexProjectionDefinition = {
|
|
42
|
+
key: "questionIndex",
|
|
43
|
+
stateVersion: 1,
|
|
44
|
+
stateSchema: questionIndexStateSchema,
|
|
45
|
+
init: () => ({
|
|
46
|
+
turn: 0,
|
|
47
|
+
questions: []
|
|
48
|
+
}),
|
|
49
|
+
apply: (state, event) => {
|
|
50
|
+
switch (event.type) {
|
|
51
|
+
case "turn/start": return event.data.turn === state.turn ? state : {
|
|
52
|
+
...state,
|
|
53
|
+
turn: event.data.turn
|
|
54
|
+
};
|
|
55
|
+
case "user/message": {
|
|
56
|
+
if (event.surfaceOp !== "append") return state;
|
|
57
|
+
if (event.data.source?.kind !== "user") return state;
|
|
58
|
+
const entry = {
|
|
59
|
+
turn: state.turn,
|
|
60
|
+
id: String(event.data.id),
|
|
61
|
+
seq: event.seq,
|
|
62
|
+
time: event.time,
|
|
63
|
+
text: messageText(event.data.content)
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
...state,
|
|
67
|
+
questions: [...state.questions, entry]
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
default: return state;
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
wire: {
|
|
74
|
+
viewSchema: questionIndexViewSchema,
|
|
75
|
+
view: (state) => state.questions
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
//#endregion
|
|
1
79
|
//#region src/index.ts
|
|
2
|
-
/**
|
|
3
|
-
|
|
80
|
+
/** Cordis plugin name. */
|
|
81
|
+
const name = "dsh-question-nav";
|
|
82
|
+
/**
|
|
83
|
+
* Register the `questionIndex` unit. The registry is an optional capability
|
|
84
|
+
* (absent in headless compositions), so registration rides `ctx.inject`:
|
|
85
|
+
* without it the host half simply contributes nothing and the browser strip
|
|
86
|
+
* falls back to live-window questions.
|
|
87
|
+
* @param ctx - plugin context.
|
|
88
|
+
*/
|
|
89
|
+
function apply(ctx) {
|
|
90
|
+
ctx.inject(["sessionProjections"], (inner) => {
|
|
91
|
+
inner.sessionProjections.register(questionIndexProjectionDefinition);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
4
94
|
//#endregion
|
|
5
|
-
export { apply };
|
|
95
|
+
export { apply, name };
|
|
@@ -1,19 +1,25 @@
|
|
|
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
|
-
|
|
4
|
+
/** Minimal observable shape of a session projection face. */
|
|
5
|
+
export interface ObservableFace {
|
|
6
|
+
/** Current projection value (unknown — validated structurally at read). */
|
|
7
|
+
getSnapshot: () => unknown;
|
|
8
|
+
/** Subscribe to value changes; returns an unsubscribe. */
|
|
9
|
+
subscribe: (listener: () => void) => () => void;
|
|
10
|
+
}
|
|
5
11
|
/** Values the registrant inject face supplies (wired in src/client/index.ts). */
|
|
6
12
|
export interface QuestionNavInjected {
|
|
7
|
-
/** Extract the user questions of a session
|
|
13
|
+
/** Extract the user questions of a session's currently loaded window. */
|
|
8
14
|
readQuestions: (sessionId: SessionId) => QuestionNode[];
|
|
9
15
|
/** Subscribe to the session list; returns an unsubscribe. */
|
|
10
16
|
subscribeList: (cb: () => void) => () => void;
|
|
11
17
|
/** Subscribe to a session's content; returns an unsubscribe. */
|
|
12
18
|
subscribeContent: (sessionId: SessionId, cb: () => void) => () => void;
|
|
13
|
-
/**
|
|
19
|
+
/** The session's `questionIndex` projection face, when the host unit is registered. */
|
|
20
|
+
questionProjection: (sessionId: SessionId) => ObservableFace | undefined;
|
|
21
|
+
/** Jump the chat to a question row (pages the window on demand). */
|
|
14
22
|
jump: (sessionId: SessionId, key: string) => void;
|
|
15
|
-
/** Expand the session history until every question is loaded. */
|
|
16
|
-
loadAllOlder: (sessionId: SessionId, options?: LoadAllOptions) => Promise<LoadAllResult>;
|
|
17
23
|
}
|
|
18
24
|
type ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>;
|
|
19
25
|
export declare function QuestionNavStrip(props: ComponentProps): React.JSX.Element | null;
|
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Registers one surface into the frame-wide floating layer (`shell.overlay`):
|
|
5
5
|
* a vertical strip on the LEFT edge of the conversation column listing every
|
|
6
|
-
* user question in the current session as a small button
|
|
7
|
-
* scrolls the chat to that
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* user question in the current session as a small button, one dot per turn.
|
|
7
|
+
* Clicking a button scrolls the chat to that turn's first question.
|
|
8
|
+
*
|
|
9
|
+
* The dots are driven by the host-folded `questionIndex` session projection
|
|
10
|
+
* (registered by the plugin's host half): the projection registry folds the
|
|
11
|
+
* WHOLE event log without touching the chat's paged render window, the
|
|
12
|
+
* projection cache persists it, and the standard carriers (history tail-page
|
|
13
|
+
* baseline + session/projection push frames) keep it live. Live-window
|
|
14
|
+
* questions not yet recorded by the projection are merged on top so a
|
|
15
|
+
* just-sent question appears immediately.
|
|
10
16
|
*
|
|
11
17
|
* Failure policy: nothing here throws at apply time — an external plugin must
|
|
12
18
|
* never take the GUI down.
|
|
@@ -4,9 +4,6 @@
|
|
|
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': "加载更早的问题";
|
|
10
7
|
readonly 'jump.inactive': "聊天视图未激活";
|
|
11
8
|
readonly 'jump.hidden': "目标无独立气泡,已定位到邻近内容";
|
|
12
9
|
readonly 'jump.notfound': "目标未加载或不存在(可能已压缩)";
|
|
@@ -14,9 +11,6 @@ export declare const zh: {
|
|
|
14
11
|
};
|
|
15
12
|
export declare const en: {
|
|
16
13
|
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";
|
|
20
14
|
readonly 'jump.inactive': "Chat view is not active";
|
|
21
15
|
readonly 'jump.hidden': "No dedicated bubble; landed on nearby content";
|
|
22
16
|
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,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One user question recorded by the `questionIndex` session projection.
|
|
3
|
+
* Shared by the host fold (src/projection.ts) and the browser strip — the
|
|
4
|
+
* wire value is an array of these entries, in seq order.
|
|
5
|
+
*/
|
|
6
|
+
export interface QuestionEntry {
|
|
7
|
+
/** Turn that claimed this question (turn/start the event followed). */
|
|
8
|
+
turn: number;
|
|
9
|
+
/** UserMessage id; the client derives the chat anchor key from it. */
|
|
10
|
+
id: string;
|
|
11
|
+
/** Event seq of the user/message event (ordering + jump anchor seq). */
|
|
12
|
+
seq: number;
|
|
13
|
+
/** Unix ms timestamp of the question. */
|
|
14
|
+
time: number;
|
|
15
|
+
/** First text block of the question (hover tooltip body). */
|
|
16
|
+
text: string;
|
|
17
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn-aligned dot model for the question-nav strip. Pure transforms over the
|
|
3
|
+
* `questionIndex` projection value plus the live chat window — no React, no
|
|
4
|
+
* DOM — so the grouping and merging rules are unit-testable in isolation.
|
|
5
|
+
*
|
|
6
|
+
* One dot per turn that claimed at least one user question; turns without a
|
|
7
|
+
* question (retry, goal continuation, cancelled empty turns) produce no dot,
|
|
8
|
+
* so dot turn labels may skip numbers but always match the Trajectory view.
|
|
9
|
+
*/
|
|
10
|
+
import type { QuestionEntry } from './question-entry.ts';
|
|
11
|
+
import type { QuestionNode } from './nodes.ts';
|
|
12
|
+
/** The conversation Definition kind whose key a user question node uses. */
|
|
13
|
+
export declare const MESSAGE_DEFINITION_KIND = "input-message";
|
|
14
|
+
/**
|
|
15
|
+
* The engine-owned stable chat key for a user question — mirrors
|
|
16
|
+
* `conversationContextKey('input-message', String(id))` from the DSH runtime
|
|
17
|
+
* (verified against that formula in the unit test).
|
|
18
|
+
*/
|
|
19
|
+
export declare function questionKey(id: unknown): string;
|
|
20
|
+
/** One strip dot: a turn's questions (grouped) or one ungrouped live question. */
|
|
21
|
+
export interface TurnDot {
|
|
22
|
+
/** Owning turn number; null for live questions the projection has not seen. */
|
|
23
|
+
readonly turn: number | null;
|
|
24
|
+
/** Jump anchor: the chat key of the turn's FIRST question. */
|
|
25
|
+
readonly key: string;
|
|
26
|
+
/** Anchor seq of the first question (ordering + jump target). */
|
|
27
|
+
readonly anchorSeq: number;
|
|
28
|
+
/** Unix ms of the first question. */
|
|
29
|
+
readonly time: number;
|
|
30
|
+
/** Every question text of this dot, in order (tooltip lists them all). */
|
|
31
|
+
readonly texts: readonly string[];
|
|
32
|
+
/** Chat keys of every question folded into this dot (live-merge dedupe). */
|
|
33
|
+
readonly memberKeys: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Fold the projection's question list into one dot per turn. Entries arrive
|
|
37
|
+
* in event order; consecutive same-turn entries merge into a single dot whose
|
|
38
|
+
* anchor is the turn's first question.
|
|
39
|
+
*/
|
|
40
|
+
export declare function groupQuestionsByTurn(entries: readonly QuestionEntry[]): TurnDot[];
|
|
41
|
+
/**
|
|
42
|
+
* Merge live-window questions the projection has not recorded yet (the brief
|
|
43
|
+
* window before the session/projection push frame lands). A live question
|
|
44
|
+
* whose key is already folded into a dot is dropped (the projected copy
|
|
45
|
+
* wins); the rest become single-question dots with `turn: null`, inserted in
|
|
46
|
+
* anchor-seq order so the strip stays strictly chronological.
|
|
47
|
+
*/
|
|
48
|
+
export declare function mergeLiveQuestions(dots: readonly TurnDot[], live: readonly QuestionNode[]): TurnDot[];
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Host
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Host half of the dsh-question-nav plugin — runs in the DSH host process.
|
|
3
|
+
* Registers the `questionIndex` session projection unit: the ordered list of
|
|
4
|
+
* user questions (each tagged with its turn), folded from the session event
|
|
5
|
+
* log by the projection registry, persisted by the projection cache, and
|
|
6
|
+
* delivered to the browser through the standard projection carriers (history
|
|
7
|
+
* tail-page baseline + session/projection push frames). The navigation UI
|
|
8
|
+
* itself lives in the browser half (src/client).
|
|
6
9
|
*/
|
|
7
10
|
import type { Context } from '@deepseek-ai/cordis';
|
|
8
|
-
/**
|
|
9
|
-
export declare
|
|
11
|
+
/** Cordis plugin name. */
|
|
12
|
+
export declare const name = "dsh-question-nav";
|
|
13
|
+
/**
|
|
14
|
+
* Register the `questionIndex` unit. The registry is an optional capability
|
|
15
|
+
* (absent in headless compositions), so registration rides `ctx.inject`:
|
|
16
|
+
* without it the host half simply contributes nothing and the browser strip
|
|
17
|
+
* falls back to live-window questions.
|
|
18
|
+
* @param ctx - plugin context.
|
|
19
|
+
*/
|
|
20
|
+
export declare function apply(ctx: Context): void;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `questionIndex` session projection unit: a pure fold of the session
|
|
3
|
+
* event log into the ordered list of user questions, each tagged with the
|
|
4
|
+
* turn that claimed it. Registered on `ctx.sessionProjections` by the host
|
|
5
|
+
* half (src/index.ts); persistence, replay, and client delivery are the
|
|
6
|
+
* projection seam's (session-projection-cache checkpoints the state, the
|
|
7
|
+
* api-proxy carriers seed + push the wire view).
|
|
8
|
+
*
|
|
9
|
+
* Fold rules mirror the chat messageDefinition classification: an
|
|
10
|
+
* append-origin `user/message` with a human (`user`) source is a question;
|
|
11
|
+
* replacement copies (compaction checkpoints) and injected context are not.
|
|
12
|
+
* Turns come from `turn/start` boundaries, so retry/goal-continuation turns
|
|
13
|
+
* without a question simply produce no entry — dots may skip turn numbers,
|
|
14
|
+
* staying exactly aligned with the Trajectory view's turn labels.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-question-nav/projection
|
|
17
|
+
*/
|
|
18
|
+
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection';
|
|
19
|
+
import type { QuestionEntry } from './core/question-entry.ts';
|
|
20
|
+
/** Fold state: the last opened turn plus every question recorded so far. */
|
|
21
|
+
export interface QuestionIndexState {
|
|
22
|
+
/** Turn of the last `turn/start` (0 before any; turns are 1-based). */
|
|
23
|
+
turn: number;
|
|
24
|
+
/** Every recorded question, in event order. */
|
|
25
|
+
questions: QuestionEntry[];
|
|
26
|
+
}
|
|
27
|
+
declare module '@deepseek-ai/dsh-session-projection/types' {
|
|
28
|
+
interface SessionProjectionMap {
|
|
29
|
+
questionIndex: QuestionEntry[];
|
|
30
|
+
}
|
|
31
|
+
interface SessionProjectionStateMap {
|
|
32
|
+
questionIndex: QuestionIndexState;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** The `questionIndex` unit registered on `ctx.sessionProjections`. */
|
|
36
|
+
export declare const questionIndexProjectionDefinition: Omit<ProjectionDefinition<'questionIndex', QuestionIndexState>, 'wire'> & {
|
|
37
|
+
wire: NonNullable<ProjectionDefinition<'questionIndex', QuestionIndexState>['wire']>;
|
|
38
|
+
};
|