@luziyang2026/dsh-question-nav 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The plugin's settings page inside the shell's Plugins section
3
+ * (`settings.plugins.tab`): a segmented control choosing which edge of the
4
+ * conversation column the dot rail anchors to. The choice is written to the
5
+ * `question-nav` settings namespace (registered by the host half); the strip
6
+ * re-anchors live when the snapshot changes.
7
+ *
8
+ * @module dsh-question-nav/client/settings-tab
9
+ */
10
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
11
+ import type { QuestionNavInjected } from './QuestionNavStrip.tsx';
12
+ type ComponentProps = PropsRuntime<'settings.plugins.tab'> & QuestionNavInjected & PropsLocale<'question-nav'>;
13
+ export declare function QuestionNavSettingsTab(props: ComponentProps): React.JSX.Element | null;
14
+ export {};
@@ -1,6 +1,7 @@
1
1
  import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
2
2
  import type { SessionId } from '@deepseek-ai/dsh-client-connection/client';
3
3
  import type { QuestionNode } from '../core/nodes.ts';
4
+ import type { AlignPreference } from '../core/align.ts';
4
5
  /** Minimal observable shape of a session projection face. */
5
6
  export interface ObservableFace {
6
7
  /** Current projection value (unknown — validated structurally at read). */
@@ -20,6 +21,12 @@ export interface QuestionNavInjected {
20
21
  questionProjection: (sessionId: SessionId) => ObservableFace | undefined;
21
22
  /** Jump the chat to a question row (pages the window on demand). */
22
23
  jump: (sessionId: SessionId, key: string) => void;
24
+ /** Current rail anchor edge (defaults to 'left' before the settings section is ready). */
25
+ align: () => AlignPreference;
26
+ /** Observe anchor-edge changes; returns an unsubscribe. */
27
+ subscribeAlign: (cb: () => void) => () => void;
28
+ /** Persist a new anchor edge. */
29
+ setAlign: (align: AlignPreference) => void;
23
30
  }
24
31
  type ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>;
25
32
  export declare function QuestionNavStrip(props: ComponentProps): React.JSX.Element | null;
@@ -8,6 +8,11 @@ export declare const zh: {
8
8
  readonly 'jump.hidden': "目标无独立气泡,已定位到邻近内容";
9
9
  readonly 'jump.notfound': "目标未加载或不存在(可能已压缩)";
10
10
  readonly 'jump.timeout': "加载历史超时,可重试";
11
+ readonly 'settings.tab': "提问导航";
12
+ readonly 'settings.align.title': "导航条对齐";
13
+ readonly 'settings.align.desc': "选择圆点导航条锚定在对话栏的哪一侧。";
14
+ readonly 'settings.align.left': "左侧";
15
+ readonly 'settings.align.right': "右侧";
11
16
  };
12
17
  export declare const en: {
13
18
  readonly 'strip.empty': "No questions in this session yet";
@@ -15,5 +20,10 @@ export declare const en: {
15
20
  readonly 'jump.hidden': "No dedicated bubble; landed on nearby content";
16
21
  readonly 'jump.notfound': "Target not loaded or missing (maybe compacted)";
17
22
  readonly 'jump.timeout': "Timed out loading history; retry";
23
+ readonly 'settings.tab': "Question Nav";
24
+ readonly 'settings.align.title': "Rail alignment";
25
+ readonly 'settings.align.desc': "Choose which edge of the conversation column the dot rail anchors to.";
26
+ readonly 'settings.align.left': "Left";
27
+ readonly 'settings.align.right': "Right";
18
28
  };
19
29
  export type QuestionNavKey = keyof typeof zh;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Browser mirror of the `question-nav` settings namespace: reads the rail
3
+ * anchor edge from the settings scope (`ctx.settingsScope.bind`) and routes
4
+ * the user's choice back through `scope.set`. The namespace itself is
5
+ * registered by the host half (src/settings.ts).
6
+ *
7
+ * The settings surface is optional and may apply after this plugin, so the
8
+ * controller starts unbound and degrades to the default alignment until
9
+ * {@link attach} binds the scope (called from a fiber that injects
10
+ * `settingsScope`). The plugin keeps working everywhere it already did.
11
+ *
12
+ * @module dsh-question-nav/client/settings
13
+ */
14
+ import type { SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client';
15
+ import { type AlignPreference } from '../core/align.ts';
16
+ /** The minimal face of the settings scope service this controller needs.
17
+ * Kept structural (bind only) so the controller stays decoupled from the
18
+ * full service and is unit-testable with a stub binder. */
19
+ export interface SettingsScopeBinderLike {
20
+ bind<T>(spec: SettingsScopeSpec<T>): SettingsScope<T>;
21
+ }
22
+ /** Snapshot consumed by the strip and the settings row. */
23
+ export interface QuestionNavSettingsState {
24
+ /** Last accepted anchor edge (default while the scope is absent/loading). */
25
+ align: AlignPreference;
26
+ /** Whether the user layer overrides the composition default. */
27
+ overridden: boolean;
28
+ }
29
+ /** Reactive handle over the plugin's durable settings section. */
30
+ export declare class QuestionNavSettingsController {
31
+ private scope;
32
+ private readonly listeners;
33
+ private unsubscribe;
34
+ private state;
35
+ /**
36
+ * Bind the namespace scope once the settings surface is present. Called
37
+ * from a fiber that injects `settingsScope`, so the scope subscription
38
+ * lives on that fiber and is released with it. A no-op after the first
39
+ * bind.
40
+ * @param binder - the settings scope service.
41
+ */
42
+ attach(binder: SettingsScopeBinderLike): void;
43
+ private derive;
44
+ /** Release the scope subscription (bound on the settings fiber's lifecycle). */
45
+ dispose(): void;
46
+ /** @returns the current state (stable reference until the next change). */
47
+ getSnapshot(): QuestionNavSettingsState;
48
+ /** Observe state replacements; returns the disposer. */
49
+ subscribe(listener: () => void): () => void;
50
+ /** Route the user's anchor-edge choice to the Host document. */
51
+ setAlign(align: AlignPreference): void;
52
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Rail anchor-edge constants shared by the host schema and the browser
3
+ * settings scope. Pure data: no DSH imports, so the client bundle may inline
4
+ * this module (a Host import here would leak into the browser half).
5
+ *
6
+ * @module dsh-question-nav/align
7
+ */
8
+ /** Supported rail anchor edges. */
9
+ export declare const ALIGN_OPTIONS: readonly ["left", "right"];
10
+ /** Rail anchor edge preference. */
11
+ export type AlignPreference = typeof ALIGN_OPTIONS[number];
12
+ /** Default anchor edge when the user-settings document has no override. */
13
+ export declare const DEFAULT_ALIGN: AlignPreference;
14
+ /** Settings namespace owned by this plugin (spelled here rather than
15
+ * imported: the client bundle must not depend on a Host package). */
16
+ export declare const QUESTION_NAV_SETTINGS_NS = "question-nav";
17
+ /** Field carrying the selected anchor edge. */
18
+ export declare const ALIGN_FIELD = "align";
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Focus-magnification model for the question-nav rail. Pure index/scale math
3
+ * for the hover-selected dot's neighborhood window, its progressive
4
+ * magnification tiers, and the vertical cascade of question cards (one per
5
+ * window dot, non-overlapping, sizes descending away from the selected one).
6
+ * No React, no DOM — every function here is unit-testable in isolation and
7
+ * safe to inline into the client bundle.
8
+ *
9
+ * @module dsh-question-nav/focus
10
+ */
11
+ /** How many dots stay enlarged on each side of the selected dot. */
12
+ export declare const FOCUS_RADIUS = 2;
13
+ /** Magnification scale per tier, by distance from the selected dot
14
+ * (0 = selected, 1 = immediate neighbor, 2 = outer window edge). */
15
+ export declare const FOCUS_SCALES: readonly [2, 1.55, 1.25];
16
+ /**
17
+ * The focus tier of a dot at `distance` from the selected dot: 0..FOCUS_RADIUS
18
+ * while inside the magnification window, null beyond it (base scale).
19
+ */
20
+ export declare function focusTier(distance: number): number | null;
21
+ /** Magnification scale for a dot at `distance`; 1 (base) outside the window. */
22
+ export declare function focusScale(distance: number): number;
23
+ /** Presentation metrics for the question card of a dot at `distance` from the
24
+ * selected one. Cards stay crisp (no blur): the selected card is the widest,
25
+ * brightest and shows every text line; each of its two neighbors on each side
26
+ * is a slightly narrower, dimmer card clamped to fewer lines. Null outside
27
+ * the window (no card). */
28
+ export interface FocusCardMetrics {
29
+ /** Card width in px (narrows away from the selected card). */
30
+ widthPx: number;
31
+ /** Text font size (px); cascade cards are slightly smaller. */
32
+ fontSize: number;
33
+ /** Max text lines before clamping (cascade cards). */
34
+ maxLines: number;
35
+ /** Brightness (1 = full, the selected card; dimmer away from it). */
36
+ brightness: number;
37
+ }
38
+ export declare function focusCardMetrics(distance: number): FocusCardMetrics | null;
39
+ /**
40
+ * Indices of the focus window: the selected dot plus `radius` on each side,
41
+ * clipped to the dot list. Empty when the list is empty or `selected` is out
42
+ * of range.
43
+ */
44
+ export declare function magnificationWindow(total: number, selected: number, radius?: number): number[];
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Question sent-time formatting for the question-nav surface. Pure: no DSH
3
+ * imports, deterministic for a given (time, now) pair — unit-testable in
4
+ * isolation and safe to inline into the client bundle.
5
+ *
6
+ * @module dsh-question-nav/time
7
+ */
8
+ /** Whether `time` falls on the same calendar day as `now`. */
9
+ export declare function isSameDay(time: number, now: number): boolean;
10
+ /**
11
+ * Smart question sent-time, relative to `now`:
12
+ * - same calendar day → `HH:MM`
13
+ * - same calendar year → `MM-DD HH:MM`
14
+ * - otherwise → `YYYY-MM-DD HH:MM`
15
+ *
16
+ * Returns `''` for a missing/invalid timestamp (e.g. a live node that never
17
+ * reported one), so callers can hide the time line without branching.
18
+ */
19
+ export declare function formatQuestionTime(time: number, now: number): string;
@@ -11,10 +11,11 @@ import type { Context } from '@deepseek-ai/cordis';
11
11
  /** Cordis plugin name. */
12
12
  export declare const name = "dsh-question-nav";
13
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.
14
+ * Register the `questionIndex` unit and the plugin's durable settings
15
+ * namespace. Both registries are optional capabilities (absent in headless
16
+ * compositions), so each registration rides `ctx.inject`: without them the
17
+ * host half simply contributes nothing and the browser strip falls back to
18
+ * live-window questions and the default rail alignment.
18
19
  * @param ctx - plugin context.
19
20
  */
20
21
  export declare function apply(ctx: Context): void;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Host-side durable settings for the question-nav plugin, registered into the
3
+ * DSH user-settings document. Currently one field: which edge of the
4
+ * conversation column the rail anchors to (`align`). The browser half reads
5
+ * the same namespace through the settings scope (`ctx.settingsScope.bind`)
6
+ * and routes the user's choice back through `scope.set`.
7
+ *
8
+ * @module dsh-question-nav/settings
9
+ */
10
+ import z from '@deepseek-ai/schemastery';
11
+ import type { SettingsNamespace } from '@deepseek-ai/dsh-settings';
12
+ import { type AlignPreference } from './core/align.ts';
13
+ /** Durable settings section shared by the Host schema and the browser scope. */
14
+ export interface QuestionNavSettings {
15
+ /** Anchor edge of the rail. */
16
+ align: AlignPreference;
17
+ }
18
+ /** Durable settings schema; also the wire envelope the browser scope validates against. */
19
+ export declare const QuestionNavSettingsSchema: z<QuestionNavSettings>;
20
+ /** The settings namespace this plugin owns, branded for the Host registry.
21
+ * `question-nav` matches the registry pattern (`^[a-z][a-z0-9-]*$`), so the
22
+ * constant needs no runtime validator — keeping this module type-only on the
23
+ * settings package avoids inlining it (and cosmokit) into the host bundle. */
24
+ export declare const questionNavSettingsNamespace: SettingsNamespace;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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.4.2",
3
+ "description": "In-session question navigator for the DSH web GUI: a vertical minimap of round dots overlaid on the edge of the conversation column (left or right, configurable in settings), one dot per user question — hovering focuses a magnified window and a vertical cascade of crisp question cards showing the full text and sent time of each (any card is clickable), click jumps to that message.",
4
+ "version": "0.6.0",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.7.0",
7
7
  "engines": {
@@ -31,7 +31,8 @@
31
31
  "@deepseek-ai/dsh-client-ui-slots",
32
32
  "@deepseek-ai/dsh-client-ui-layout",
33
33
  "@deepseek-ai/dsh-client-ui-conversation",
34
- "@deepseek-ai/dsh-client-locale"
34
+ "@deepseek-ai/dsh-client-locale",
35
+ "@deepseek-ai/dsh-client-ui-settings"
35
36
  ],
36
37
  "platform": "web"
37
38
  }
@@ -55,9 +56,12 @@
55
56
  "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.1",
56
57
  "@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.1",
57
58
  "@deepseek-ai/dsh-client-ui-layout": "^0.1.1-rc.1",
59
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.1",
58
60
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.1",
59
61
  "@deepseek-ai/dsh-session": "0.1.1-rc.1",
60
62
  "@deepseek-ai/dsh-session-projection": "0.1.1-rc.1",
63
+ "@deepseek-ai/dsh-settings": "0.1.1-rc.2",
64
+ "@deepseek-ai/schemastery": "^3.18.1",
61
65
  "@testing-library/dom": "^10.4.1",
62
66
  "@testing-library/react": "^16.3.2",
63
67
  "@types/node": "^22.20.0",
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The plugin's settings page inside the shell's Plugins section
3
+ * (`settings.plugins.tab`): a segmented control choosing which edge of the
4
+ * conversation column the dot rail anchors to. The choice is written to the
5
+ * `question-nav` settings namespace (registered by the host half); the strip
6
+ * re-anchors live when the snapshot changes.
7
+ *
8
+ * @module dsh-question-nav/client/settings-tab
9
+ */
10
+
11
+ import { useEffect, useState } from 'react'
12
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
13
+ // Type-only: pulls the settings shell's SlotMap merge ('settings.plugins.tab').
14
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
15
+ import { ALIGN_OPTIONS } from '../core/align.ts'
16
+ import type { QuestionNavInjected } from './QuestionNavStrip.tsx'
17
+ import type { QuestionNavKey } from './locales.ts'
18
+ import styles from './question-nav.module.css'
19
+
20
+ type ComponentProps = PropsRuntime<'settings.plugins.tab'> & QuestionNavInjected & PropsLocale<'question-nav'>
21
+
22
+ /** Re-render on settings snapshot changes (the register inject face is static). */
23
+ function useAlignTick(subscribe: QuestionNavInjected['subscribeAlign']): void {
24
+ const [, bump] = useState(0)
25
+ useEffect(() => subscribe(() => bump((n) => n + 1)), [subscribe])
26
+ }
27
+
28
+ export function QuestionNavSettingsTab(props: ComponentProps): React.JSX.Element | null {
29
+ useAlignTick(props.subscribeAlign)
30
+ const align = props.align()
31
+ const t = props.t
32
+
33
+ return (
34
+ <div className={styles.settings}>
35
+ <p className={styles.settingsTitle}>{t('settings.align.title')}</p>
36
+ <p className={styles.settingsDesc}>{t('settings.align.desc')}</p>
37
+ <div className={styles.segmented} role="radiogroup" aria-label={t('settings.align.title')}>
38
+ {ALIGN_OPTIONS.map((option) => (
39
+ <button
40
+ key={option}
41
+ type="button"
42
+ role="radio"
43
+ aria-checked={align === option}
44
+ className={align === option ? `${styles.segment} ${styles.segmentActive}` : styles.segment}
45
+ onClick={() => props.setAlign(option)}
46
+ >
47
+ {t(option === 'left' ? 'settings.align.left' : 'settings.align.right')}
48
+ </button>
49
+ ))}
50
+ </div>
51
+ </div>
52
+ )
53
+ }