dsh-smooth-stream 0.1.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,240 @@
1
+ .root {
2
+ display: flex;
3
+ flex-direction: column;
4
+ min-width: 0;
5
+ color: var(--dsw-alias-label-primary);
6
+ font-size: 16px;
7
+ line-height: 28px;
8
+ }
9
+
10
+ .body {
11
+ display: flex;
12
+ flex-direction: column;
13
+ gap: 16px;
14
+ min-width: 0;
15
+ }
16
+
17
+ .think {
18
+ display: flex;
19
+ flex-direction: column;
20
+ }
21
+
22
+ .thinkRow {
23
+ position: relative;
24
+ overflow: hidden;
25
+ }
26
+
27
+ .think[data-state='running'] .thinkRow::after {
28
+ content: '';
29
+ position: absolute;
30
+ inset-block: 0;
31
+ left: 0;
32
+ width: 300px;
33
+ background: linear-gradient(
34
+ 90deg,
35
+ transparent 0%,
36
+ color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
37
+ transparent 100%
38
+ );
39
+ animation: dsh-smooth-stream-think-sweep 2.6s ease-out infinite;
40
+ pointer-events: none;
41
+ }
42
+
43
+ @keyframes dsh-smooth-stream-think-sweep {
44
+ 0% { left: -300px; }
45
+ 90%, 100% { left: 100%; }
46
+ }
47
+
48
+ .thinkLeading {
49
+ flex-shrink: 0;
50
+ }
51
+
52
+ .thinkChevron {
53
+ color: var(--dsw-alias-label-secondary);
54
+ }
55
+
56
+ .thinkTitle {
57
+ font-weight: 400;
58
+ }
59
+
60
+ .thinkSeparator {
61
+ flex: none;
62
+ width: 2px;
63
+ height: 2px;
64
+ margin: 0 8px;
65
+ border-radius: 1px;
66
+ background: var(--dsw-alias-label-caption);
67
+ }
68
+
69
+ .thinkSummary {
70
+ min-width: 0;
71
+ overflow: hidden;
72
+ flex: 1 1 auto;
73
+ color: var(--dsw-alias-label-tertiary);
74
+ font-size: 14px;
75
+ line-height: 24px;
76
+ text-overflow: ellipsis;
77
+ white-space: nowrap;
78
+ }
79
+
80
+ .thinkSummary[data-follow-end] {
81
+ text-overflow: clip;
82
+ }
83
+
84
+ .thinkBody {
85
+ padding: 4px 0 4px 22px;
86
+ color: var(--dsw-alias-label-tertiary);
87
+ font-size: 14px;
88
+ line-height: 24px;
89
+ white-space: pre-wrap;
90
+ word-break: break-word;
91
+ }
92
+
93
+ /*
94
+ * AnimatedDisclosure chrome: mirrors the harness DisclosureRow geometry
95
+ * ([16px leading] gap 6 [title 14/24]) plus the hover icon->chevron preview.
96
+ */
97
+ .disclosureRoot {
98
+ display: flex;
99
+ flex-direction: column;
100
+ width: 100%;
101
+ min-width: 0;
102
+ }
103
+
104
+ .disclosureRow {
105
+ position: relative;
106
+ overflow: hidden;
107
+ display: flex;
108
+ align-items: center;
109
+ height: 24px;
110
+ min-width: 0;
111
+ cursor: pointer;
112
+ }
113
+
114
+ .disclosureLeading {
115
+ position: relative;
116
+ flex: none;
117
+ width: 16px;
118
+ height: 16px;
119
+ display: inline-flex;
120
+ align-items: center;
121
+ justify-content: center;
122
+ margin-right: 6px;
123
+ color: var(--dsw-alias-label-tertiary);
124
+ }
125
+
126
+ .disclosureIconIdle {
127
+ display: inline-flex;
128
+ opacity: 1;
129
+ transition: opacity 100ms ease;
130
+ }
131
+
132
+ .disclosureChevronHover {
133
+ position: absolute;
134
+ inset: 0;
135
+ margin: auto;
136
+ opacity: 0;
137
+ transition: opacity 100ms ease;
138
+ }
139
+
140
+ .disclosureRow:hover .disclosureIconIdle {
141
+ opacity: 0;
142
+ }
143
+
144
+ .disclosureRow:hover .disclosureChevronHover {
145
+ opacity: 1;
146
+ }
147
+
148
+ .disclosureTitle {
149
+ flex: none;
150
+ font-size: 14px;
151
+ line-height: 24px;
152
+ color: var(--dsw-alias-label-secondary);
153
+ }
154
+
155
+ /*
156
+ * The animated body: one grid track transitions 0fr <-> 1fr, so the height
157
+ * glides to the body's natural size with no measured pixel target. The direct
158
+ * child clips to the track; `visibility` (not display) keeps a collapsed body
159
+ * out of the accessibility tree and tab order while it still measures for the
160
+ * animation - it flips visible immediately on expand and waits for the height
161
+ * to collapse before hiding. Rides the deepsuite curve tokens with fallbacks
162
+ * for standalone composition.
163
+ */
164
+ .disclosureContent {
165
+ display: grid;
166
+ grid-template-rows: 1fr;
167
+ visibility: visible;
168
+ transition:
169
+ grid-template-rows var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, cubic-bezier(0.4, 0, 0.2, 1)),
170
+ visibility 0s;
171
+ }
172
+
173
+ .disclosureContent[data-collapsed] {
174
+ grid-template-rows: 0fr;
175
+ visibility: hidden;
176
+ transition:
177
+ grid-template-rows var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, cubic-bezier(0.4, 0, 0.2, 1)),
178
+ visibility 0s var(--ds-transition-duration, 0.2s);
179
+ }
180
+
181
+ .disclosureContent > * {
182
+ min-height: 0;
183
+ overflow: hidden;
184
+ }
185
+
186
+ @media (prefers-reduced-motion: reduce) {
187
+ .think[data-state='running'] .thinkRow::after {
188
+ animation: none;
189
+ }
190
+
191
+ .disclosureContent,
192
+ .disclosureContent[data-collapsed] {
193
+ transition: none;
194
+ }
195
+ }
196
+
197
+ .stopped {
198
+ align-self: flex-start;
199
+ padding: 0 6px;
200
+ border-radius: 6px;
201
+ background: var(--dsw-alias-interactive-bg-hover);
202
+ color: var(--dsw-alias-label-tertiary);
203
+ font-size: 11px;
204
+ line-height: 18px;
205
+ }
206
+
207
+ .visuallyHidden {
208
+ position: absolute;
209
+ overflow: hidden;
210
+ width: 1px;
211
+ height: 1px;
212
+ padding: 0;
213
+ border: 0;
214
+ margin: -1px;
215
+ clip: rect(0 0 0 0);
216
+ clip-path: inset(50%);
217
+ white-space: nowrap;
218
+ }
219
+
220
+ /*
221
+ * Follow host: ordinary document flow. The last text block owns the
222
+ * conversation scrollport's bottom-follow while the reply reveals
223
+ * (see useConversationFollow).
224
+ */
225
+ .follow {
226
+ min-width: 0;
227
+ }
228
+
229
+ /*
230
+ * Trim the half-leading above and below each text block so line boxes hug
231
+ * the font's ascent/descent edges. `text` edges are used instead of
232
+ * `cap alphabetic` because assistant text is CJK-heavy and cap-height
233
+ * trimming would clip ideographic glyphs.
234
+ */
235
+ @supports (text-box-trim: trim-both) {
236
+ .root :is(p, h1, h2, h3, h4, h5, h6, li, blockquote) {
237
+ text-box-trim: trim-both;
238
+ text-box-edge: text;
239
+ }
240
+ }
@@ -0,0 +1,319 @@
1
+ import { memo, useEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'
2
+ import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
3
+ import { ImageGallery, type ImageLoader, type MessageImageLabels } from '@deepseek-ai/dsh-client-ui-attachment'
4
+ import type { ChatNodeViewProps, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
5
+ import { AnimatedDisclosure } from './AnimatedDisclosure.tsx'
6
+ import { useSmoothStreamContent, type StreamSmoothingPreset } from './useSmoothStreamContent.ts'
7
+ import { useFpsGuard } from './useFpsGuard.ts'
8
+ import { FollowHost } from './FollowHost.tsx'
9
+ import { DEFAULT_STREAM_CONFIG, type StreamMode } from '../config.ts'
10
+ import css from './TypewriterAssistantNodeView.module.css'
11
+
12
+ type AssistantProps = ChatNodeViewProps<'assistant-step'>
13
+ type MarkdownProps = Pick<ComponentProps<typeof MarkdownText>, 'codeLabels' | 'fileMentions' | 'text'>
14
+
15
+ function usePrefersReducedMotion(): boolean {
16
+ const [reduced, setReduced] = useState(
17
+ () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true,
18
+ )
19
+ useEffect(() => {
20
+ if (typeof window === 'undefined' || window.matchMedia === undefined) return
21
+ const query = window.matchMedia('(prefers-reduced-motion: reduce)')
22
+ const onChange = () => setReduced(query.matches)
23
+ query.addEventListener('change', onChange)
24
+ return () => query.removeEventListener('change', onChange)
25
+ }, [])
26
+ return reduced
27
+ }
28
+
29
+ interface AnimatedMarkdownTextProps extends MarkdownProps {
30
+ streaming: boolean
31
+ announce: boolean
32
+ /** True on the last text block: that block owns conversation follow. */
33
+ ownFollow: boolean
34
+ preset: StreamSmoothingPreset
35
+ shouldHoldBack: () => boolean
36
+ }
37
+
38
+ /**
39
+ * Smooth streaming text arm. While the reply runs, the accumulated source is
40
+ * revealed through the smoother at a rate that tracks the model's arrival
41
+ * and rendered by the Harness `MarkdownText`
42
+ * streaming arm (incremental parse, frozen non-tail blocks), so there is no
43
+ * raw-text tail and no text-to-markdown swap: the tree stays markdown
44
+ * throughout. The last text block owns conversation-port follow so wraps
45
+ * glide instead of snapping. Once the stream closes and the reveal queue
46
+ * drains, the settled full parse (KaTeX math, fence highlighting, file
47
+ * mentions) swaps in exactly once.
48
+ */
49
+ function AnimatedMarkdownText({
50
+ text,
51
+ codeLabels,
52
+ fileMentions,
53
+ streaming,
54
+ announce,
55
+ ownFollow,
56
+ preset,
57
+ shouldHoldBack,
58
+ }: AnimatedMarkdownTextProps) {
59
+ const reduced = usePrefersReducedMotion()
60
+ const [typing, setTyping] = useState(streaming)
61
+ const speedCpsRef = useRef(35)
62
+ const displayed = useSmoothStreamContent(text, {
63
+ enabled: typing && !reduced,
64
+ preset,
65
+ shouldHoldBack,
66
+ speedCpsRef,
67
+ })
68
+ const shown = reduced ? text : displayed
69
+ const live = typing && !reduced
70
+
71
+ // The stream closed: keep revealing the remaining queue, then swap to the
72
+ // settled parse exactly once. The markdown tree stays mounted until then.
73
+ useEffect(() => {
74
+ if (typing && !streaming && shown.length === text.length) setTyping(false)
75
+ }, [shown, streaming, text, typing])
76
+
77
+ if (live) {
78
+ return (
79
+ <>
80
+ {announce && <span className={css.visuallyHidden} aria-live="polite">{text}</span>}
81
+ <FollowHost active={ownFollow} speedCpsRef={speedCpsRef}>
82
+ <MarkdownText text={shown} streaming codeLabels={codeLabels} />
83
+ </FollowHost>
84
+ </>
85
+ )
86
+ }
87
+ return <MarkdownText text={text} codeLabels={codeLabels} fileMentions={fileMentions} />
88
+ }
89
+
90
+ function imageLabels(t: AssistantProps['t']): MessageImageLabels {
91
+ return {
92
+ image: t('image.label'),
93
+ open: t('image.openOriginal'),
94
+ openNamed: label => t('image.openOriginalLabel', { label }),
95
+ loading: t('image.loading'),
96
+ loadFailed: t('image.loadFailed'),
97
+ lightbox: {
98
+ dialog: t('image.preview'),
99
+ close: t('image.closePreview'),
100
+ },
101
+ }
102
+ }
103
+
104
+ function firstLine(text: string): string {
105
+ const newline = text.indexOf('\n')
106
+ return newline === -1 ? text : text.slice(0, newline)
107
+ }
108
+
109
+ function latestLine(text: string): string {
110
+ const visible = text.trimEnd()
111
+ const newline = visible.lastIndexOf('\n')
112
+ return newline === -1 ? visible : visible.slice(newline + 1)
113
+ }
114
+
115
+ /**
116
+ * Built-in Think disclosure with a smoothed `text` feed. Chevron and row
117
+ * click stay on the disclosure chrome, which the plugin's AnimatedDisclosure
118
+ * renders with a height-animated body (the harness primitive would mount and
119
+ * unmount it, which cannot glide). The row opens only while this block is
120
+ * the streaming tail and closes as soon as thinking ends — a later block,
121
+ * or the assistant node settling — not when the rest of the reply is
122
+ * still streaming.
123
+ */
124
+ function AnimatedReasoning({
125
+ text,
126
+ running,
127
+ preset,
128
+ shouldHoldBack,
129
+ t,
130
+ }: {
131
+ text: string
132
+ running: boolean
133
+ preset: StreamSmoothingPreset
134
+ shouldHoldBack: () => boolean
135
+ t: AssistantProps['t']
136
+ }) {
137
+ const reduced = usePrefersReducedMotion()
138
+ const [expanded, setExpanded] = useState(running)
139
+ const summaryRef = useRef<HTMLSpanElement>(null)
140
+ const speedCpsRef = useRef(35)
141
+ const displayed = useSmoothStreamContent(text, {
142
+ enabled: running && !reduced,
143
+ preset,
144
+ shouldHoldBack,
145
+ speedCpsRef,
146
+ })
147
+ const shown = running && !reduced ? displayed : text
148
+ const summary = running ? latestLine(shown) : firstLine(text)
149
+
150
+ useEffect(() => {
151
+ setExpanded(running)
152
+ }, [running])
153
+
154
+ useEffect(() => {
155
+ const element = summaryRef.current
156
+ if (element === null) return
157
+ element.scrollLeft = running ? element.scrollWidth - element.clientWidth : 0
158
+ }, [running, summary])
159
+
160
+ return (
161
+ <FollowHost active={running} speedCpsRef={speedCpsRef}>
162
+ <div className={css.think} data-variant="think" data-state={running ? 'running' : 'ok'}>
163
+ {running && <span className={css.visuallyHidden}>{t('row.running')}</span>}
164
+ <AnimatedDisclosure
165
+ rowClassName={css.thinkRow}
166
+ leadingClassName={css.thinkLeading}
167
+ titleClassName={css.thinkTitle}
168
+ chevronClassName={css.thinkChevron}
169
+ icon={<IconThinkOutline14 size={14} />}
170
+ title="Think"
171
+ open={expanded}
172
+ onToggle={() => { setExpanded(value => !value) }}
173
+ collapsedContent={(
174
+ <>
175
+ <span className={css.thinkSeparator} aria-hidden />
176
+ <span ref={summaryRef} className={css.thinkSummary} data-follow-end={running || undefined}>{summary}</span>
177
+ </>
178
+ )}
179
+ >
180
+ <div className={css.thinkBody}>{shown}</div>
181
+ </AnimatedDisclosure>
182
+ </div>
183
+ </FollowHost>
184
+ )
185
+ }
186
+
187
+ /**
188
+ * Assistant node renderer for the typewriter overlay. Text observed while
189
+ * streaming is revealed by the smoother through the Harness Markdown
190
+ * renderer at a rate that tracks arrival. Reasoning blocks keep the
191
+ * 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.
196
+ */
197
+ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNodeView({
198
+ mode: _mode = DEFAULT_STREAM_CONFIG.mode,
199
+ preset = DEFAULT_STREAM_CONFIG.preset,
200
+ revealCharsPerSec: _revealCharsPerSec = DEFAULT_STREAM_CONFIG.revealCharsPerSec,
201
+ scrollSpeedPxPerSec: _scrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.scrollSpeedPxPerSec,
202
+ maxScrollSpeedPxPerSec: _maxScrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec,
203
+ node,
204
+ useTurnData,
205
+ openFile,
206
+ loadImage,
207
+ fileMentions,
208
+ t,
209
+ }: AssistantProps & {
210
+ mode?: StreamMode
211
+ preset?: StreamSmoothingPreset
212
+ revealCharsPerSec?: number
213
+ scrollSpeedPxPerSec?: number
214
+ maxScrollSpeedPxPerSec?: number
215
+ }) {
216
+ const data = node.data
217
+ const streaming = data.status === 'running'
218
+ const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming)
219
+ const rootSpeedRef = useRef(35)
220
+ const turn = node.location.kind === 'turn' || node.location.kind === 'step'
221
+ ? node.location.turn
222
+ : undefined
223
+ const tail = useTurnData('turn-tail')
224
+ const owner = useMemo<TurnTailOwnerProps | undefined>(() => {
225
+ if (turn?.status !== 'closed' || data.finalNode === undefined) return undefined
226
+ if (tail?.closing?.finalNode.seq !== data.finalNode.seq) return undefined
227
+ return { turn, seq: data.finalNode.seq, openFile }
228
+ }, [data.finalNode, openFile, tail, turn])
229
+ const mentions = useMemo(
230
+ () => owner === undefined ? undefined : fileMentions(owner),
231
+ [fileMentions, owner],
232
+ )
233
+ const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
234
+ const imageLoader: ImageLoader = loadImage ?? (async () => {
235
+ throw new Error(t('image.serviceUnavailable'))
236
+ })
237
+ const hasVisible = streaming
238
+ || data.status === 'interrupted'
239
+ || data.blocks.some(block => block.kind !== 'tool-call')
240
+ if (!hasVisible) return null
241
+
242
+ const rendered: ReactNode[] = []
243
+ const last = data.blocks.length - 1
244
+ let lastFollow = -1
245
+ for (let index = 0; index < data.blocks.length; index += 1) {
246
+ const kind = data.blocks[index]?.kind
247
+ if (kind === 'text' || kind === 'reasoning') lastFollow = index
248
+ }
249
+ for (let index = 0; index < data.blocks.length; index += 1) {
250
+ const block = data.blocks[index]
251
+ if (block === undefined) continue
252
+ switch (block.kind) {
253
+ case 'text':
254
+ rendered.push(
255
+ <AnimatedMarkdownText
256
+ key={index}
257
+ text={block.text}
258
+ codeLabels={codeLabels}
259
+ fileMentions={mentions}
260
+ streaming={streaming}
261
+ announce={index === last}
262
+ ownFollow={!streaming && index === lastFollow}
263
+ preset={preset}
264
+ shouldHoldBack={shouldHoldBack}
265
+ />,
266
+ )
267
+ break
268
+ case 'reasoning':
269
+ rendered.push(
270
+ <AnimatedReasoning
271
+ key={index}
272
+ text={block.text}
273
+ running={streaming && index === last}
274
+ preset={preset}
275
+ shouldHoldBack={shouldHoldBack}
276
+ t={t}
277
+ />,
278
+ )
279
+ break
280
+ case 'image': {
281
+ const start = index
282
+ const group = [block]
283
+ while (index + 1 < data.blocks.length) {
284
+ const next = data.blocks[index + 1]
285
+ if (next === undefined || next.kind !== 'image') break
286
+ group.push(next)
287
+ index += 1
288
+ }
289
+ rendered.push(
290
+ <ImageGallery key={start} images={group} load={imageLoader} align="start" labels={imageLabels(t)} />,
291
+ )
292
+ break
293
+ }
294
+ case 'tool-call':
295
+ break
296
+ case 'other':
297
+ rendered.push(
298
+ <JsonBlock
299
+ key={index}
300
+ label={t('message.unknownBlock')}
301
+ payload={block.block}
302
+ truncatedLabel={total => t('json.truncated', { total })}
303
+ />,
304
+ )
305
+ break
306
+ }
307
+ }
308
+
309
+ return (
310
+ <div ref={guardRef} className={css.root} data-streaming={streaming || undefined}>
311
+ <FollowHost active={streaming} speedCpsRef={rootSpeedRef}>
312
+ <div className={css.body}>
313
+ {rendered}
314
+ {data.status === 'interrupted' && <span className={css.stopped}>{t('message.stopped')}</span>}
315
+ </div>
316
+ </FollowHost>
317
+ </div>
318
+ )
319
+ })
@@ -0,0 +1,54 @@
1
+ import { createElement, useRef, type ComponentType } from 'react'
2
+ import { FollowHost } from './FollowHost.tsx'
3
+
4
+ /** Props forwarded through a follow wrap; extra kit seats pass through. */
5
+ export type FollowWrapProps = {
6
+ node?: unknown
7
+ renderSlot?: unknown
8
+ } & Record<string, unknown>
9
+
10
+ /**
11
+ * True while a Chat node is still growing: an assistant/workflow `status:
12
+ * 'running'` payload, a Tool root that has not settled (`kind` absent), or a
13
+ * model-retry whose current attempt is still `scheduled`.
14
+ * @param node - The Chat node's view `node` prop.
15
+ * @returns whether this row should own conversation follow.
16
+ */
17
+ export function isGrowingChatNode(node: unknown): boolean {
18
+ if (node === null || typeof node !== 'object' || !('data' in node)) return false
19
+ const data = (node as { data: unknown }).data
20
+ if (data === null || typeof data !== 'object') return false
21
+ if ('status' in data && (data as { status: unknown }).status === 'running') return true
22
+ if ('root' in data) {
23
+ const root = (data as { root: unknown }).root
24
+ if (root !== null && typeof root === 'object' && !('kind' in root)) return true
25
+ }
26
+ if ('current' in data) {
27
+ const current = (data as { current: unknown }).current
28
+ if (
29
+ current !== null
30
+ && typeof current === 'object'
31
+ && 'retryState' in current
32
+ && (current as { retryState: unknown }).retryState === 'scheduled'
33
+ ) return true
34
+ }
35
+ return false
36
+ }
37
+
38
+ /**
39
+ * Wrap a prior Chat node renderer so a growing row shares conversation
40
+ * follow. Presentation stays with the wrapped component; kit seats
41
+ * (`renderSlot`, locale, inject) pass through unchanged.
42
+ * @param Inner - The already-registered row component.
43
+ * @returns A follow-hosted row.
44
+ */
45
+ export function wrapFollowNodeView(Inner: ComponentType<FollowWrapProps>) {
46
+ return function TypewriterFollowNodeView(props: FollowWrapProps) {
47
+ const speedCpsRef = useRef(35)
48
+ return (
49
+ <FollowHost active={isGrowingChatNode(props.node)} speedCpsRef={speedCpsRef}>
50
+ {createElement(Inner, props)}
51
+ </FollowHost>
52
+ )
53
+ }
54
+ }