@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
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
pointer-events: none;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/* Right-anchored rail: release the CSS `left: 0` so the inline `right`
|
|
19
|
+
(written by the layout-correction loop) owns the position. */
|
|
20
|
+
.railRight {
|
|
21
|
+
left: auto;
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
/* Vertically center the dot column when it is short; scroll from the top when
|
|
19
25
|
it is tall (auto margins collapse to 0 on overflow, so nothing clips). */
|
|
20
26
|
.list {
|
|
@@ -118,3 +124,52 @@
|
|
|
118
124
|
padding-top: 6px;
|
|
119
125
|
border-top: 1px solid var(--dsw-alias-border-l1);
|
|
120
126
|
}
|
|
127
|
+
|
|
128
|
+
/* Settings page (settings.plugins.tab). */
|
|
129
|
+
.settings {
|
|
130
|
+
display: flex;
|
|
131
|
+
flex-direction: column;
|
|
132
|
+
gap: 8px;
|
|
133
|
+
padding: 4px 0;
|
|
134
|
+
}
|
|
135
|
+
.settingsTitle {
|
|
136
|
+
font-size: 13px;
|
|
137
|
+
font-weight: 600;
|
|
138
|
+
color: var(--dsw-alias-label-primary);
|
|
139
|
+
margin: 0;
|
|
140
|
+
}
|
|
141
|
+
.settingsDesc {
|
|
142
|
+
font-size: 12px;
|
|
143
|
+
line-height: 18px;
|
|
144
|
+
color: var(--dsw-alias-label-secondary);
|
|
145
|
+
margin: 0;
|
|
146
|
+
}
|
|
147
|
+
.segmented {
|
|
148
|
+
display: inline-flex;
|
|
149
|
+
border: 1px solid var(--dsw-alias-border-l1);
|
|
150
|
+
border-radius: 8px;
|
|
151
|
+
overflow: hidden;
|
|
152
|
+
align-self: flex-start;
|
|
153
|
+
}
|
|
154
|
+
.segment {
|
|
155
|
+
border: none;
|
|
156
|
+
background: transparent;
|
|
157
|
+
color: var(--dsw-alias-label-secondary);
|
|
158
|
+
font-size: 13px;
|
|
159
|
+
line-height: 1;
|
|
160
|
+
padding: 8px 16px;
|
|
161
|
+
cursor: pointer;
|
|
162
|
+
transition: background 120ms ease, color 120ms ease;
|
|
163
|
+
}
|
|
164
|
+
.segment + .segment {
|
|
165
|
+
border-left: 1px solid var(--dsw-alias-border-l1);
|
|
166
|
+
}
|
|
167
|
+
.segment:hover {
|
|
168
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
169
|
+
color: var(--dsw-alias-label-primary);
|
|
170
|
+
}
|
|
171
|
+
.segmentActive,
|
|
172
|
+
.segmentActive:hover {
|
|
173
|
+
background: var(--dsw-alias-brand-primary);
|
|
174
|
+
color: var(--dsw-alias-label-inverse);
|
|
175
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
|
|
15
|
+
import type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
|
|
16
|
+
import {
|
|
17
|
+
ALIGN_FIELD, ALIGN_OPTIONS, DEFAULT_ALIGN, QUESTION_NAV_SETTINGS_NS,
|
|
18
|
+
type AlignPreference,
|
|
19
|
+
} from '../core/align.ts'
|
|
20
|
+
|
|
21
|
+
/** Narrow a raw section to the anchor field. */
|
|
22
|
+
function isAlignPreference(value: unknown): value is AlignPreference {
|
|
23
|
+
return ALIGN_OPTIONS.some((option) => option === value)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The minimal face of the settings scope service this controller needs.
|
|
27
|
+
* Kept structural (bind only) so the controller stays decoupled from the
|
|
28
|
+
* full service and is unit-testable with a stub binder. */
|
|
29
|
+
export interface SettingsScopeBinderLike {
|
|
30
|
+
bind<T>(spec: SettingsScopeSpec<T>): SettingsScope<T>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Wire section this plugin owns (the Host schema's `align` field). */
|
|
34
|
+
interface QuestionNavSection {
|
|
35
|
+
align?: unknown
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Snapshot consumed by the strip and the settings row. */
|
|
39
|
+
export interface QuestionNavSettingsState {
|
|
40
|
+
/** Last accepted anchor edge (default while the scope is absent/loading). */
|
|
41
|
+
align: AlignPreference
|
|
42
|
+
/** Whether the user layer overrides the composition default. */
|
|
43
|
+
overridden: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Reactive handle over the plugin's durable settings section. */
|
|
47
|
+
export class QuestionNavSettingsController {
|
|
48
|
+
private scope: SettingsScope<QuestionNavSection> | undefined
|
|
49
|
+
private readonly listeners = new Set<() => void>()
|
|
50
|
+
private unsubscribe: () => void = () => {}
|
|
51
|
+
private state: QuestionNavSettingsState = { align: DEFAULT_ALIGN, overridden: false }
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Bind the namespace scope once the settings surface is present. Called
|
|
55
|
+
* from a fiber that injects `settingsScope`, so the scope subscription
|
|
56
|
+
* lives on that fiber and is released with it. A no-op after the first
|
|
57
|
+
* bind.
|
|
58
|
+
* @param binder - the settings scope service.
|
|
59
|
+
*/
|
|
60
|
+
attach(binder: SettingsScopeBinderLike): void {
|
|
61
|
+
if (this.scope !== undefined) return
|
|
62
|
+
this.scope = binder.bind<QuestionNavSection>({ namespace: QUESTION_NAV_SETTINGS_NS })
|
|
63
|
+
this.state = this.derive(this.scope.getSnapshot())
|
|
64
|
+
this.unsubscribe = this.scope.subscribe(() => {
|
|
65
|
+
if (this.scope === undefined) return
|
|
66
|
+
const next = this.derive(this.scope.getSnapshot())
|
|
67
|
+
if (next.align === this.state.align && next.overridden === this.state.overridden) return
|
|
68
|
+
this.state = next
|
|
69
|
+
for (const listener of this.listeners) listener()
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private derive(snapshot: SettingsScopeSnapshot<QuestionNavSection>): QuestionNavSettingsState {
|
|
74
|
+
const user = snapshot.user as { align?: unknown } | undefined
|
|
75
|
+
return {
|
|
76
|
+
align: snapshot.status === 'ready' && isAlignPreference(snapshot.value?.align)
|
|
77
|
+
? snapshot.value.align
|
|
78
|
+
: DEFAULT_ALIGN,
|
|
79
|
+
overridden: user !== undefined && user.align !== undefined,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Release the scope subscription (bound on the settings fiber's lifecycle). */
|
|
84
|
+
dispose(): void {
|
|
85
|
+
this.unsubscribe()
|
|
86
|
+
this.listeners.clear()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @returns the current state (stable reference until the next change). */
|
|
90
|
+
getSnapshot(): QuestionNavSettingsState {
|
|
91
|
+
return this.state
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Observe state replacements; returns the disposer. */
|
|
95
|
+
subscribe(listener: () => void): () => void {
|
|
96
|
+
this.listeners.add(listener)
|
|
97
|
+
return () => { this.listeners.delete(listener) }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Route the user's anchor-edge choice to the Host document. */
|
|
101
|
+
setAlign(align: AlignPreference): void {
|
|
102
|
+
if (this.scope === undefined) return
|
|
103
|
+
void this.scope.set(ALIGN_FIELD, align)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
|
|
9
|
+
/** Supported rail anchor edges. */
|
|
10
|
+
export const ALIGN_OPTIONS = ['left', 'right'] as const
|
|
11
|
+
|
|
12
|
+
/** Rail anchor edge preference. */
|
|
13
|
+
export type AlignPreference = typeof ALIGN_OPTIONS[number]
|
|
14
|
+
|
|
15
|
+
/** Default anchor edge when the user-settings document has no override. */
|
|
16
|
+
export const DEFAULT_ALIGN: AlignPreference = 'left'
|
|
17
|
+
|
|
18
|
+
/** Settings namespace owned by this plugin (spelled here rather than
|
|
19
|
+
* imported: the client bundle must not depend on a Host package). */
|
|
20
|
+
export const QUESTION_NAV_SETTINGS_NS = 'question-nav'
|
|
21
|
+
|
|
22
|
+
/** Field carrying the selected anchor edge. */
|
|
23
|
+
export const ALIGN_FIELD = 'align'
|
package/src/core/turn-dots.ts
CHANGED
|
@@ -40,13 +40,24 @@ export interface TurnDot {
|
|
|
40
40
|
readonly memberKeys: readonly string[]
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** True when entries are already in non-decreasing seq order (the projection
|
|
44
|
+
* appends in event order, so this is the common case and skips the sort). */
|
|
45
|
+
function isSortedBySeq(entries: readonly QuestionEntry[]): boolean {
|
|
46
|
+
for (let i = 1; i < entries.length; i++) {
|
|
47
|
+
if (entries[i].seq < entries[i - 1].seq) return false
|
|
48
|
+
}
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
|
|
43
52
|
/**
|
|
44
53
|
* Fold the projection's question list into one dot per turn. Entries arrive
|
|
45
54
|
* in event order; consecutive same-turn entries merge into a single dot whose
|
|
46
|
-
* anchor is the turn's first question.
|
|
55
|
+
* anchor is the turn's first question. The input is expected to be sorted by
|
|
56
|
+
* seq; the defensive sort is skipped when it already is, so a long session
|
|
57
|
+
* never pays an O(n log n) sort on every content update.
|
|
47
58
|
*/
|
|
48
59
|
export function groupQuestionsByTurn(entries: readonly QuestionEntry[]): TurnDot[] {
|
|
49
|
-
const sorted = [...entries].sort((a, b) => a.seq - b.seq)
|
|
60
|
+
const sorted = isSortedBySeq(entries) ? entries : [...entries].sort((a, b) => a.seq - b.seq)
|
|
50
61
|
const dots: TurnDot[] = []
|
|
51
62
|
for (const entry of sorted) {
|
|
52
63
|
const key = questionKey(entry.id)
|
|
@@ -77,22 +88,49 @@ export function groupQuestionsByTurn(entries: readonly QuestionEntry[]): TurnDot
|
|
|
77
88
|
* whose key is already folded into a dot is dropped (the projected copy
|
|
78
89
|
* wins); the rest become single-question dots with `turn: null`, inserted in
|
|
79
90
|
* anchor-seq order so the strip stays strictly chronological.
|
|
91
|
+
*
|
|
92
|
+
* Fast path: when nothing new arrives the SAME array is returned (no copy),
|
|
93
|
+
* so the caller can bail out of a re-render on identical reference.
|
|
80
94
|
*/
|
|
81
95
|
export function mergeLiveQuestions(
|
|
82
96
|
dots: readonly TurnDot[],
|
|
83
97
|
live: readonly QuestionNode[],
|
|
84
98
|
): TurnDot[] {
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
99
|
+
if (live.length === 0) return dots as TurnDot[]
|
|
100
|
+
const known = new Set<string>()
|
|
101
|
+
for (const dot of dots) {
|
|
102
|
+
for (const key of dot.memberKeys) known.add(key)
|
|
103
|
+
}
|
|
104
|
+
const extras: TurnDot[] = []
|
|
105
|
+
for (const question of live) {
|
|
106
|
+
if (known.has(question.key)) continue
|
|
107
|
+
extras.push({
|
|
89
108
|
turn: null,
|
|
90
109
|
key: question.key,
|
|
91
110
|
anchorSeq: question.anchorSeq,
|
|
92
111
|
time: question.time,
|
|
93
112
|
texts: [question.text],
|
|
94
113
|
memberKeys: [question.key],
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
// Nothing new from the live window — reuse the input array unchanged.
|
|
117
|
+
if (extras.length === 0) return dots as TurnDot[]
|
|
118
|
+
// Both `dots` and `extras` are sorted by anchorSeq (dots from the projection
|
|
119
|
+
// order, extras from the live window order): merge linearly instead of
|
|
120
|
+
// re-sorting the whole list. Ties keep the projected dot first (stable
|
|
121
|
+
// sort semantics), matching the previous [...dots, ...extras].sort().
|
|
122
|
+
const out: TurnDot[] = []
|
|
123
|
+
let i = 0
|
|
124
|
+
for (const extra of extras) {
|
|
125
|
+
while (i < dots.length && dots[i].anchorSeq <= extra.anchorSeq) {
|
|
126
|
+
out.push(dots[i])
|
|
127
|
+
i += 1
|
|
128
|
+
}
|
|
129
|
+
out.push(extra)
|
|
130
|
+
}
|
|
131
|
+
while (i < dots.length) {
|
|
132
|
+
out.push(dots[i])
|
|
133
|
+
i += 1
|
|
134
|
+
}
|
|
135
|
+
return out
|
|
98
136
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,19 +9,24 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { Context } from '@deepseek-ai/cordis'
|
|
11
11
|
import { questionIndexProjectionDefinition } from './projection.ts'
|
|
12
|
+
import { questionNavSettingsNamespace, QuestionNavSettingsSchema } from './settings.ts'
|
|
12
13
|
|
|
13
14
|
/** Cordis plugin name. */
|
|
14
15
|
export const name = 'dsh-question-nav'
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
|
-
* Register the `questionIndex` unit
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* falls back to
|
|
18
|
+
* Register the `questionIndex` unit and the plugin's durable settings
|
|
19
|
+
* namespace. Both registries are optional capabilities (absent in headless
|
|
20
|
+
* compositions), so each registration rides `ctx.inject`: without them the
|
|
21
|
+
* host half simply contributes nothing and the browser strip falls back to
|
|
22
|
+
* live-window questions and the default rail alignment.
|
|
21
23
|
* @param ctx - plugin context.
|
|
22
24
|
*/
|
|
23
25
|
export function apply(ctx: Context): void {
|
|
24
26
|
ctx.inject(['sessionProjections'], (inner) => {
|
|
25
27
|
inner.sessionProjections.register(questionIndexProjectionDefinition)
|
|
26
28
|
})
|
|
29
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
30
|
+
settingsCtx.settings.register(questionNavSettingsNamespace, QuestionNavSettingsSchema)
|
|
31
|
+
})
|
|
27
32
|
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
|
|
11
|
+
import z from '@deepseek-ai/schemastery'
|
|
12
|
+
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
13
|
+
import {
|
|
14
|
+
ALIGN_FIELD, ALIGN_OPTIONS, DEFAULT_ALIGN, QUESTION_NAV_SETTINGS_NS,
|
|
15
|
+
type AlignPreference,
|
|
16
|
+
} from './core/align.ts'
|
|
17
|
+
|
|
18
|
+
/** Durable settings section shared by the Host schema and the browser scope. */
|
|
19
|
+
export interface QuestionNavSettings {
|
|
20
|
+
/** Anchor edge of the rail. */
|
|
21
|
+
align: AlignPreference
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Durable settings schema; also the wire envelope the browser scope validates against. */
|
|
25
|
+
export const QuestionNavSettingsSchema: z<QuestionNavSettings> = z.object({
|
|
26
|
+
[ALIGN_FIELD]: z.union([...ALIGN_OPTIONS]).default(DEFAULT_ALIGN),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/** The settings namespace this plugin owns, branded for the Host registry.
|
|
30
|
+
* `question-nav` matches the registry pattern (`^[a-z][a-z0-9-]*$`), so the
|
|
31
|
+
* constant needs no runtime validator — keeping this module type-only on the
|
|
32
|
+
* settings package avoids inlining it (and cosmokit) into the host bundle. */
|
|
33
|
+
export const questionNavSettingsNamespace = QUESTION_NAV_SETTINGS_NS as SettingsNamespace
|