dsh-smooth-stream 0.1.0 → 0.3.2

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,122 @@
1
+ /**
2
+ * The smooth-stream plugin configuration card, rendered inside the Web
3
+ * Settings "plugin configuration" page. One toggle, staged until the user
4
+ * saves — the same shape as the Host-shipped cards, hand-drawn because the
5
+ * Host cards' chrome is not exported for reuse.
6
+ */
7
+
8
+ import { useState } from 'react'
9
+ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
10
+ import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
11
+ import { IconChevronDownOutline14, IconRefreshOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
12
+ import type { SmoothStreamCardFace } from './smooth-stream-card-controller.ts'
13
+ import css from './SmoothStreamCard.module.css'
14
+
15
+ /** Props the renderer binds for the smooth-stream card. */
16
+ export type SmoothStreamCardProps =
17
+ PropsRuntime<'settings.plugin.item'>
18
+ & PropsLocale<'settings.smoothStream'>
19
+ & InjectFace<SmoothStreamCardFace>
20
+
21
+ /** Render the smooth-stream card independently of the core settings namespace allowlist. */
22
+ export function SmoothStreamCard(props: SmoothStreamCardProps) {
23
+ const { t } = props
24
+ const [open, setOpen] = useState(false)
25
+ const state = props.useSmoothStreamCard(snapshot => snapshot)
26
+ const blocked = !state.dirty || state.saving || state.status !== 'ready'
27
+ const versionLabel = state.version === undefined
28
+ ? null
29
+ : t(state.installation === 'development' ? 'developmentVersion' : 'version')
30
+ .replace('{version}', state.version)
31
+
32
+ return (
33
+ <li className={open ? `${css.card} ${css.cardOpen}` : css.card}>
34
+ <button
35
+ type="button"
36
+ className={css.header}
37
+ aria-expanded={open}
38
+ onClick={() => { setOpen(!open) }}
39
+ >
40
+ <span className={css.headText}>
41
+ <span className={css.name}>{t('title')}</span>
42
+ <span className={css.description}>{t('description')}</span>
43
+ </span>
44
+ {versionLabel === null ? null : <span className={css.version}>{versionLabel}</span>}
45
+ {state.dirty ? <span className={css.pending}>{t('unsaved')}</span> : null}
46
+ <IconChevronDownOutline14 className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron} />
47
+ </button>
48
+ {open
49
+ ? (
50
+ <div className={css.body}>
51
+ {state.status === 'loading' ? <p className={css.readOnly} role="status">{t('loading')}</p> : null}
52
+ {state.status === 'unavailable' ? (
53
+ <div className={css.failure}>
54
+ <p className={css.readOnly} role="status">{t('unavailable')}</p>
55
+ <button type="button" className={css.discard} onClick={props.reload}>{t('retry')}</button>
56
+ </div>
57
+ ) : null}
58
+ {state.status === 'ready' ? (
59
+ <>
60
+ {!state.writable ? <p className={css.readOnly} role="status">{t('readOnly')}</p> : null}
61
+ <label className={css.field}>
62
+ <span className={css.fieldHead}>
63
+ <span className={css.label}>{t('thinkAutoExpand')}</span>
64
+ <input
65
+ type="checkbox"
66
+ className={css.toggle}
67
+ checked={state.thinkAutoExpand}
68
+ disabled={!state.writable}
69
+ onChange={(event) => { props.edit(event.target.checked) }}
70
+ />
71
+ </span>
72
+ <span className={css.hint}>{t('thinkAutoExpandHint')}</span>
73
+ </label>
74
+ <div className={css.updateRow}>
75
+ <span className={css.updateCopy}>
76
+ <span className={css.label}>{t('updates')}</span>
77
+ <span className={css.hint}>
78
+ {state.restartRequired
79
+ ? t('restartRequired')
80
+ : state.installation === 'npm' ? t('updateHint')
81
+ : state.installation === 'development' ? t('developmentBuild') : t('updateUnavailable')}
82
+ </span>
83
+ </span>
84
+ <button
85
+ type="button"
86
+ className={css.update}
87
+ disabled={!state.canUpgrade || state.upgrading || state.restartRequired}
88
+ title={state.canUpgrade ? undefined : t('updateUnavailable')}
89
+ onClick={props.upgrade}
90
+ >
91
+ <span aria-hidden="true"><IconRefreshOutline14 /></span>
92
+ {t(state.upgrading ? 'updating' : 'update')}
93
+ </button>
94
+ </div>
95
+ {state.upgradeFailed ? <p className={css.failed} role="status">{t('updateFailed')}</p> : null}
96
+ <div className={css.footer}>
97
+ {state.failed ? <p className={css.failed} role="status">{t('saveFailed')}</p> : null}
98
+ <button
99
+ type="button"
100
+ className={css.discard}
101
+ disabled={!state.dirty || state.saving}
102
+ onClick={props.discard}
103
+ >
104
+ {t('discard')}
105
+ </button>
106
+ <button
107
+ type="button"
108
+ className={css.save}
109
+ disabled={blocked}
110
+ onClick={props.save}
111
+ >
112
+ {t(state.saving ? 'saving' : 'save')}
113
+ </button>
114
+ </div>
115
+ </>
116
+ ) : null}
117
+ </div>
118
+ )
119
+ : null}
120
+ </li>
121
+ )
122
+ }
@@ -218,8 +218,8 @@
218
218
  }
