dsh-smooth-stream 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,248 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
4
+ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
5
+ import {
6
+ IconCloseOutline16,
7
+ IconCodeOutline16,
8
+ IconCopyOutline16,
9
+ IconQuestionOutline14,
10
+ IconRefreshOutline16,
11
+ Tooltip,
12
+ writeClipboard,
13
+ } from '@deepseek-ai/dsh-client-ui-primitives'
14
+ import type { StreamDebugTuning } from '../settings.ts'
15
+ import type { DebugPanelFace, DebugRuntimeState } from './debugRuntime.ts'
16
+ import type { NS } from './locales.ts'
17
+ import css from './DebugPanel.module.css'
18
+
19
+ export type DebugPanelProps =
20
+ PropsRuntime<'conversation.session.header.utilities'>
21
+ & PropsLocale<typeof NS>
22
+ & InjectFace<DebugPanelFace>
23
+
24
+ type TuningKey = keyof StreamDebugTuning
25
+
26
+ interface TuningControl {
27
+ key: TuningKey
28
+ label: Parameters<DebugPanelProps['t']>[0]
29
+ tip: Parameters<DebugPanelProps['t']>[0]
30
+ min: number
31
+ max: number
32
+ step: number
33
+ unit: string
34
+ }
35
+
36
+ const REVEAL_CONTROLS: readonly TuningControl[] = [
37
+ { key: 'revealScale', label: 'debugRevealMultiplier', tip: 'debugTipRevealMultiplier', min: 0.25, max: 2, step: 0.05, unit: 'x' },
38
+ { key: 'queuePressure', label: 'debugQueuePressure', tip: 'debugTipQueuePressure', min: 0, max: 2, step: 0.05, unit: 'x' },
39
+ { key: 'maxRevealCps', label: 'debugMaxReveal', tip: 'debugTipMaxReveal', min: 120, max: 1000, step: 10, unit: 'cps' },
40
+ ]
41
+
42
+ const FOLLOW_CONTROLS: readonly TuningControl[] = [
43
+ { key: 'springStiffness', label: 'debugSpringStiffness', tip: 'debugTipSpringStiffness', min: 40, max: 320, step: 5, unit: '' },
44
+ { key: 'springDamping', label: 'debugSpringDamping', tip: 'debugTipSpringDamping', min: 8, max: 80, step: 1, unit: '' },
45
+ { key: 'springMass', label: 'debugSpringMass', tip: 'debugTipSpringMass', min: 0.5, max: 3, step: 0.05, unit: '' },
46
+ { key: 'runwayPx', label: 'debugRunway', tip: 'debugTipRunway', min: 0, max: 120, step: 2, unit: 'px' },
47
+ { key: 'reserveResponseMs', label: 'debugReserveResponse', tip: 'debugTipReserveResponse', min: 60, max: 600, step: 10, unit: 'ms' },
48
+ { key: 'backpressureMinScale', label: 'debugBackpressureMin', tip: 'debugTipBackpressureMin', min: 0.25, max: 1, step: 0.05, unit: 'x' },
49
+ ]
50
+
51
+ function fixed(value: number | null, digits = 1): string {
52
+ return value === null || !Number.isFinite(value) ? '-' : value.toFixed(digits)
53
+ }
54
+
55
+ function Metric({ label, value, tone }: { label: string; value: string; tone?: 'good' | 'warn' | undefined }) {
56
+ return (
57
+ <div className={css.metric}>
58
+ <dt>{label}</dt>
59
+ <dd data-tone={tone}>{value}</dd>
60
+ </div>
61
+ )
62
+ }
63
+
64
+ function TuningField({
65
+ control,
66
+ state,
67
+ edit,
68
+ label,
69
+ t,
70
+ }: {
71
+ control: TuningControl
72
+ state: DebugRuntimeState
73
+ edit: DebugPanelFace['edit']
74
+ label: string
75
+ t: DebugPanelProps['t']
76
+ }) {
77
+ const value = state.tuning[control.key]
78
+ const labelId = `smooth-stream-debug-${control.key}`
79
+ const update = (next: number) => {
80
+ if (!Number.isFinite(next)) return
81
+ const clamped = Math.min(control.max, Math.max(control.min, next))
82
+ edit({ debugTuning: { ...state.tuning, [control.key]: clamped } })
83
+ }
84
+ return (
85
+ <div className={css.control}>
86
+ <span className={css.controlHead}>
87
+ <span className={css.controlLabel}>
88
+ <span id={labelId}>{label}</span>
89
+ <Tooltip label={t(control.tip)} side="right" maxWidth={300}>
90
+ <button
91
+ className={css.infoButton}
92
+ type="button"
93
+ aria-label={label}
94
+ title={t(control.tip)}
95
+ >
96
+ <IconQuestionOutline14 />
97
+ </button>
98
+ </Tooltip>
99
+ </span>
100
+ <span className={css.numberWrap}>
101
+ <input
102
+ className={css.number}
103
+ type="number"
104
+ aria-labelledby={labelId}
105
+ min={control.min}
106
+ max={control.max}
107
+ step={control.step}
108
+ value={value}
109
+ disabled={!state.writable}
110
+ onChange={event => { update(event.currentTarget.valueAsNumber) }}
111
+ />
112
+ {control.unit === '' ? null : <span className={css.unit}>{control.unit}</span>}
113
+ </span>
114
+ </span>
115
+ <input
116
+ className={css.range}
117
+ type="range"
118
+ aria-labelledby={labelId}
119
+ min={control.min}
120
+ max={control.max}
121
+ step={control.step}
122
+ value={value}
123
+ disabled={!state.writable}
124
+ onChange={event => { update(event.currentTarget.valueAsNumber) }}
125
+ />
126
+ </div>
127
+ )
128
+ }
129
+
130
+ export function DebugPanel(props: DebugPanelProps) {
131
+ const { t } = props
132
+ const state = props.useDebugRuntime(snapshot => snapshot)
133
+ const [open, setOpen] = useState(true)
134
+ const [copied, setCopied] = useState(false)
135
+
136
+ useEffect(() => {
137
+ if (!copied) return
138
+ const timer = setTimeout(() => { setCopied(false) }, 1400)
139
+ return () => { clearTimeout(timer) }
140
+ }, [copied])
141
+
142
+ useEffect(() => {
143
+ if (state.enabled) setOpen(true)
144
+ }, [state.enabled])
145
+
146
+ if (!state.available || !state.enabled || !open) return null
147
+ const metrics = state.metrics
148
+ const live = metrics.streamActive || metrics.followActive
149
+ const progress = metrics.streamTargetChars <= 0
150
+ ? '-'
151
+ : `${String(metrics.streamDisplayedChars)} / ${String(metrics.streamTargetChars)}`
152
+ const copyDiagnostics = async () => {
153
+ const accepted = await writeClipboard(JSON.stringify({
154
+ tuning: state.tuning,
155
+ metrics: state.metrics,
156
+ }, null, 2))
157
+ if (accepted) setCopied(true)
158
+ }
159
+
160
+ const panel = !state.enabled || !open ? null : (
161
+ <aside className={css.panel} role="complementary" aria-label={t('debugPanelTitle')}>
162
+ <header className={css.panelHeader}>
163
+ <span className={live ? `${css.statusDot} ${css.statusLive}` : css.statusDot} aria-hidden />
164
+ <span className={css.title}>{t('debugPanelTitle')}</span>
165
+ <span className={css.state}>{t(live ? 'debugLive' : 'debugIdle')}</span>
166
+ {state.dirty ? <span className={css.unsaved}>{t('debugUnsaved')}</span> : null}
167
+ <button className={css.iconButton} type="button" title={t('debugCopy')} aria-label={t('debugCopy')} onClick={() => { void copyDiagnostics() }}>
168
+ <IconCopyOutline16 />
169
+ </button>
170
+ <button
171
+ className={css.iconButton}
172
+ type="button"
173
+ title={t('debugPanelClose')}
174
+ aria-label={t('debugPanelClose')}
175
+ disabled={!state.writable}
176
+ onClick={() => {
177
+ setOpen(false)
178
+ props.edit({ debugEnabled: false })
179
+ props.save()
180
+ }}
181
+ >
182
+ <IconCloseOutline16 />
183
+ </button>
184
+ <span className={css.visuallyHidden} aria-live="polite">{copied ? t('debugCopied') : ''}</span>
185
+ </header>
186
+
187
+ <div className={css.scrollArea}>
188
+ <p className={css.guide}>{t('debugGuide')}</p>
189
+ <section className={css.section} aria-labelledby="smooth-stream-live-heading">
190
+ <h2 id="smooth-stream-live-heading">{t('debugSectionLive')}</h2>
191
+ <dl className={css.metrics}>
192
+ <Metric label={t('debugFps')} value={fixed(metrics.fps, 0)} tone={(metrics.fps ?? 60) < 45 ? 'warn' : 'good'} />
193
+ <Metric label={t('debugFrameTime')} value={`${fixed(metrics.frameMs)} ms`} />
194
+ <Metric label={t('debugBacklog')} value={String(metrics.streamBacklog)} tone={metrics.streamBacklog > 32 ? 'warn' : undefined} />
195
+ <Metric label={t('debugRevealSpeed')} value={`${fixed(metrics.streamSpeedCps, 0)} cps`} />
196
+ <Metric label={t('debugProgress')} value={progress} />
197
+ <Metric label={t('debugFollowState')} value={t(metrics.followFollowing ? 'debugFollowing' : 'debugReleased')} />
198
+ <Metric label={t('debugLag')} value={`${fixed(metrics.followLagPx)} px`} tone={metrics.followConstrained ? 'warn' : undefined} />
199
+ <Metric label={t('debugVelocity')} value={`${fixed(metrics.followVelocityPxPerSec, 0)} px/s`} />
200
+ <Metric label={t('debugReserve')} value={`${fixed(metrics.followReservePx)} px`} />
201
+ <Metric label={t('debugCapacity')} value={`${fixed(metrics.followCapacityPx)} px`} />
202
+ <Metric label={t('debugAppliedScale')} value={`${fixed(metrics.followRevealScale, 2)}x`} />
203
+ </dl>
204
+ </section>
205
+
206
+ <section className={css.section} aria-labelledby="smooth-stream-reveal-heading">
207
+ <h2 id="smooth-stream-reveal-heading">{t('debugSectionReveal')}</h2>
208
+ {REVEAL_CONTROLS.map(control => (
209
+ <TuningField key={control.key} control={control} state={state} edit={props.edit} label={t(control.label)} t={t} />
210
+ ))}
211
+ </section>
212
+
213
+ <section className={css.section} aria-labelledby="smooth-stream-follow-heading">
214
+ <h2 id="smooth-stream-follow-heading">{t('debugSectionFollow')}</h2>
215
+ {FOLLOW_CONTROLS.map(control => (
216
+ <TuningField key={control.key} control={control} state={state} edit={props.edit} label={t(control.label)} t={t} />
217
+ ))}
218
+ </section>
219
+ </div>
220
+
221
+ <footer className={css.footer}>
222
+ <button className={css.secondaryButton} type="button" disabled={!state.writable} onClick={props.reset}>
223
+ <IconRefreshOutline16 />
224
+ {t('debugReset')}
225
+ </button>
226
+ <span className={css.footerSpacer} />
227
+ <button className={css.secondaryButton} type="button" disabled={!state.writable || !state.dirty} onClick={props.discard}>{t('debugDiscard')}</button>
228
+ <button className={css.primaryButton} type="button" disabled={!state.writable || !state.dirty} onClick={props.save}>{t('debugSave')}</button>
229
+ </footer>
230
+ </aside>
231
+ )
232
+
233
+ return (
234
+ <>
235
+ <button
236
+ type="button"
237
+ className={state.enabled && open ? `${css.trigger} ${css.triggerActive}` : css.trigger}
238
+ aria-expanded={state.enabled && open}
239
+ aria-label={t('debugPanelToggle')}
240
+ title={t('debugPanelToggle')}
241
+ onClick={() => { setOpen(current => !current) }}
242
+ >
243
+ <IconCodeOutline16 />
244
+ </button>
245
+ {typeof document === 'undefined' || panel === null ? null : createPortal(panel, document.body)}
246
+ </>
247
+ )
248
+ }
@@ -117,6 +117,14 @@
117
117
  padding: 12px 0;
