@luziyang2026/dsh-question-nav 0.7.3 → 0.7.5
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 +12 -5
- package/README.zh.md +10 -3
- package/lib/client.js +130 -27
- package/lib/client.js.map +1 -1
- package/lib/index.js +30 -5
- package/lib/types/client/QuestionNavSettingsTab.d.ts +4 -3
- package/lib/types/client/QuestionNavStrip.d.ts +7 -2
- package/lib/types/client/locales.d.ts +8 -4
- package/lib/types/client/settings.d.ts +13 -7
- package/lib/types/core/page-size.d.ts +16 -0
- package/lib/types/settings.d.ts +8 -4
- package/package.json +1 -1
- package/src/client/QuestionNavSettingsTab.tsx +24 -5
- package/src/client/QuestionNavStrip.tsx +84 -20
- package/src/client/index.ts +3 -1
- package/src/client/locales.ts +8 -4
- package/src/client/question-nav.module.css +90 -19
- package/src/client/settings.ts +36 -13
- package/src/core/page-size.ts +20 -0
- package/src/settings.ts +12 -4
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-size constants for the ▲/▼ paging buttons, shared by the host schema
|
|
3
|
+
* and the browser settings scope. Pure data: no DSH imports, so the client
|
|
4
|
+
* bundle may inline this module (a Host import here would leak into the
|
|
5
|
+
* browser half).
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-question-nav/page-size
|
|
8
|
+
*/
|
|
9
|
+
/** Selectable page sizes: how many hidden dots one ▲/▼ click reveals. */
|
|
10
|
+
export declare const PAGE_SIZE_OPTIONS: readonly [3, 5, 8, 10];
|
|
11
|
+
/** Page-size preference (dots revealed per paging-button click). */
|
|
12
|
+
export type PageSize = typeof PAGE_SIZE_OPTIONS[number];
|
|
13
|
+
/** Default page size when the user-settings document has no override. */
|
|
14
|
+
export declare const DEFAULT_PAGE_SIZE: PageSize;
|
|
15
|
+
/** Field carrying the selected page size. */
|
|
16
|
+
export declare const PAGE_SIZE_FIELD = "pageSize";
|
package/lib/types/settings.d.ts
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Host-side durable settings for the question-nav plugin, registered into the
|
|
3
|
-
* DSH user-settings document.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* DSH user-settings document. Two fields: which edge of the conversation
|
|
4
|
+
* column the rail anchors to (`align`) and how many hidden dots one ▲/▼
|
|
5
|
+
* paging-button click reveals (`pageSize`). The browser half reads the same
|
|
6
|
+
* namespace through the settings scope (`ctx.settingsScope.bind`) and routes
|
|
7
|
+
* the user's choice back through `scope.set`.
|
|
7
8
|
*
|
|
8
9
|
* @module dsh-question-nav/settings
|
|
9
10
|
*/
|
|
10
11
|
import z from '@deepseek-ai/schemastery';
|
|
11
12
|
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
12
13
|
import { type AlignPreference } from './core/align.ts';
|
|
14
|
+
import { type PageSize } from './core/page-size.ts';
|
|
13
15
|
/** Durable settings section shared by the Host schema and the browser scope. */
|
|
14
16
|
export interface QuestionNavSettings {
|
|
15
17
|
/** Anchor edge of the rail. */
|
|
16
18
|
align: AlignPreference;
|
|
19
|
+
/** Dots revealed per ▲/▼ paging-button click. */
|
|
20
|
+
pageSize: PageSize;
|
|
17
21
|
}
|
|
18
22
|
/** Durable settings schema; also the wire envelope the browser scope validates against. */
|
|
19
23
|
export declare const QuestionNavSettingsSchema: z<QuestionNavSettings>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luziyang2026/dsh-question-nav",
|
|
3
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.7.
|
|
4
|
+
"version": "0.7.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.7.0",
|
|
7
7
|
"engines": {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The plugin's settings page inside the shell's Plugins section
|
|
3
|
-
* (`settings.plugins.tab`):
|
|
4
|
-
* conversation column the dot rail anchors to
|
|
3
|
+
* (`settings.plugins.tab`): two segmented controls — which edge of the
|
|
4
|
+
* conversation column the dot rail anchors to, and how many hidden dots one
|
|
5
|
+
* ▲/▼ paging-button click reveals. The choices are written to the
|
|
5
6
|
* `question-nav` settings namespace (registered by the host half); the strip
|
|
6
|
-
* re-anchors live when the snapshot changes.
|
|
7
|
+
* re-anchors and re-steps live when the snapshot changes.
|
|
7
8
|
*
|
|
8
9
|
* @module dsh-question-nav/client/settings-tab
|
|
9
10
|
*/
|
|
@@ -13,6 +14,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots
|
|
|
13
14
|
// Type-only: pulls the settings shell's SlotMap merge ('settings.plugins.tab').
|
|
14
15
|
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
15
16
|
import { ALIGN_OPTIONS } from '../core/align.ts'
|
|
17
|
+
import { PAGE_SIZE_OPTIONS } from '../core/page-size.ts'
|
|
16
18
|
import type { QuestionNavInjected } from './QuestionNavStrip.tsx'
|
|
17
19
|
import type { QuestionNavKey } from './locales.ts'
|
|
18
20
|
import styles from './question-nav.module.css'
|
|
@@ -20,14 +22,15 @@ import styles from './question-nav.module.css'
|
|
|
20
22
|
type ComponentProps = PropsRuntime<'settings.plugins.tab'> & QuestionNavInjected & PropsLocale<'question-nav'>
|
|
21
23
|
|
|
22
24
|
/** Re-render on settings snapshot changes (the register inject face is static). */
|
|
23
|
-
function
|
|
25
|
+
function useSettingsTick(subscribe: QuestionNavInjected['subscribeSettings']): void {
|
|
24
26
|
const [, bump] = useState(0)
|
|
25
27
|
useEffect(() => subscribe(() => bump((n) => n + 1)), [subscribe])
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export function QuestionNavSettingsTab(props: ComponentProps): React.JSX.Element | null {
|
|
29
|
-
|
|
31
|
+
useSettingsTick(props.subscribeSettings)
|
|
30
32
|
const align = props.align()
|
|
33
|
+
const pageSize = props.pageSize()
|
|
31
34
|
const t = props.t
|
|
32
35
|
|
|
33
36
|
return (
|
|
@@ -48,6 +51,22 @@ export function QuestionNavSettingsTab(props: ComponentProps): React.JSX.Element
|
|
|
48
51
|
</button>
|
|
49
52
|
))}
|
|
50
53
|
</div>
|
|
54
|
+
<p className={styles.settingsTitle}>{t('settings.pagesize.title')}</p>
|
|
55
|
+
<p className={styles.settingsDesc}>{t('settings.pagesize.desc')}</p>
|
|
56
|
+
<div className={styles.segmented} role="radiogroup" aria-label={t('settings.pagesize.title')}>
|
|
57
|
+
{PAGE_SIZE_OPTIONS.map((option) => (
|
|
58
|
+
<button
|
|
59
|
+
key={option}
|
|
60
|
+
type="button"
|
|
61
|
+
role="radio"
|
|
62
|
+
aria-checked={pageSize === option}
|
|
63
|
+
className={pageSize === option ? `${styles.segment} ${styles.segmentActive}` : styles.segment}
|
|
64
|
+
onClick={() => props.setPageSize(option)}
|
|
65
|
+
>
|
|
66
|
+
{option}
|
|
67
|
+
</button>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
51
70
|
</div>
|
|
52
71
|
)
|
|
53
72
|
}
|
|
@@ -16,9 +16,12 @@
|
|
|
16
16
|
* the band — the dot column stays put while you browse.
|
|
17
17
|
*
|
|
18
18
|
* Overflow is paged, not auto-scrolled: two small triangle buttons in the dot
|
|
19
|
-
* style sit above and below the dot queue (▲ / ▼), each click revealing
|
|
20
|
-
* hidden dots
|
|
21
|
-
*
|
|
19
|
+
* style sit above and below the dot queue (▲ / ▼), each click revealing one
|
|
20
|
+
* page of hidden dots (the page size is configurable in the plugin settings,
|
|
21
|
+
* default 5) with a staggered pop-in animation as click feedback. The native
|
|
22
|
+
* scrollbar stays hidden; the triangles themselves are the overflow cue (each
|
|
23
|
+
* appears only while its direction has more to reveal) — no hover auto-scroll
|
|
24
|
+
* of any kind.
|
|
22
25
|
*
|
|
23
26
|
* Data source: the host-folded `questionIndex` session projection (whole
|
|
24
27
|
* history, persisted host-side, pushed live through session/projection
|
|
@@ -41,8 +44,9 @@ import type { QuestionNode } from '../core/nodes.ts'
|
|
|
41
44
|
import type { QuestionEntry } from '../core/question-entry.ts'
|
|
42
45
|
import { groupQuestionsByTurn, mergeLiveQuestions, type TurnDot } from '../core/turn-dots.ts'
|
|
43
46
|
import type { AlignPreference } from '../core/align.ts'
|
|
47
|
+
import type { PageSize } from '../core/page-size.ts'
|
|
44
48
|
import type { JumpFailureCode } from '../core/jump.ts'
|
|
45
|
-
import { FOCUS_RADIUS, clampScrollTop, focusCardMetrics, focusScale, focusTier, pageStep } from '../core/focus.ts'
|
|
49
|
+
import { DOT_GAP, DOT_SIZE, FOCUS_RADIUS, clampScrollTop, focusCardMetrics, focusScale, focusTier, pageStep } from '../core/focus.ts'
|
|
46
50
|
import { formatQuestionTime } from '../core/time.ts'
|
|
47
51
|
import type { QuestionNavKey } from './locales.ts'
|
|
48
52
|
import styles from './question-nav.module.css'
|
|
@@ -69,10 +73,14 @@ export interface QuestionNavInjected {
|
|
|
69
73
|
jump: (sessionId: SessionId, key: string) => void
|
|
70
74
|
/** Current rail anchor edge (defaults to 'left' before the settings section is ready). */
|
|
71
75
|
align: () => AlignPreference
|
|
72
|
-
/** Observe
|
|
73
|
-
|
|
76
|
+
/** Observe plugin-settings changes (align, page size); returns an unsubscribe. */
|
|
77
|
+
subscribeSettings: (cb: () => void) => () => void
|
|
74
78
|
/** Persist a new anchor edge. */
|
|
75
79
|
setAlign: (align: AlignPreference) => void
|
|
80
|
+
/** Current ▲/▼ page size (defaults to DEFAULT_PAGE_SIZE before the settings section is ready). */
|
|
81
|
+
pageSize: () => PageSize
|
|
82
|
+
/** Persist a new page size. */
|
|
83
|
+
setPageSize: (pageSize: PageSize) => void
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
type ComponentProps = PropsRuntime<'shell.overlay'> & QuestionNavInjected & PropsLocale<'question-nav'>
|
|
@@ -146,6 +154,8 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
146
154
|
const [hint, setHint] = useState<string | null>(null)
|
|
147
155
|
const [focus, setFocus] = useState<FocusState | null>(null)
|
|
148
156
|
const [align, setAlign] = useState<AlignPreference>(() => props.align())
|
|
157
|
+
// Dots revealed per ▲/▼ click (settings scope; drives pageBy's step).
|
|
158
|
+
const [pageSize, setPageSize] = useState<PageSize>(() => props.pageSize())
|
|
149
159
|
const panelRef = useRef<HTMLDivElement | null>(null)
|
|
150
160
|
const listRef = useRef<HTMLDivElement | null>(null)
|
|
151
161
|
const hintTimerRef = useRef<number | null>(null)
|
|
@@ -154,12 +164,17 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
154
164
|
const clearFocusTimerRef = useRef<number | null>(null)
|
|
155
165
|
// Last rendered dot list, for the change-detection bail-out below.
|
|
156
166
|
const lastDotsRef = useRef<TurnDot[]>([])
|
|
157
|
-
// Whether the dot band overflows its 60% clamp (drives the
|
|
158
|
-
//
|
|
167
|
+
// Whether the dot band overflows its 60% clamp (drives the ▲/▼ paging
|
|
168
|
+
// buttons).
|
|
159
169
|
const [scrollable, setScrollable] = useState(false)
|
|
160
170
|
// Scroll offset of the band + its max offset: drives the ▲/▼ visibility
|
|
161
171
|
// (each direction hides once there is nothing more to reveal).
|
|
162
172
|
const [scrollPos, setScrollPos] = useState<{ top: number; max: number }>({ top: 0, max: 0 })
|
|
173
|
+
// Dots revealed by the latest ▲/▼ click: key → stagger order + travel
|
|
174
|
+
// direction. Drives a short staggered pop-in animation so the click has a
|
|
175
|
+
// visible effect; cleared by a timer once the animation has played out.
|
|
176
|
+
const [revealed, setRevealed] = useState<ReadonlyMap<string, { dir: 1 | -1; order: number }> | null>(null)
|
|
177
|
+
const revealTimerRef = useRef<number | null>(null)
|
|
163
178
|
|
|
164
179
|
const syncScroll = (): void => {
|
|
165
180
|
const list = listRef.current
|
|
@@ -168,13 +183,45 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
168
183
|
setScrollPos({ top: list.scrollTop, max })
|
|
169
184
|
}
|
|
170
185
|
|
|
171
|
-
// Page the band by one DOT_PAGE_ROWS click in the given direction
|
|
186
|
+
// Page the band by one DOT_PAGE_ROWS click in the given direction, then mark
|
|
187
|
+
// the dots the page just brought into view so they play a staggered pop-in
|
|
188
|
+
// animation in the direction of travel (▼ → rise from below, ▲ → drop from
|
|
189
|
+
// above) — the visual confirmation that the click revealed new dots.
|
|
172
190
|
const pageBy = (dir: 1 | -1): void => {
|
|
173
191
|
const list = listRef.current
|
|
174
192
|
if (list === null) return
|
|
175
193
|
const max = Math.max(0, list.scrollHeight - list.clientHeight)
|
|
176
|
-
|
|
194
|
+
const target = clampScrollTop(list.scrollTop + dir * pageStep(DOT_SIZE, DOT_GAP, pageSize), max)
|
|
195
|
+
if (target === list.scrollTop) return
|
|
196
|
+
// Snapshot which dots are geometrically inside the band before the jump
|
|
197
|
+
// (the container rect does not move when its content scrolls).
|
|
198
|
+
const band = list.getBoundingClientRect()
|
|
199
|
+
const els = Array.from(list.querySelectorAll<HTMLElement>('[data-question-nav-key]'))
|
|
200
|
+
const wasVisible = els.map((el) => {
|
|
201
|
+
const r = el.getBoundingClientRect()
|
|
202
|
+
return r.bottom > band.top && r.top < band.bottom
|
|
203
|
+
})
|
|
204
|
+
list.scrollTop = target
|
|
177
205
|
syncScroll()
|
|
206
|
+
const next = new Map<string, { dir: 1 | -1; order: number }>()
|
|
207
|
+
const fresh: string[] = []
|
|
208
|
+
els.forEach((el, i) => {
|
|
209
|
+
if (wasVisible[i]) return
|
|
210
|
+
const r = el.getBoundingClientRect()
|
|
211
|
+
const key = el.dataset.questionNavKey
|
|
212
|
+
if (key !== undefined && r.bottom > band.top && r.top < band.bottom) {
|
|
213
|
+
fresh.push(key)
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
// Stagger radiates from the clicked triangle: paging down (▼) starts at
|
|
217
|
+
// the bottom of the fresh batch, paging up (▲) at the top.
|
|
218
|
+
if (dir === 1) fresh.reverse()
|
|
219
|
+
fresh.forEach((key, order) => next.set(key, { dir, order }))
|
|
220
|
+
if (next.size === 0) return
|
|
221
|
+
setRevealed(next)
|
|
222
|
+
if (revealTimerRef.current !== null) window.clearTimeout(revealTimerRef.current)
|
|
223
|
+
// Animation (320ms) + per-dot stagger (35ms each) + slack.
|
|
224
|
+
revealTimerRef.current = window.setTimeout(() => setRevealed(null), 320 + next.size * 35 + 120)
|
|
178
225
|
}
|
|
179
226
|
|
|
180
227
|
const canPageUp = scrollable && scrollPos.top > 0
|
|
@@ -194,9 +241,13 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
194
241
|
clearFocusTimerRef.current = window.setTimeout(() => setFocus(null), 240)
|
|
195
242
|
}
|
|
196
243
|
|
|
197
|
-
// Follow the
|
|
198
|
-
// the rail through the layout effect below
|
|
199
|
-
|
|
244
|
+
// Follow the plugin settings from the settings scope (an align change
|
|
245
|
+
// re-anchors the rail through the layout effect below; a page-size change
|
|
246
|
+
// steps the ▲/▼ paging).
|
|
247
|
+
useEffect(() => props.subscribeSettings(() => {
|
|
248
|
+
setAlign(props.align())
|
|
249
|
+
setPageSize(props.pageSize())
|
|
250
|
+
}), [props])
|
|
200
251
|
|
|
201
252
|
const showHint = (message: string): void => {
|
|
202
253
|
setHint(message)
|
|
@@ -355,9 +406,9 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
355
406
|
}
|
|
356
407
|
}, [visible, align])
|
|
357
408
|
|
|
358
|
-
// Detect band overflow: drives the
|
|
359
|
-
//
|
|
360
|
-
//
|
|
409
|
+
// Detect band overflow: drives the ▲/▼ paging buttons. Re-checked when the
|
|
410
|
+
// dots change and whenever the band itself resizes (the layout loop above
|
|
411
|
+
// clamps it to the conversation height).
|
|
361
412
|
useLayoutEffect(() => {
|
|
362
413
|
const list = listRef.current
|
|
363
414
|
if (list === null) return
|
|
@@ -376,6 +427,7 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
376
427
|
useEffect(() => () => {
|
|
377
428
|
if (hintTimerRef.current !== null) window.clearTimeout(hintTimerRef.current)
|
|
378
429
|
if (clearFocusTimerRef.current !== null) window.clearTimeout(clearFocusTimerRef.current)
|
|
430
|
+
if (revealTimerRef.current !== null) window.clearTimeout(revealTimerRef.current)
|
|
379
431
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
380
432
|
}, [])
|
|
381
433
|
|
|
@@ -436,7 +488,7 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
436
488
|
) : null}
|
|
437
489
|
<div
|
|
438
490
|
ref={listRef}
|
|
439
|
-
className={
|
|
491
|
+
className={styles.list}
|
|
440
492
|
onScroll={syncScroll}
|
|
441
493
|
onMouseLeave={scheduleClearFocus}
|
|
442
494
|
>
|
|
@@ -447,16 +499,26 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
447
499
|
// smaller still, the rest base scale.
|
|
448
500
|
const tier = selectedIndex < 0 ? null : focusTier(index - selectedIndex)
|
|
449
501
|
const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? focusScale(tier) : 1
|
|
502
|
+
// Click-paging feedback: dots just paged into view play a
|
|
503
|
+
// staggered pop-in, traveling in the paging direction.
|
|
504
|
+
const reveal = revealed?.get(dot.key)
|
|
450
505
|
const cls = [styles.dot]
|
|
451
506
|
if (isFocused) cls.push(styles.focused)
|
|
452
507
|
if (jumpingKey === dot.key) cls.push(styles.active)
|
|
508
|
+
if (reveal !== undefined) {
|
|
509
|
+
cls.push(reveal.dir === 1 ? styles.dotEnterFromBottom : styles.dotEnterFromTop)
|
|
510
|
+
}
|
|
453
511
|
return (
|
|
454
512
|
<button
|
|
455
513
|
key={dot.key}
|
|
456
514
|
className={cls.join(' ')}
|
|
457
|
-
style={{
|
|
515
|
+
style={{
|
|
516
|
+
transform: `scale(${scale})`,
|
|
517
|
+
...(reveal !== undefined ? { animationDelay: `${reveal.order * 35}ms` } : {}),
|
|
518
|
+
}}
|
|
458
519
|
data-question-nav-focused={isFocused ? 'true' : undefined}
|
|
459
520
|
data-question-nav-index={index}
|
|
521
|
+
data-question-nav-key={dot.key}
|
|
460
522
|
aria-label={dot.texts[0] ?? ''}
|
|
461
523
|
onMouseEnter={(e) => openFocus(dot, e.currentTarget)}
|
|
462
524
|
onClick={() => onJump(dot)}
|
|
@@ -497,7 +559,7 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
497
559
|
Hovering the cascade keeps it alive (the rail only re-arms its
|
|
498
560
|
clear once the mouse leaves both). */
|
|
499
561
|
<div
|
|
500
|
-
className={styles.cascade}
|
|
562
|
+
className={align === 'right' ? `${styles.cascade} ${styles.cascadeRight}` : styles.cascade}
|
|
501
563
|
style={{
|
|
502
564
|
...(focus.left !== undefined ? { left: focus.left } : {}),
|
|
503
565
|
...(focus.right !== undefined ? { right: focus.right } : {}),
|
|
@@ -516,7 +578,9 @@ export function QuestionNavStrip(props: ComponentProps): React.JSX.Element | nul
|
|
|
516
578
|
// question's sent time, and clicking any card jumps to its
|
|
517
579
|
// question exactly like clicking the dot.
|
|
518
580
|
const text = item.dot.texts.join(' · ')
|
|
519
|
-
const cls = isSelected
|
|
581
|
+
const cls = isSelected
|
|
582
|
+
? `${styles.card} ${styles.cardSelected}${align === 'right' ? ` ${styles.cardSelectedRight}` : ''}`
|
|
583
|
+
: styles.card
|
|
520
584
|
return (
|
|
521
585
|
<div
|
|
522
586
|
key={item.dot.key}
|
package/src/client/index.ts
CHANGED
|
@@ -133,8 +133,10 @@ function createInject(ctx: ClientContext, settings: QuestionNavSettingsControlle
|
|
|
133
133
|
void jumpToQuestion(ports, key)
|
|
134
134
|
},
|
|
135
135
|
align: () => settings.getSnapshot().align,
|
|
136
|
-
|
|
136
|
+
subscribeSettings: (cb) => settings.subscribe(cb),
|
|
137
137
|
setAlign: (align) => settings.setAlign(align),
|
|
138
|
+
pageSize: () => settings.getSnapshot().pageSize,
|
|
139
|
+
setPageSize: (pageSize) => settings.setPageSize(pageSize),
|
|
138
140
|
}
|
|
139
141
|
}
|
|
140
142
|
|
package/src/client/locales.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export const zh = {
|
|
6
6
|
'strip.empty': '本会话还没有提问',
|
|
7
|
-
'strip.up': '
|
|
8
|
-
'strip.down': '
|
|
7
|
+
'strip.up': '向上翻出更多提问圆点',
|
|
8
|
+
'strip.down': '向下翻出更多提问圆点',
|
|
9
9
|
'jump.inactive': '聊天视图未激活',
|
|
10
10
|
'jump.hidden': '目标无独立气泡,已定位到邻近内容',
|
|
11
11
|
'jump.notfound': '目标未加载或不存在(可能已压缩)',
|
|
@@ -15,12 +15,14 @@ export const zh = {
|
|
|
15
15
|
'settings.align.desc': '选择圆点导航条锚定在对话栏的哪一侧。',
|
|
16
16
|
'settings.align.left': '左侧',
|
|
17
17
|
'settings.align.right': '右侧',
|
|
18
|
+
'settings.pagesize.title': '每次翻出数量',
|
|
19
|
+
'settings.pagesize.desc': '点击 ▲/▼ 三角按钮时,每次翻出的隐藏圆点数量。',
|
|
18
20
|
} as const
|
|
19
21
|
|
|
20
22
|
export const en = {
|
|
21
23
|
'strip.empty': 'No questions in this session yet',
|
|
22
|
-
'strip.up': 'Reveal
|
|
23
|
-
'strip.down': 'Reveal
|
|
24
|
+
'strip.up': 'Reveal more question dots above',
|
|
25
|
+
'strip.down': 'Reveal more question dots below',
|
|
24
26
|
'jump.inactive': 'Chat view is not active',
|
|
25
27
|
'jump.hidden': 'No dedicated bubble; landed on nearby content',
|
|
26
28
|
'jump.notfound': 'Target not loaded or missing (maybe compacted)',
|
|
@@ -30,6 +32,8 @@ export const en = {
|
|
|
30
32
|
'settings.align.desc': 'Choose which edge of the conversation column the dot rail anchors to.',
|
|
31
33
|
'settings.align.left': 'Left',
|
|
32
34
|
'settings.align.right': 'Right',
|
|
35
|
+
'settings.pagesize.title': 'Dots per page',
|
|
36
|
+
'settings.pagesize.desc': 'How many hidden dots one click of the ▲/▼ paging buttons reveals.',
|
|
33
37
|
} as const
|
|
34
38
|
|
|
35
39
|
export type QuestionNavKey = keyof typeof zh
|
|
@@ -22,11 +22,10 @@
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/* The paging queue: the fixed column (count + ▲, then the scrollable dot band,
|
|
25
|
-
then ▼)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
pointer events. */
|
|
25
|
+
then ▼). Clamped to 60% of the conversation height and vertically centered
|
|
26
|
+
(auto margins); when the queue overflows, only the middle dot band scrolls
|
|
27
|
+
— the count and triangles stay pinned. The wrapper stays pass-through; the
|
|
28
|
+
band and triangles re-enable pointer events. */
|
|
30
29
|
.queue {
|
|
31
30
|
flex: 0 1 auto;
|
|
32
31
|
min-height: 0;
|
|
@@ -44,7 +43,8 @@
|
|
|
44
43
|
/* The middle dot band: the only scrollable part of the queue. Scrolls from the
|
|
45
44
|
top (overflow collapses the auto margins to 0). The native scrollbar is
|
|
46
45
|
always hidden: this is a minimap paged by the ▲/▼ buttons and by
|
|
47
|
-
wheel/trackpad, and overflow is signalled by the
|
|
46
|
+
wheel/trackpad, and overflow is signalled by the ▲/▼ triangles themselves
|
|
47
|
+
(each appears only while its direction has more dots to reveal). */
|
|
48
48
|
.list {
|
|
49
49
|
flex: 1 1 auto;
|
|
50
50
|
min-height: 0;
|
|
@@ -64,13 +64,6 @@
|
|
|
64
64
|
height: 0;
|
|
65
65
|
display: none;
|
|
66
66
|
}
|
|
67
|
-
/* Overflowing band (applied from JS when scrollHeight > clientHeight): dots
|
|
68
|
-
fade out towards both edges — a pure visual cue for "more beyond" (the
|
|
69
|
-
paging triangles do the actual scrolling). */
|
|
70
|
-
.listScrollable {
|
|
71
|
-
-webkit-mask-image: linear-gradient(to bottom, transparent, black 22px, black calc(100% - 22px), transparent);
|
|
72
|
-
mask-image: linear-gradient(to bottom, transparent, black 22px, black calc(100% - 22px), transparent);
|
|
73
|
-
}
|
|
74
67
|
|
|
75
68
|
.dot {
|
|
76
69
|
flex: none;
|
|
@@ -96,6 +89,50 @@
|
|
|
96
89
|
background: var(--dsw-alias-brand-primary);
|
|
97
90
|
}
|
|
98
91
|
|
|
92
|
+
/* Click-paging feedback: dots revealed by a ▲/▼ click pop in with a staggered
|
|
93
|
+
fade + rise/drop (per-dot animation-delay is set inline, stagger order *
|
|
94
|
+
35ms), traveling in the paging direction so the newly revealed batch reads
|
|
95
|
+
as "the click brought these in". `both` fill holds the hidden `from` state
|
|
96
|
+
during each dot's delay. */
|
|
97
|
+
@keyframes dotEnterFromBottom {
|
|
98
|
+
from {
|
|
99
|
+
opacity: 0;
|
|
100
|
+
transform: translateY(7px) scale(0.4);
|
|
101
|
+
}
|
|
102
|
+
60% {
|
|
103
|
+
opacity: 1;
|
|
104
|
+
}
|
|
105
|
+
to {
|
|
106
|
+
opacity: 1;
|
|
107
|
+
transform: translateY(0) scale(1);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
@keyframes dotEnterFromTop {
|
|
111
|
+
from {
|
|
112
|
+
opacity: 0;
|
|
113
|
+
transform: translateY(-7px) scale(0.4);
|
|
114
|
+
}
|
|
115
|
+
60% {
|
|
116
|
+
opacity: 1;
|
|
117
|
+
}
|
|
118
|
+
to {
|
|
119
|
+
opacity: 1;
|
|
120
|
+
transform: translateY(0) scale(1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
.dotEnterFromBottom {
|
|
124
|
+
animation: dotEnterFromBottom 320ms cubic-bezier(0.2, 0.9, 0.3, 1.2) both;
|
|
125
|
+
}
|
|
126
|
+
.dotEnterFromTop {
|
|
127
|
+
animation: dotEnterFromTop 320ms cubic-bezier(0.2, 0.9, 0.3, 1.2) both;
|
|
128
|
+
}
|
|
129
|
+
@media (prefers-reduced-motion: reduce) {
|
|
130
|
+
.dotEnterFromBottom,
|
|
131
|
+
.dotEnterFromTop {
|
|
132
|
+
animation: none;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
99
136
|
/* Question count, rendered just above the first dot (sits in the list gap). */
|
|
100
137
|
.count {
|
|
101
138
|
flex: none;
|
|
@@ -169,7 +206,7 @@
|
|
|
169
206
|
position: fixed;
|
|
170
207
|
z-index: 30;
|
|
171
208
|
max-width: 260px;
|
|
172
|
-
background: var(--dsw-alias-bg-layer-
|
|
209
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
173
210
|
border: 1px solid var(--dsw-alias-border-l1);
|
|
174
211
|
border-radius: 6px;
|
|
175
212
|
padding: 6px 10px;
|
|
@@ -193,14 +230,23 @@
|
|
|
193
230
|
pointer-events: none;
|
|
194
231
|
}
|
|
195
232
|
|
|
233
|
+
/* Right-aligned rail: the cascade opens to the LEFT of the rail, so the
|
|
234
|
+
column hugs the rail by stacking its cards against the right edge. */
|
|
235
|
+
.cascadeRight {
|
|
236
|
+
align-items: flex-end;
|
|
237
|
+
}
|
|
238
|
+
|
|
196
239
|
/* One focus question card (portal-rendered in the cascade). The four context
|
|
197
240
|
cards use the base style: a clean solid surface, a quiet neutral border and
|
|
198
241
|
a low shadow — crisp and readable, one visual step behind the focus card.
|
|
199
|
-
|
|
200
|
-
|
|
242
|
+
The surface is the theme's TOP elevation layer (bg-layer-3, the same token
|
|
243
|
+
DSH menus and popups use): these cards float above the conversation, and in
|
|
244
|
+
dark themes the layer ramp gives them the raised-surface tint. Clicking
|
|
245
|
+
jumps to the question, like the dot; hover raises the border to signal the
|
|
246
|
+
card is interactive. */
|
|
201
247
|
.card {
|
|
202
248
|
width: 380px;
|
|
203
|
-
background: var(--dsw-alias-bg-layer-
|
|
249
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
204
250
|
border: 1px solid var(--dsw-alias-border-l2);
|
|
205
251
|
border-radius: 10px;
|
|
206
252
|
padding: 8px 12px 10px;
|
|
@@ -237,6 +283,20 @@
|
|
|
237
283
|
0 6px 22px rgba(0, 0, 0, 0.26);
|
|
238
284
|
}
|
|
239
285
|
|
|
286
|
+
/* Right-aligned rail: the focus card's accent bar mirrors to the right edge
|
|
287
|
+
(the side facing the rail). Declared after .cardSelected:hover so the
|
|
288
|
+
mirrored bar wins at equal specificity. */
|
|
289
|
+
.cardSelectedRight {
|
|
290
|
+
box-shadow:
|
|
291
|
+
inset -3px 0 0 0 var(--dsw-alias-brand-primary),
|
|
292
|
+
0 6px 20px rgba(0, 0, 0, 0.22);
|
|
293
|
+
}
|
|
294
|
+
.cardSelectedRight:hover {
|
|
295
|
+
box-shadow:
|
|
296
|
+
inset -3px 0 0 0 var(--dsw-alias-brand-primary),
|
|
297
|
+
0 6px 22px rgba(0, 0, 0, 0.26);
|
|
298
|
+
}
|
|
299
|
+
|
|
240
300
|
/* Turn label line at the top of the card ("Turn 32"). */
|
|
241
301
|
.cardTitle {
|
|
242
302
|
font-size: 11px;
|
|
@@ -245,11 +305,18 @@
|
|
|
245
305
|
margin-bottom: 4px;
|
|
246
306
|
}
|
|
247
307
|
|
|
248
|
-
/* Question body; long multi-question turns scroll inside the card.
|
|
308
|
+
/* Question body; long multi-question turns scroll inside the card. The
|
|
309
|
+
scrollbar stays themed: NO `scrollbar-width` here — a non-auto value makes
|
|
310
|
+
Chromium/Safari drop the GUI's `::-webkit-scrollbar*` skin for this element
|
|
311
|
+
(see ui-theme styles/scrollbar.css). Firefox still gets a thin themed bar
|
|
312
|
+
from the theme's `body *` rule, which reads the rebound tokens below. */
|
|
249
313
|
.cardBody {
|
|
250
314
|
max-height: 96px;
|
|
251
315
|
overflow-y: auto;
|
|
252
|
-
|
|
316
|
+
/* Elevated surface (bg-layer-3): the scrollbar thumb takes the l2 elevation
|
|
317
|
+
tokens, matching DSH menus/popovers. */
|
|
318
|
+
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
|
319
|
+
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
|
253
320
|
}
|
|
254
321
|
|
|
255
322
|
/* Clamped cascade-card text (the two neighbors on each side): N lines with
|
|
@@ -291,6 +358,10 @@
|
|
|
291
358
|
color: var(--dsw-alias-label-primary);
|
|
292
359
|
margin: 0;
|
|
293
360
|
}
|
|
361
|
+
/* Separate consecutive setting groups (align, then page size). */
|
|
362
|
+
.segmented + .settingsTitle {
|
|
363
|
+
margin-top: 12px;
|
|
364
|
+
}
|
|
294
365
|
.settingsDesc {
|
|
295
366
|
font-size: 12px;
|
|
296
367
|
line-height: 18px;
|