219
219
 
220
220
  /*
221
- * Follow host: ordinary document flow. The last text block owns the
222
- * conversation scrollport's bottom-follow while the reply reveals
221
+ * Follow host: ordinary document flow. The assistant node owns follow during
222
+ * streaming; the last text block takes over while its settled queue drains
223
223
  * (see useConversationFollow).
224
224
  */
225
225
  .follow {
@@ -7,6 +7,7 @@ import { useSmoothStreamContent, type StreamSmoothingPreset } from './useSmoothS
7
7
  import { useFpsGuard } from './useFpsGuard.ts'
8
8
  import { FollowHost } from './FollowHost.tsx'
9
9
  import { DEFAULT_STREAM_CONFIG, type StreamMode } from '../config.ts'
10
+ import { DEFAULT_STREAM_SETTINGS } from '../settings.ts'
10
11
  import css from './TypewriterAssistantNodeView.module.css'
11
12
 
12
13
  type AssistantProps = ChatNodeViewProps<'assistant-step'>
@@ -125,31 +126,36 @@ function AnimatedReasoning({
125
126
  text,
126
127
  running,
127
128
  preset,
129
+ thinkAutoExpand,
128
130
  shouldHoldBack,
131
+ followSpeedCpsRef,
129
132
  t,
130
133
  }: {
131
134
  text: string
132
135
  running: boolean
133
136
  preset: StreamSmoothingPreset
137
+ thinkAutoExpand: boolean
134
138
  shouldHoldBack: () => boolean
139
+ followSpeedCpsRef?: { current: number } | undefined
135
140
  t: AssistantProps['t']
136
141
  }) {
137
142
  const reduced = usePrefersReducedMotion()
138
- const [expanded, setExpanded] = useState(running)
143
+ const [expanded, setExpanded] = useState(running && thinkAutoExpand)
139
144
  const summaryRef = useRef<HTMLSpanElement>(null)
140
- const speedCpsRef = useRef(35)
141
145
  const displayed = useSmoothStreamContent(text, {
142
146
  enabled: running && !reduced,
143
147
  preset,
144
148
  shouldHoldBack,
145
- speedCpsRef,
149
+ speedCpsRef: followSpeedCpsRef,
146
150
  })
147
151
  const shown = running && !reduced ? displayed : text
148
152
  const summary = running ? latestLine(shown) : firstLine(text)
149
153
 
150
154
  useEffect(() => {
151
- setExpanded(running)
152
- }, [running])
155
+ // Only the running state owns disclosure while auto-expand is on; with it
156
+ // off, a manual toggle is never wrestled back by the stream.
157
+ if (thinkAutoExpand) setExpanded(running)
158
+ }, [running, thinkAutoExpand])
153
159
 