118
118
  }
119
119
 
120
+ .field + .field {
121
+ border-top: 1px solid var(--dsw-alias-border-l2);
122
+ }
123
+
124
+ .fieldDisabled {
125
+ opacity: 0.5;
126
+ }
127
+
120
128
  .fieldHead {
121
129
  display: flex;
122
130
  align-items: center;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The smooth-stream plugin configuration card, rendered inside the Web
3
- * Settings "plugin configuration" page. One toggle, staged until the user
3
+ * Settings "plugin configuration" page. Preferences are staged until the user
4
4
  * saves — the same shape as the Host-shipped cards, hand-drawn because the
5
5
  * Host cards' chrome is not exported for reuse.
6
6
  */
@@ -59,18 +59,57 @@ export function SmoothStreamCard(props: SmoothStreamCardProps) {
59
59
  <>
60
60
  {!state.writable ? <p className={css.readOnly} role="status">{t('readOnly')}</p> : null}
61
61
  <label className={css.field}>
62
+ <span className={css.fieldHead}>
63
+ <span className={css.label}>{t('enabled')}</span>
64
+ <input
65
+ type="checkbox"
66
+ className={css.toggle}
67
+ checked={state.enabled}
68
+ disabled={!state.writable || state.saving}
69
+ onChange={(event) => { props.edit({ enabled: event.target.checked }) }}
70
+ />
71
+ </span>
72
+ <span className={css.hint}>{t('enabledHint')}</span>
73
+ </label>
74
+ <label className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
62
75
  <span className={css.fieldHead}>
63
76
  <span className={css.label}>{t('thinkAutoExpand')}</span>
64
77
  <input
65
78
  type="checkbox"
66
79
  className={css.toggle}
67
80
  checked={state.thinkAutoExpand}
68
- disabled={!state.writable}
69
- onChange={(event) => { props.edit(event.target.checked) }}
81
+ disabled={!state.writable || state.saving || !state.enabled}
82
+ onChange={(event) => { props.edit({ thinkAutoExpand: event.target.checked }) }}
70
83
  />
71
84
  </span>
72
85
  <span className={css.hint}>{t('thinkAutoExpandHint')}</span>
73
86
  </label>
87
+ <label className={css.field}>
88
+ <span className={css.fieldHead}>
89
+ <span className={css.label}>{t('autoCollapse')}</span>
90
+ <input
91
+ type="checkbox"
92
+ className={css.toggle}
93
+ checked={state.autoCollapse}
94
+ disabled={!state.writable || state.saving}
95
+ onChange={(event) => { props.edit({ autoCollapse: event.target.checked }) }}
96
+ />
97
+ </span>
98
+ <span className={css.hint}>{t('autoCollapseHint')}</span>
99
+ </label>
100
+ <label className={state.debugAvailable ? css.field : `${css.field} ${css.fieldDisabled}`}>
101
+ <span className={css.fieldHead}>
102
+ <span className={css.label}>{t('debugEnabled')}</span>
103
+ <input
104
+ type="checkbox"
105
+ className={css.toggle}
106
+ checked={state.debugEnabled}
107
+ disabled={!state.debugAvailable || !state.writable || state.saving}
108
+ onChange={(event) => { props.edit({ debugEnabled: event.target.checked }) }}
109
+ />
110
+ </span>
111
+ <span className={css.hint}>{state.debugAvailable ? t('debugEnabledHint') : t('debugUnavailable')}</span>
112
+ </label>
74
113
  <div className={css.updateRow}>
75
114
  <span className={css.updateCopy}>
76
115
  <span className={css.label}>{t('updates')}</span>