@luziyang2026/dsh-question-nav 0.4.1 → 0.5.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 -2
- package/README.zh.md +8 -2
- package/lib/client.js +303 -46
- package/lib/client.js.map +1 -1
- package/lib/index.js +832 -4
- package/lib/types/client/QuestionNavSettingsTab.d.ts +14 -0
- package/lib/types/client/QuestionNavStrip.d.ts +7 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/client/settings.d.ts +52 -0
- package/lib/types/core/align.d.ts +18 -0
- package/lib/types/core/turn-dots.d.ts +6 -1
- package/lib/types/index.d.ts +5 -4
- package/lib/types/settings.d.ts +24 -0
- package/package.json +7 -3
- package/src/client/QuestionNavSettingsTab.tsx +53 -0
- package/src/client/QuestionNavStrip.tsx +147 -30
- package/src/client/index.ts +28 -2
- package/src/client/locales.ts +10 -0
- package/src/client/question-nav.module.css +55 -0
- package/src/client/settings.ts +105 -0
- package/src/core/align.ts +23 -0
- package/src/core/turn-dots.ts +47 -9
- package/src/index.ts +9 -4
- package/src/settings.ts +33 -0
|
@@ -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";
|
|
@@ -35,7 +35,9 @@ export interface TurnDot {
|
|
|
35
35
|
/**
|
|
36
36
|
* Fold the projection's question list into one dot per turn. Entries arrive
|
|
37
37
|
* in event order; consecutive same-turn entries merge into a single dot whose
|
|
38
|
-
* anchor is the turn's first question.
|
|
38
|
+
* anchor is the turn's first question. The input is expected to be sorted by
|
|
39
|
+
* seq; the defensive sort is skipped when it already is, so a long session
|
|
40
|
+
* never pays an O(n log n) sort on every content update.
|
|
39
41
|
*/
|
|
40
42
|
export declare function groupQuestionsByTurn(entries: readonly QuestionEntry[]): TurnDot[];
|
|
41
43
|
/**
|
|
@@ -44,5 +46,8 @@ export declare function groupQuestionsByTurn(entries: readonly QuestionEntry[]):
|
|
|
44
46
|
* whose key is already folded into a dot is dropped (the projected copy
|
|
45
47
|
* wins); the rest become single-question dots with `turn: null`, inserted in
|
|
46
48
|
* anchor-seq order so the strip stays strictly chronological.
|
|
49
|
+
*
|
|
50
|
+
* Fast path: when nothing new arrives the SAME array is returned (no copy),
|
|
51
|
+
* so the caller can bail out of a re-render on identical reference.
|
|
47
52
|
*/
|
|
48
53
|
export declare function mergeLiveQuestions(dots: readonly TurnDot[], live: readonly QuestionNode[]): TurnDot[];
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* falls back to
|
|
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
|
|
4
|
-
"version": "0.
|
|
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 — hover enlarges and shows the full question text, click jumps to that message.",
|
|
4
|
+
"version": "0.5.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
|
+
}
|
|
@@ -28,6 +28,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
|
|
28
28
|
import type { QuestionNode } from '../core/nodes.ts'
|
|
29
29
|
import type { QuestionEntry } from '../core/question-entry.ts'
|
|
30
30
|
import { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'
|
|
31
|
+
import type { AlignPreference } from '../core/align.ts'
|
|
31
32
|
import type { JumpFailureCode } from '../core/jump.ts'
|
|
32
33
|
import type { QuestionNavKey } from './locales.ts'
|
|
33
34
|
import styles from './question-nav.module.css'
|
|
@@ -52,6 +53,12 @@ export interface QuestionNavInjected {
|
|
|
52
53
|
questionProjection: (sessionId: SessionId) => ObservableFace | undefined
|
|
53
54
|
/** Jump the chat to a question row (pages the window on demand). */
|
|
54
55
|
jump: (sessionId: SessionId, key: string) => void
|
|
56
|
+
/** Current rail anchor edge (defaults to 'left' before the settings section is ready). */
|
|
57
|
+
align: () => AlignPreference
|
|
58
|
+
/** Observe anchor-edge changes; returns an unsubscribe. */
|
|
59
|
+
subscribeAlign: (cb: () => void) => () => void
|
|
60
|
+
/** Persist a new anchor edge. */
|
|
61
|
+
setAlign: (align: AlignPreference) => void
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
type ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>
|
|
@@ -69,7 +76,9 @@ interface TooltipState {
|
|
|
69
76
|
title: string | null
|
|
70
77
|
/** Question text lines (one per question folded into the dot). */
|
|
71
78
|
lines: readonly string[]
|
|
72
|
-
left
|
|
79
|
+
/** Horizontal anchor — left edge (left-aligned rail) or right edge (right-aligned). */
|
|
80
|
+
left?: number
|
|
81
|
+
right?: number
|
|
73
82
|
top: number
|
|
74
83
|
}
|
|
75
84
|
|
|
@@ -88,6 +97,22 @@ function findConvRoot(): HTMLElement | null {
|
|
|
88
97
|
return document.querySelector<HTMLElement>('[data-slot="conversation"] > div[data-phase]')
|
|
89
98
|
}
|
|
90
99
|
|
|
100
|
+
/** Structural equality of two dot lists (member keys fully capture a dot's
|
|
101
|
+
* folded questions, so identical key sequences mean identical content).
|
|
102
|
+
* Lets the strip skip a re-render when a refresh produced no change. */
|
|
103
|
+
function sameDots(a: readonly TurnDot[], b: readonly TurnDot[]): boolean {
|
|
104
|
+
if (a.length !== b.length) return false
|
|
105
|
+
for (let i = 0; i < a.length; i++) {
|
|
106
|
+
const da = a[i]
|
|
107
|
+
const db = b[i]
|
|
108
|
+
if (da.key !== db.key || da.memberKeys.length !== db.memberKeys.length) return false
|
|
109
|
+
for (let j = 0; j < da.memberKeys.length; j++) {
|
|
110
|
+
if (da.memberKeys[j] !== db.memberKeys[j]) return false
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
|
|
91
116
|
export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | null {
|
|
92
117
|
const current = props.useSessions((s) => s.current)
|
|
93
118
|
const summary = props.useSessions((s) => (s.current === undefined ? undefined : s.byId[s.current]))
|
|
@@ -97,8 +122,15 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
97
122
|
const [jumpingKey, setJumpingKey] = useState<string | null>(null)
|
|
98
123
|
const [hint, setHint] = useState<string | null>(null)
|
|
99
124
|
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
|
|
125
|
+
const [align, setAlign] = useState<AlignPreference>(() => props.align())
|
|
100
126
|
const panelRef = useRef<HTMLDivElement | null>(null)
|
|
101
127
|
const hintTimerRef = useRef<number | null>(null)
|
|
128
|
+
// Last rendered dot list, for the change-detection bail-out below.
|
|
129
|
+
const lastDotsRef = useRef<TurnDot[]>([])
|
|
130
|
+
|
|
131
|
+
// Follow the rail anchor edge from the settings scope (a change re-anchors
|
|
132
|
+
// the rail through the layout effect below).
|
|
133
|
+
useEffect(() => props.subscribeAlign(() => setAlign(props.align())), [props])
|
|
102
134
|
|
|
103
135
|
const showHint = (message: string): void => {
|
|
104
136
|
setHint(message)
|
|
@@ -106,27 +138,38 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
106
138
|
hintTimerRef.current = window.setTimeout(() => setHint(null), 1800)
|
|
107
139
|
}
|
|
108
140
|
|
|
109
|
-
// Recompute the dot list from the projection + live window
|
|
110
|
-
// the projection push frames
|
|
141
|
+
// Recompute the dot list from the projection + live window. Subscribed to
|
|
142
|
+
// the projection push frames and the session's content only — session
|
|
143
|
+
// switches are covered by `current` below (the effect re-runs on change),
|
|
144
|
+
// so the session-list feed is not subscribed: it would re-run the full
|
|
145
|
+
// recompute for unrelated list churn.
|
|
111
146
|
useEffect(() => {
|
|
112
147
|
if (!visible || current === undefined) {
|
|
113
148
|
setDots([])
|
|
149
|
+
lastDotsRef.current = []
|
|
114
150
|
return
|
|
115
151
|
}
|
|
116
152
|
const sessionId = current
|
|
117
153
|
const face = props.questionProjection(sessionId)
|
|
118
154
|
const refresh = (): void => {
|
|
119
155
|
const grouped = groupQuestionsByTurn(projectionEntries(face))
|
|
120
|
-
|
|
156
|
+
const next = mergeLiveQuestions(grouped, props.readQuestions(sessionId))
|
|
157
|
+
// Identical content (a streaming update that added no question): re-use
|
|
158
|
+
// the previous array reference so React bails out of re-rendering the
|
|
159
|
+
// strip — the common case during assistant streaming.
|
|
160
|
+
if (sameDots(next, lastDotsRef.current)) {
|
|
161
|
+
setDots(lastDotsRef.current)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
lastDotsRef.current = next
|
|
165
|
+
setDots(next)
|
|
121
166
|
}
|
|
122
167
|
refresh()
|
|
123
168
|
const unsubProjection = face?.subscribe(refresh) ?? (() => {})
|
|
124
169
|
const unsubContent = props.subscribeContent(sessionId, refresh)
|
|
125
|
-
const unsubList = props.subscribeList(refresh)
|
|
126
170
|
return () => {
|
|
127
171
|
unsubProjection()
|
|
128
172
|
unsubContent()
|
|
129
|
-
unsubList()
|
|
130
173
|
}
|
|
131
174
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
132
175
|
}, [visible, current, props])
|
|
@@ -143,42 +186,108 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
143
186
|
|
|
144
187
|
// Anchor the minimap to the conversation column: position it at the left
|
|
145
188
|
// edge of the conversation root and reserve a thin rail with padding-left.
|
|
189
|
+
//
|
|
190
|
+
// A layout-correction loop keeps the rail pinned to the conversation even
|
|
191
|
+
// when an outside panel (e.g. a browser-extension sidebar like Doubao) moves
|
|
192
|
+
// or resizes the frame without resizing the browser window: `window resize`
|
|
193
|
+
// never fires for in-page panels, and a ResizeObserver on the conversation
|
|
194
|
+
// root alone misses remounts and the tail of the frame's grid-column
|
|
195
|
+
// transition, which used to leave the rail drifting into the session list.
|
|
196
|
+
// The loop runs while the layout is still moving, then parks itself after a
|
|
197
|
+
// few stable frames; observers re-wake it on the next change.
|
|
146
198
|
useLayoutEffect(() => {
|
|
147
199
|
if (!visible) return
|
|
148
200
|
let raf = 0
|
|
149
|
-
let
|
|
150
|
-
|
|
201
|
+
let idle = 0
|
|
202
|
+
let stopped = false
|
|
203
|
+
let observer: ResizeObserver | null = null
|
|
204
|
+
const observed = { frame: null as Element | null, convRoot: null as Element | null }
|
|
205
|
+
|
|
206
|
+
const applyLayout = (): boolean => {
|
|
151
207
|
const panel = panelRef.current
|
|
152
|
-
if (panel === null) return
|
|
208
|
+
if (panel === null) return false
|
|
153
209
|
const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null
|
|
154
210
|
const convRoot = findConvRoot()
|
|
155
|
-
|
|
211
|
+
// Keep looping while the anchors are not both present (conversation not
|
|
212
|
+
// mounted yet / mid-reflow), so a late mount still aligns.
|
|
213
|
+
if (frame === null || convRoot === null) return true
|
|
214
|
+
// Keep observing the live nodes: the conversation root may remount
|
|
215
|
+
// (e.g. after a panel-triggered reflow), which silently detaches an
|
|
216
|
+
// earlier ResizeObserver target.
|
|
217
|
+
if (observer !== null) {
|
|
218
|
+
if (observed.frame !== frame) {
|
|
219
|
+
observer.observe(frame, { box: 'border-box' })
|
|
220
|
+
observed.frame = frame
|
|
221
|
+
}
|
|
222
|
+
if (observed.convRoot !== convRoot) {
|
|
223
|
+
observer.observe(convRoot, { box: 'border-box' })
|
|
224
|
+
observed.convRoot = convRoot
|
|
225
|
+
}
|
|
226
|
+
}
|
|
156
227
|
const frameRect = frame.getBoundingClientRect()
|
|
157
228
|
const convRect = convRoot.getBoundingClientRect()
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
229
|
+
// Never snap onto a transient box: keep correcting until the
|
|
230
|
+
// conversation has a real footprint again.
|
|
231
|
+
if (convRect.height <= 0 || convRect.width <= 0) return true
|
|
232
|
+
const top = `${convRect.top - frameRect.top}px`
|
|
233
|
+
const height = `${convRect.height}px`
|
|
234
|
+
// Anchor to the configured edge: keep the other edge's inline style
|
|
235
|
+
// cleared so the CSS class (left: auto on .railRight) owns it.
|
|
236
|
+
if (align === 'right') {
|
|
237
|
+
const right = `${frameRect.right - convRect.right}px`
|
|
238
|
+
if (panel.style.top === top && panel.style.height === height
|
|
239
|
+
&& panel.style.right === right && panel.style.left === '') {
|
|
240
|
+
return false
|
|
162
241
|
}
|
|
163
|
-
|
|
242
|
+
panel.style.top = top
|
|
243
|
+
panel.style.height = height
|
|
244
|
+
panel.style.right = right
|
|
245
|
+
panel.style.left = ''
|
|
246
|
+
return true
|
|
247
|
+
}
|
|
248
|
+
const left = `${convRect.left - frameRect.left}px`
|
|
249
|
+
if (panel.style.top === top && panel.style.height === height
|
|
250
|
+
&& panel.style.left === left && panel.style.right === '') {
|
|
251
|
+
return false
|
|
164
252
|
}
|
|
165
|
-
|
|
166
|
-
panel.style.
|
|
167
|
-
panel.style.
|
|
168
|
-
panel.style.
|
|
253
|
+
panel.style.top = top
|
|
254
|
+
panel.style.height = height
|
|
255
|
+
panel.style.left = left
|
|
256
|
+
panel.style.right = ''
|
|
257
|
+
return true
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const loop = (): void => {
|
|
261
|
+
if (stopped) return
|
|
262
|
+
raf = 0
|
|
263
|
+
idle = applyLayout() ? 0 : idle + 1
|
|
264
|
+
if (idle < 3) raf = requestAnimationFrame(loop)
|
|
265
|
+
}
|
|
266
|
+
const wake = (): void => {
|
|
267
|
+
if (stopped) return
|
|
268
|
+
idle = 0
|
|
269
|
+
if (raf === 0) raf = requestAnimationFrame(loop)
|
|
169
270
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
271
|
+
|
|
272
|
+
observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(wake)
|
|
273
|
+
if (observer !== null) {
|
|
274
|
+
const panel = panelRef.current
|
|
275
|
+
const frame = panel?.closest('[data-shell-overlay]')?.parentElement ?? null
|
|
276
|
+
const convRoot = findConvRoot()
|
|
277
|
+
if (frame !== null) { observer.observe(frame, { box: 'border-box' }); observed.frame = frame }
|
|
278
|
+
if (convRoot !== null) { observer.observe(convRoot, { box: 'border-box' }); observed.convRoot = convRoot }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Initial alignment; the loop keeps correcting through layout transitions.
|
|
282
|
+
wake()
|
|
283
|
+
window.addEventListener('resize', wake)
|
|
176
284
|
return () => {
|
|
285
|
+
stopped = true
|
|
177
286
|
if (raf !== 0) cancelAnimationFrame(raf)
|
|
178
287
|
observer?.disconnect()
|
|
179
|
-
window.removeEventListener('resize',
|
|
288
|
+
window.removeEventListener('resize', wake)
|
|
180
289
|
}
|
|
181
|
-
}, [visible])
|
|
290
|
+
}, [visible, align])
|
|
182
291
|
|
|
183
292
|
// Clear any pending hint timer on unmount.
|
|
184
293
|
useEffect(() => () => {
|
|
@@ -199,7 +308,11 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
199
308
|
setTooltip({
|
|
200
309
|
title: dot.turn === null ? null : `Turn ${dot.turn}`,
|
|
201
310
|
lines: dot.texts,
|
|
202
|
-
|
|
311
|
+
// The tooltip opens away from the rail: to the right of the dot on a
|
|
312
|
+
// left-anchored rail, to the left on a right-anchored one.
|
|
313
|
+
...(align === 'right'
|
|
314
|
+
? { right: window.innerWidth - r.left + 10 }
|
|
315
|
+
: { left: r.right + 10 }),
|
|
203
316
|
top: r.top,
|
|
204
317
|
})
|
|
205
318
|
}
|
|
@@ -207,7 +320,11 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
207
320
|
const t = props.t
|
|
208
321
|
|
|
209
322
|
return (
|
|
210
|
-
<div
|
|
323
|
+
<div
|
|
324
|
+
ref={panelRef}
|
|
325
|
+
className={align === 'right' ? `${styles.rail} ${styles.railRight}` : styles.rail}
|
|
326
|
+
data-question-nav="rail"
|
|
327
|
+
>
|
|
211
328
|
{hint !== null ? <div className={styles.hint} role="status">{hint}</div> : null}
|
|
212
329
|
<div className={styles.list}>
|
|
213
330
|
{dots.length === 0 ? (
|
|
@@ -230,7 +347,7 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
230
347
|
</div>
|
|
231
348
|
{tooltip !== null
|
|
232
349
|
? createPortal(
|
|
233
|
-
<div className={styles.tooltip} style={{ left: tooltip.left, top: tooltip.top }}>
|
|
350
|
+
<div className={styles.tooltip} style={{ left: tooltip.left, right: tooltip.right, top: tooltip.top }}>
|
|
234
351
|
{tooltip.title !== null ? <div className={styles.tooltipTitle}>{tooltip.title}</div> : null}
|
|
235
352
|
{tooltip.lines.map((line, index) => (
|
|
236
353
|
<div key={index} className={styles.tooltipLine}>{line}</div>
|
package/src/client/index.ts
CHANGED
|
@@ -23,7 +23,13 @@ import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
|
|
23
23
|
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
|
24
24
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
|
25
25
|
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
26
|
+
// Type-only: pulls the settings shell's SlotMap merge ('settings.plugins.tab')
|
|
27
|
+
// and the ctx.settingsScope Context merge.
|
|
28
|
+
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
29
|
+
import type { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
26
30
|
import { QuestionNavStrip, type ObservableFace, type QuestionNavInjected } from './QuestionNavStrip.tsx'
|
|
31
|
+
import { QuestionNavSettingsTab } from './QuestionNavSettingsTab.tsx'
|
|
32
|
+
import { QuestionNavSettingsController } from './settings.ts'
|
|
27
33
|
import { en, zh, type QuestionNavKey } from './locales.ts'
|
|
28
34
|
import { extractQuestions } from '../core/nodes.ts'
|
|
29
35
|
import { jumpToQuestion, type JumpFailureCode, type JumpPorts } from '../core/jump.ts'
|
|
@@ -103,7 +109,7 @@ function questionProjectionOf(ctx: ClientContext, sessionId: SessionId): Observa
|
|
|
103
109
|
}
|
|
104
110
|
}
|
|
105
111
|
|
|
106
|
-
function createInject(ctx: ClientContext): QuestionNavInjected {
|
|
112
|
+
function createInject(ctx: ClientContext, settings: QuestionNavSettingsController): QuestionNavInjected {
|
|
107
113
|
return {
|
|
108
114
|
readQuestions: (sessionId) => {
|
|
109
115
|
const snap = ctx.sessions.binding(sessionId)?.session.getSnapshot()
|
|
@@ -126,6 +132,9 @@ function createInject(ctx: ClientContext): QuestionNavInjected {
|
|
|
126
132
|
}
|
|
127
133
|
void jumpToQuestion(ports, key)
|
|
128
134
|
},
|
|
135
|
+
align: () => settings.getSnapshot().align,
|
|
136
|
+
subscribeAlign: (cb) => settings.subscribe(cb),
|
|
137
|
+
setAlign: (align) => settings.setAlign(align),
|
|
129
138
|
}
|
|
130
139
|
}
|
|
131
140
|
|
|
@@ -138,8 +147,10 @@ export function apply(ctx: ClientContext): void {
|
|
|
138
147
|
ctx.effect(() => releaseApply, 'question-nav: apply claim')
|
|
139
148
|
|
|
140
149
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'question-nav: dictionaries')
|
|
150
|
+
const t = ctx.locale.bind(NS)
|
|
141
151
|
|
|
142
|
-
const
|
|
152
|
+
const settings = new QuestionNavSettingsController()
|
|
153
|
+
const injected = createInject(ctx, settings)
|
|
143
154
|
|
|
144
155
|
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
|
145
156
|
name: 'shell.overlay',
|
|
@@ -148,4 +159,19 @@ export function apply(ctx: ClientContext): void {
|
|
|
148
159
|
locale: NS,
|
|
149
160
|
inject: () => injected,
|
|
150
161
|
}, QuestionNavStrip))
|
|
162
|
+
|
|
163
|
+
// The settings surface is optional and may apply after this plugin, so the
|
|
164
|
+
// namespace binds and the settings page mounts in a fiber that waits for
|
|
165
|
+
// it. Without it the strip simply keeps the default left alignment.
|
|
166
|
+
ctx.inject(['settingsScope'], (scopeCtx) => {
|
|
167
|
+
settings.attach(scopeCtx.get('settingsScope') as SettingsScopeBinder)
|
|
168
|
+
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
|
169
|
+
name: 'settings.plugins.tab',
|
|
170
|
+
id: 'question-nav',
|
|
171
|
+
order: 100,
|
|
172
|
+
label: () => t('settings.tab'),
|
|
173
|
+
locale: NS,
|
|
174
|
+
inject: () => injected,
|
|
175
|
+
}, QuestionNavSettingsTab))
|
|
176
|
+
})
|
|
151
177
|
}
|
package/src/client/locales.ts
CHANGED
|
@@ -8,6 +8,11 @@ export const zh = {
|
|
|
8
8
|
'jump.hidden': '目标无独立气泡,已定位到邻近内容',
|
|
9
9
|
'jump.notfound': '目标未加载或不存在(可能已压缩)',
|
|
10
10
|
'jump.timeout': '加载历史超时,可重试',
|
|
11
|
+
'settings.tab': '提问导航',
|
|
12
|
+
'settings.align.title': '导航条对齐',
|
|
13
|
+
'settings.align.desc': '选择圆点导航条锚定在对话栏的哪一侧。',
|
|
14
|
+
'settings.align.left': '左侧',
|
|
15
|
+
'settings.align.right': '右侧',
|
|
11
16
|
} as const
|
|
12
17
|
|
|
13
18
|
export const en = {
|
|
@@ -16,6 +21,11 @@ export const en = {
|
|
|
16
21
|
'jump.hidden': 'No dedicated bubble; landed on nearby content',
|
|
17
22
|
'jump.notfound': 'Target not loaded or missing (maybe compacted)',
|
|
18
23
|
'jump.timeout': 'Timed out loading history; retry',
|
|
24
|
+
'settings.tab': 'Question Nav',
|
|
25
|
+
'settings.align.title': 'Rail alignment',
|
|
26
|
+
'settings.align.desc': 'Choose which edge of the conversation column the dot rail anchors to.',
|
|
27
|
+
'settings.align.left': 'Left',
|
|
28
|
+
'settings.align.right': 'Right',
|
|
19
29
|
} as const
|
|
20
30
|
|
|
21
31
|
export type QuestionNavKey = keyof typeof zh
|