154
160
  useEffect(() => {
155
161
  const element = summaryRef.current
@@ -157,8 +163,9 @@ function AnimatedReasoning({
157
163
  element.scrollLeft = running ? element.scrollWidth - element.clientWidth : 0
158
164
  }, [running, summary])
159
165
 
166
+ // Preserve FollowHost's layout wrapper without mounting a second scroll owner.
160
167
  return (
161
- <FollowHost active={running} speedCpsRef={speedCpsRef}>
168
+ <div className={css.follow}>
162
169
  <div className={css.think} data-variant="think" data-state={running ? 'running' : 'ok'}>
163
170
  {running && <span className={css.visuallyHidden}>{t('row.running')}</span>}
164
171
  <AnimatedDisclosure
@@ -180,7 +187,7 @@ function AnimatedReasoning({
180
187
  <div className={css.thinkBody}>{shown}</div>
181
188
  </AnimatedDisclosure>
182
189
  </div>
183
- </FollowHost>
190
+ </div>
184
191
  )
185
192
  }
186
193
 
@@ -189,10 +196,10 @@ function AnimatedReasoning({
189
196
  * streaming is revealed by the smoother through the Harness Markdown
190
197
  * renderer at a rate that tracks arrival. Reasoning blocks keep the
191
198
  * built-in Think disclosure and only receive a smoothed text feed; the
192
- * last text or reasoning block owns conversation-port follow so wraps
193
- * glide. The FPS guard holds offscreen
194
- * reveals when the frame rate is degraded. Settled text renders with the
195
- * full Markdown pipeline.
199
+ * outer node owns conversation-port follow while streaming; the final text
200
+ * block keeps ownership while its settled reveal queue drains. The FPS guard
201
+ * holds offscreen reveals when the frame rate is degraded. Settled text
202
+ * renders with the full Markdown pipeline.
196
203
  */
197
204
  export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNodeView({
198
205
  mode: _mode = DEFAULT_STREAM_CONFIG.mode,
@@ -200,6 +207,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
200
207
  revealCharsPerSec: _revealCharsPerSec = DEFAULT_STREAM_CONFIG.revealCharsPerSec,
201
208
  scrollSpeedPxPerSec: _scrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.scrollSpeedPxPerSec,
202
209
  maxScrollSpeedPxPerSec: _maxScrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec,
210
+ thinkAutoExpand = DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
203
211
  node,
204
212
  useTurnData,
205
213
  openFile,
@@ -212,11 +220,16 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
212
220
  revealCharsPerSec?: number
213
221
  scrollSpeedPxPerSec?: number
214
222
  maxScrollSpeedPxPerSec?: number
223
+ thinkAutoExpand?: boolean
215
224
  }) {
216
225
  const data = node.data
217
226
  const streaming = data.status === 'running'
218
227
  const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming)
219
228
  const rootSpeedRef = useRef(35)
229
+ const reasoningOwnsSpeed = streaming && data.blocks[data.blocks.length - 1]?.kind === 'reasoning'
230
+ useEffect(() => {
231
+ if (!reasoningOwnsSpeed) rootSpeedRef.current = 35
232
+ }, [reasoningOwnsSpeed])
220
233
  const turn = node.location.kind === 'turn' || node.location.kind === 'step'
221
234
  ? node.location.turn
222
235
  : undefined
@@ -272,7 +285,9 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
272
285
  text={block.text}
273
286
  running={streaming && index === last}
274
287
  preset={preset}
288
+ thinkAutoExpand={thinkAutoExpand}
275
289
  shouldHoldBack={shouldHoldBack}
290
+ followSpeedCpsRef={reasoningOwnsSpeed && index === last ? rootSpeedRef : undefined}
276
291
  t={t}
277
292
  />,
278
293
  )
@@ -1,12 +1,26 @@
1
- import { createElement, type ComponentType } from 'react'
1
+ import { createElement, useSyncExternalStore, type ComponentType } from 'react'
2
2
  import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
3
3
  import type {} from '@deepseek-ai/dsh-client-locale/client'
4
+ // Type-only: the connection Context merge and the plugins section's SlotMap
5
+ // entry ('settings.plugin.item').
6
+ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
7
+ import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
4
8
  import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
5
9
  import { TypewriterAssistantNodeView } from './TypewriterAssistantNodeView.tsx'
6
10
  import { wrapFollowNodeView, type FollowWrapProps } from './TypewriterToolNodeView.tsx'
11
+ import { SmoothStreamCard } from './SmoothStreamCard.tsx'
12
+ import { SmoothStreamCardController } from './smooth-stream-card-controller.ts'
13
+ import { createSmoothStreamSettingsApi } from './smooth-stream-settings-api.ts'
14
+ import { NS as SETTINGS_NS, en, zh } from './locales.ts'
7
15
  import { DEFAULT_STREAM_CONFIG, STREAM_BOOT_GLOBAL, type StreamConfig } from '../config.ts'
16
+ import { DEFAULT_STREAM_SETTINGS } from '../settings.ts'
8
17
 
9
- /** Cordis services required by the browser half. */
18
+ /**
19
+ * Cordis services required by the browser half. Only `slots` is load-bearing
20
+ * for the stream itself; locale and Connection power the configuration card
21
+ * and are wired through `ctx.inject` below so a deployment without them still
22
+ * streams with defaults.
23
+ */
10
24
  export const inject = ['slots']
11
25
 
12
26
  type AssistantProps = ChatNodeViewProps<'assistant-step'>
@@ -85,17 +99,95 @@ function wrapGrowingChatRows(ctx: ClientContext): () => void {
85
99
  }
86
100
  }
87
101
 
102
+ /**
103
+ * A live preference cell read by `useSyncExternalStore`. It starts on the
104
+ * shared default and, once the plugin-owned settings controller is attached,
105
+ * tracks the resolved `thinkAutoExpand` value. A composition (or test) that
106
+ * ships no settings surface keeps the cell on the default.
107
+ */
108
+ class PreferenceCell {
109
+ private readonly listeners = new Set<() => void>()
110
+ private card: SmoothStreamCardController | undefined
111
+ private value = DEFAULT_STREAM_SETTINGS.thinkAutoExpand
112
+
113
+ /** Re-point the cell at the plugin-owned settings controller. */
114
+ attach(card: SmoothStreamCardController): () => void {
115
+ this.card = card
116
+ this.refresh()
117
+ const unsubscribe = card.subscribe(() => { this.refresh() })
118
+ return () => {
119
+ unsubscribe()
120
+ if (this.card !== card) return
121
+ this.card = undefined
122
+ this.refresh()
123
+ }
124
+ }
125
+
126
+ private read(): boolean {
127
+ return this.card?.getSnapshot().thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand
128
+ }
129
+
130
+ private refresh(): void {
131
+ const next = this.read()
132
+ if (next === this.value) return
133
+ this.value = next
134
+ for (const listener of this.listeners) listener()
135
+ }
136
+
137
+ readonly getSnapshot = (): boolean => this.value
138
+
139
+ readonly subscribe = (listener: () => void): () => void => {
140
+ this.listeners.add(listener)
141
+ return () => { this.listeners.delete(listener) }
142
+ }
143
+ }
144
+
88
145
  /**
89
146
  * Register the typewriter renderer after the conversation package declares the
90
147
  * keyed Chat node seat. A lower priority shadows the built-in assistant row;
91
148
  * every other growing row is wrapped in place so Tool cards, retries, and
92
149
  * workflow runs share conversation follow. The Host-bridged configuration
93
- * selects the render direction, smoothing preset, and glide speed.
150
+ * selects the render direction, smoothing preset, and glide speed; the
151
+ * plugin-owned settings RPC supplies the live auto-expand preference when the
152
+ * settings surface is composed.
94
153
  * @param ctx - Browser context carrying the shared slot registry.
95
154
  */
96
155
  export function apply(ctx: ClientContext): void {
97
156
  const config = readBootConfig()
157
+ const preference = new PreferenceCell()
158
+
159
+ // The card talks to the plugin-owned loopback RPC, so the core settings
160
+ // namespace allowlist cannot make it disappear. The stream still applies
161
+ // with defaults when the optional Settings UI or Connection is absent.
162
+ ctx.inject(['slots', 'locale', 'connection'], (settingsCtx) => {
163
+ const card = new SmoothStreamCardController(
164
+ // The shared Context augmentation also carries the Host-side Connection
165
+ // shape. This browser entry runs after the client provider installs its
166
+ // handle, so narrow through unknown to its client contract here.
167
+ createSmoothStreamSettingsApi(settingsCtx.get('connection') as unknown as ConnectionHandle),
168
+ )
169
+ const detachPreference = preference.attach(card)
170
+ card.start()
171
+ settingsCtx.effect(() => settingsCtx.locale.register(SETTINGS_NS, { zh, en }), 'dsh-smooth-stream: settings dictionaries')
172
+ settingsCtx.slots.inject('settings.plugin.item', () => settingsCtx.slots.register({
173
+ name: 'settings.plugin.item',
174
+ id: 'smooth-stream',
175
+ order: 30,
176
+ locale: SETTINGS_NS,
177
+ inject: () => card.inject(),
178
+ }, SmoothStreamCard))
179
+ return () => {
180
+ card.stop()
181
+ detachPreference()
182
+ }
183
+ })
184
+
98
185
  const configured = function StreamConfiguredView(props: AssistantProps) {
186
+ const thinkAutoExpand = useSyncExternalStore(
187
+ preference.subscribe,
188
+ preference.getSnapshot,
189
+ preference.getSnapshot,
190
+ )
99
191
  return createElement(TypewriterAssistantNodeView, {
100
192
  ...props,
101
193
  mode: config.mode,
@@ -103,6 +195,7 @@ export function apply(ctx: ClientContext): void {
103
195
  revealCharsPerSec: config.revealCharsPerSec,
104
196
  scrollSpeedPxPerSec: config.scrollSpeedPxPerSec,
105
197
  maxScrollSpeedPxPerSec: config.maxScrollSpeedPxPerSec,
198
+ thinkAutoExpand,
106
199
  })
107
200
  }
108
201
  ctx.slots.inject('conversation.chat.node', () => {
@@ -0,0 +1,74 @@
1
+ /** Locale bundles for the smooth-stream plugin configuration card. */
2
+
3
+ /** Dictionary namespace owned by this plugin's settings card. */
4
+ export const NS = 'settings.smoothStream'
5
+
6
+ /** Locale keys the card renders. */
7
+ export type SmoothStreamLocaleKey =
8
+ | 'title' | 'description'
9
+ | 'thinkAutoExpand' | 'thinkAutoExpandHint'
10
+ | 'readOnly' | 'loading' | 'unavailable' | 'retry'
11
+ | 'version' | 'developmentVersion'
12
+ | 'updates' | 'updateHint' | 'developmentBuild' | 'updateUnavailable'
13
+ | 'update' | 'updating' | 'restartRequired' | 'updateFailed'
14
+ | 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed'
15
+
16
+ /** English copy. */
17
+ export const en: Record<SmoothStreamLocaleKey, string> = {
18
+ title: 'Smooth stream',
19
+ description: 'How replies are revealed while they stream.',
20
+ thinkAutoExpand: 'Auto-expand thinking',
21
+ thinkAutoExpandHint: 'Open the thinking block while it streams. Turn off to keep it collapsed.',
22
+ readOnly: 'This deployment stores settings read-only.',
23
+ loading: 'Loading plugin settings…',
24
+ unavailable: 'Plugin settings are unavailable in this connection.',
25
+ retry: 'Retry',
26
+ version: 'Version {version}',
27
+ developmentVersion: 'Development version {version}',
28
+ updates: 'Updates',
29
+ updateHint: 'Install the newest npm version, then restart Harness.',
30
+ developmentBuild: 'Linked source; updates are managed in the checkout.',
31
+ updateUnavailable: 'Updates are available only for an npm profile installation.',
32
+ update: 'Update',
33
+ updating: 'Updating…',
34
+ restartRequired: 'Updated. Restart Harness to load the new version.',
35
+ updateFailed: 'The package update failed; your current version is unchanged.',
36
+ save: 'Save',
37
+ saving: 'Saving…',
38
+ discard: 'Discard',
39
+ unsaved: 'Unsaved',
40
+ saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
41
+ }
42
+
43
+ /** Simplified Chinese copy. */
44
+ export const zh: Record<SmoothStreamLocaleKey, string> = {
45
+ title: '丝滑流式',
46
+ description: '回复在流式输出时如何逐字展现。',
47
+ thinkAutoExpand: '自动展开思考',
48
+ thinkAutoExpandHint: '思考块在流式时自动展开;关闭后保持折叠,可手动展开。',
49
+ readOnly: '本部署的设置为只读。',
50
+ loading: '正在加载插件设置…',
51
+ unavailable: '当前连接无法访问插件设置。',
52
+ retry: '重试',
53
+ version: '版本 {version}',
54
+ developmentVersion: '开发版本 {version}',
55
+ updates: '更新',
56
+ updateHint: '安装最新 npm 版本后重启 Harness。',
57
+ developmentBuild: '当前为本地链接版本,请在源码目录管理更新。',
58
+ updateUnavailable: '只有 profile 使用 npm 包时才能更新。',
59
+ update: '更新',
60
+ updating: '更新中…',
61
+ restartRequired: '已更新;重启 Harness 后加载新版本。',
62
+ updateFailed: '包更新失败,当前版本未改变。',
63
+ save: '保存',
64
+ saving: '保存中…',
65
+ discard: '放弃修改',
66
+ unsaved: '未保存',
67
+ saveFailed: '本部署没有接受这些值,已保留供你修改。',
68
+ }
69
+
70
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
71
+ interface LocaleNamespaceMap {
72
+ 'settings.smoothStream': SmoothStreamLocaleKey
73
+ }
74
+ }
@@ -0,0 +1,162 @@
1
+ /** Staged form state for the plugin-owned smooth-stream settings RPC. */
2
+
3
+ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
4
+ import { DEFAULT_STREAM_SETTINGS } from '../settings.ts'
5
+ import type { StreamInstallationKind, StreamSettingsView } from '../settings-api.ts'
6
+ import type { SmoothStreamSettingsApi } from './smooth-stream-settings-api.ts'
7
+
8
+ /** What the smooth-stream card renders. It remains visible while its Host RPC loads. */
9
+ export interface SmoothStreamCardState {
10
+ status: 'loading' | 'ready' | 'unavailable'
11
+ writable: boolean
12
+ dirty: boolean
13
+ saving: boolean
14
+ failed: boolean
15
+ thinkAutoExpand: boolean
16
+ version: string | undefined
17
+ installation: StreamInstallationKind
18
+ canUpgrade: boolean
19
+ upgrading: boolean
20
+ upgradeFailed: boolean
21
+ restartRequired: boolean
22
+ }
23
+
24
+ /** The registration-side face injected into the settings slot renderer. */
25
+ export interface SmoothStreamCardFace {
26
+ hooks: {
27
+ smoothStreamCard: SnapshotStore<SmoothStreamCardState>
28
+ }
29
+ edit: (value: boolean) => void
30
+ save: () => void
31
+ discard: () => void
32
+ reload: () => void
33
+ upgrade: () => void
34
+ }
35
+
36
+ /** Bridges the plugin's protected Host interface onto a staged settings form. */
37
+ export class SmoothStreamCardController {
38
+ private readonly store = createSnapshotStore<SmoothStreamCardState>(this.projection())
39
+ private loaded: StreamSettingsView | undefined
40
+ private staged: boolean | undefined
41
+ private saving = false
42
+ private failed = false
43
+ private upgrading = false
44
+ private upgradeFailed = false
45
+ private restartRequired = false
46
+ private loadGeneration = 0
47
+ private loadStatus: 'loading' | 'ready' | 'unavailable' = 'loading'
48
+
49
+ constructor(private readonly api: SmoothStreamSettingsApi) {}
50
+
51
+ /** Begin the background read after the card has been registered. */
52
+ start(): void {
53
+ void this.load()
54
+ }
55
+
56
+ /** Ignore a late response after the surrounding optional services unload. */
57
+ stop(): void {
58
+ this.loadGeneration += 1
59
+ }
60
+
61
+ /** Current card snapshot, also consumed by the streaming preference cell. */
62
+ getSnapshot(): SmoothStreamCardState {
63
+ return this.store.getSnapshot()
64
+ }
65
+
66
+ /** Subscribe to state changes. */
67
+ subscribe(listener: () => void): () => void {
68
+ return this.store.subscribe(listener)
69
+ }
70
+
71
+ /** Build the face consumed by the settings slot renderer. */
72
+ inject(): SmoothStreamCardFace {
73
+ return {
74
+ hooks: { smoothStreamCard: this.store },
75
+ edit: (value) => {
76
+ this.staged = value
77
+ this.failed = false
78
+ this.publish()
79
+ },
80
+ save: () => { void this.save() },
81
+ discard: () => {
82
+ if (this.staged === undefined && !this.failed) return
83
+ this.staged = undefined
84
+ this.failed = false
85
+ this.publish()
86
+ },
87
+ reload: () => { void this.load() },
88
+ upgrade: () => { void this.upgrade() },
89
+ }
90
+ }
91
+
92
+ private projection(): SmoothStreamCardState {
93
+ return {
94
+ status: this.loadStatus,
95
+ writable: this.loaded?.writable ?? false,
96
+ dirty: this.staged !== undefined,
97
+ saving: this.saving,
98
+ failed: this.failed,
99
+ thinkAutoExpand: this.staged ?? this.loaded?.thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
100
+ version: this.loaded?.version,
101
+ installation: this.loaded?.installation ?? 'unmanaged',
102
+ canUpgrade: this.loaded?.canUpgrade ?? false,
103
+ upgrading: this.upgrading,
104
+ upgradeFailed: this.upgradeFailed,
105
+ restartRequired: this.restartRequired,
106
+ }
107
+ }
108
+
109
+ private async load(): Promise<void> {
110
+ const generation = ++this.loadGeneration
111
+ this.loadStatus = 'loading'
112
+ this.loaded = undefined
113
+ this.publish()
114
+ try {
115
+ const view = await this.api.read()
116
+ if (generation !== this.loadGeneration) return
117
+ this.loaded = view
118
+ this.loadStatus = 'ready'
119
+ } catch {
120
+ if (generation !== this.loadGeneration) return
121
+ this.loaded = undefined
122
+ this.loadStatus = 'unavailable'
123
+ }
124
+ this.publish()
125
+ }
126
+
127
+ private async save(): Promise<void> {
128
+ if (this.staged === undefined || this.saving || this.loaded?.writable !== true) return
129
+ const value = this.staged
130
+ this.saving = true
131
+ this.failed = false
132
+ this.publish()
133
+ try {
134
+ this.loaded = await this.api.write(value)
135
+ this.staged = undefined
136
+ } catch {
137
+ this.failed = true
138
+ }
139
+ this.saving = false
140
+ this.publish()
141
+ }
142
+
143
+ private async upgrade(): Promise<void> {
144
+ if (this.loaded?.canUpgrade !== true || this.upgrading) return
145
+ this.upgrading = true
146
+ this.upgradeFailed = false
147
+ this.restartRequired = false
148
+ this.publish()
149
+ try {
150
+ const result = await this.api.upgrade()
151
+ this.restartRequired = result.restartRequired
152
+ } catch {
153
+ this.upgradeFailed = true
154
+ }
155
+ this.upgrading = false
156
+ this.publish()
157
+ }
158
+
159
+ private publish(): void {
160
+ this.store.set(this.projection())
161
+ }
162
+ }