@jcy2387/dsh-conversation-ui 0.1.0-rc.12

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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +123 -0
  3. package/README.md +123 -0
  4. package/conversation-ui-off.yml +3 -0
  5. package/cordis.patch.yml +10 -0
  6. package/lib/client.js +3672 -0
  7. package/lib/client.js.map +1 -0
  8. package/lib/index.js +302 -0
  9. package/package.json +182 -0
  10. package/src/boot-config.ts +28 -0
  11. package/src/client/AnimatedDisclosure.tsx +89 -0
  12. package/src/client/ConversationCard.module.css +253 -0
  13. package/src/client/ConversationCard.tsx +122 -0
  14. package/src/client/DeliverablesCard.module.css +187 -0
  15. package/src/client/DeliverablesCard.tsx +142 -0
  16. package/src/client/FollowHost.tsx +25 -0
  17. package/src/client/ToolActivityGroup.tsx +341 -0
  18. package/src/client/TurnPreludeUserNodeView.tsx +258 -0
  19. package/src/client/TurnProcessMember.tsx +92 -0
  20. package/src/client/TypewriterAssistantNodeView.module.css +1260 -0
  21. package/src/client/TypewriterAssistantNodeView.tsx +390 -0
  22. package/src/client/TypewriterToolNodeView.tsx +916 -0
  23. package/src/client/conversation-ui-card-controller.ts +162 -0
  24. package/src/client/conversation-ui-settings-api.ts +65 -0
  25. package/src/client/deliverables.ts +263 -0
  26. package/src/client/index.ts +242 -0
  27. package/src/client/locales.ts +74 -0
  28. package/src/client/teleprompterGlide.ts +590 -0
  29. package/src/client/turnElapsed.ts +27 -0
  30. package/src/client/turnProcessFold.ts +224 -0
  31. package/src/client/useConversationContent.ts +471 -0
  32. package/src/client/useFpsGuard.ts +84 -0
  33. package/src/config.ts +44 -0
  34. package/src/css-modules.d.ts +9 -0
  35. package/src/index.ts +2 -0
  36. package/src/package-meta.ts +39 -0
  37. package/src/plugin.ts +155 -0
  38. package/src/profile-installation.ts +121 -0
  39. package/src/settings-api.ts +33 -0
  40. package/src/settings.ts +30 -0
@@ -0,0 +1,390 @@
1
+ import { memo, useEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'
2
+ import {
3
+ JsonBlock,
4
+ MarkdownText,
5
+ } from '@deepseek-ai/dsh-client-ui-primitives'
6
+ import type { ChatNodeViewProps, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
7
+ import { useConversationContent, type ConversationSmoothingPreset } from './useConversationContent.ts'
8
+ import { useFpsGuard } from './useFpsGuard.ts'
9
+ import { FollowHost } from './FollowHost.tsx'
10
+ import { DEFAULT_CONVERSATION_CONFIG, type ConversationMode } from '../config.ts'
11
+ import { turnProcessMemberId, useTurnProcessFold } from './turnProcessFold.ts'
12
+ import { TurnProcessMember } from './TurnProcessMember.tsx'
13
+ import { formatTurnElapsed, formatTurnProcessed } from './turnElapsed.ts'
14
+ import css from './TypewriterAssistantNodeView.module.css'
15
+
16
+ type AssistantProps = ChatNodeViewProps<'assistant-step'>
17
+ type MarkdownProps = Pick<ComponentProps<typeof MarkdownText>, 'codeLabels' | 'fileMentions' | 'text'>
18
+
19
+ function usePrefersReducedMotion(): boolean {
20
+ const [reduced, setReduced] = useState(
21
+ () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true,
22
+ )
23
+ useEffect(() => {
24
+ if (typeof window === 'undefined' || window.matchMedia === undefined) return
25
+ const query = window.matchMedia('(prefers-reduced-motion: reduce)')
26
+ const onChange = () => setReduced(query.matches)
27
+ query.addEventListener('change', onChange)
28
+ return () => query.removeEventListener('change', onChange)
29
+ }, [])
30
+ return reduced
31
+ }
32
+
33
+ interface AnimatedMarkdownTextProps extends MarkdownProps {
34
+ streaming: boolean
35
+ announce: boolean
36
+ /** True on the last text block: that block owns conversation follow. */
37
+ ownFollow: boolean
38
+ mode: ConversationMode
39
+ preset: ConversationSmoothingPreset
40
+ revealCharsPerSec: number
41
+ scrollSpeedPxPerSec: number
42
+ maxScrollSpeedPxPerSec: number
43
+ shouldHoldBack: () => boolean
44
+ }
45
+
46
+ /**
47
+ * Smooth streaming text arm. While the reply runs, the accumulated source is
48
+ * revealed through the smoother at a rate that tracks the model's arrival
49
+ * and rendered by the Harness `MarkdownText`
50
+ * streaming arm (incremental parse, frozen non-tail blocks), so there is no
51
+ * raw-text tail and no text-to-markdown swap: the tree stays markdown
52
+ * throughout. The last text block owns conversation-port follow so wraps
53
+ * glide instead of snapping. Once the stream closes and the reveal queue
54
+ * drains, the settled full parse (KaTeX math, fence highlighting, file
55
+ * mentions) swaps in exactly once.
56
+ */
57
+ function AnimatedMarkdownText({
58
+ text,
59
+ codeLabels,
60
+ fileMentions,
61
+ streaming,
62
+ announce,
63
+ ownFollow,
64
+ mode,
65
+ preset,
66
+ revealCharsPerSec,
67
+ scrollSpeedPxPerSec,
68
+ maxScrollSpeedPxPerSec,
69
+ shouldHoldBack,
70
+ }: AnimatedMarkdownTextProps) {
71
+ const reduced = usePrefersReducedMotion()
72
+ const [typing, setTyping] = useState(streaming)
73
+ const speedCpsRef = useRef(35)
74
+ const displayed = useConversationContent(text, {
75
+ enabled: mode === 'typewriter' && typing && !reduced,
76
+ preset,
77
+ shouldHoldBack,
78
+ steadyCps: mode === 'typewriter' ? revealCharsPerSec : undefined,
79
+ speedCpsRef,
80
+ })
81
+ const shown = reduced || mode === 'teleprompter' ? text : displayed
82
+ const live = typing && !reduced
83
+
84
+ // The stream closed: keep revealing the remaining queue, then swap to the
85
+ // settled parse exactly once. The markdown tree stays mounted until then.
86
+ useEffect(() => {
87
+ if (typing && !streaming && shown.length === text.length) setTyping(false)
88
+ }, [shown, streaming, text, typing])
89
+
90
+ if (live) {
91
+ return (
92
+ <>
93
+ {announce && <span className={css.visuallyHidden} aria-live="polite">{text}</span>}
94
+ <FollowHost
95
+ active={ownFollow}
96
+ speedCpsRef={speedCpsRef}
97
+ minSpeedPxPerSec={scrollSpeedPxPerSec}
98
+ maxSpeedPxPerSec={maxScrollSpeedPxPerSec}
99
+ >
100
+ <div className={css.markdownFlow}>
101
+ <MarkdownText text={shown} streaming codeLabels={codeLabels} />
102
+ </div>
103
+ </FollowHost>
104
+ </>
105
+ )
106
+ }
107
+ return (
108
+ <div className={css.markdownFlow}>
109
+ <MarkdownText text={text} codeLabels={codeLabels} fileMentions={fileMentions} />
110
+ </div>
111
+ )
112
+ }
113
+
114
+ /**
115
+ * Think is part of the Turn document flow rather than a second disclosure
116
+ * card. It uses the same streaming smoother as ordinary assistant prose, so
117
+ * reasoning reads as a continuous body while the surrounding Turn still owns
118
+ * process folding and viewport follow.
119
+ */
120
+ function AnimatedReasoning({
121
+ text,
122
+ running,
123
+ mode,
124
+ preset,
125
+ revealCharsPerSec,
126
+ shouldHoldBack,
127
+ }: {
128
+ text: string
129
+ running: boolean
130
+ mode: ConversationMode
131
+ preset: ConversationSmoothingPreset
132
+ revealCharsPerSec: number
133
+ shouldHoldBack: () => boolean
134
+ }) {
135
+ const reduced = usePrefersReducedMotion()
136
+ const [typing, setTyping] = useState(running)
137
+ const speedCpsRef = useRef(35)
138
+ const displayed = useConversationContent(text, {
139
+ enabled: mode === 'typewriter' && typing && !reduced,
140
+ preset,
141
+ shouldHoldBack,
142
+ steadyCps: mode === 'typewriter' ? revealCharsPerSec : undefined,
143
+ speedCpsRef,
144
+ })
145
+ const shown = reduced || mode === 'teleprompter' ? text : displayed
146
+ useEffect(() => {
147
+ if (typing && !running && shown.length === text.length) setTyping(false)
148
+ }, [shown, running, text, typing])
149
+ const live = typing && !reduced
150
+ return (
151
+ <div
152
+ className={css.reasoningText}
153
+ data-stream-node="reasoning"
154
+ data-stream-state={running ? 'running' : 'settled'}
155
+ data-stream-reasoning=""
156
+ >
157
+ {live ? <MarkdownText text={shown} streaming /> : <MarkdownText text={text} />}
158
+ </div>
159
+ )
160
+ }
161
+
162
+ /**
163
+ * Assistant renderer for the Codex-style event stream. Teleprompter mode
164
+ * publishes each latest model snapshot immediately; typewriter mode reveals
165
+ * complete grapheme clusters at the configured rate. Reasoning stays in the
166
+ * same document flow as ordinary prose, and the final Assistant message is marked separately
167
+ * from intermediate process updates. The FPS guard holds offscreen typewriter
168
+ * commits when the frame rate is degraded. Settled text uses the full Markdown
169
+ * pipeline.
170
+ */
171
+ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNodeView({
172
+ mode = DEFAULT_CONVERSATION_CONFIG.mode,
173
+ preset = DEFAULT_CONVERSATION_CONFIG.preset,
174
+ revealCharsPerSec = DEFAULT_CONVERSATION_CONFIG.revealCharsPerSec,
175
+ scrollSpeedPxPerSec = DEFAULT_CONVERSATION_CONFIG.scrollSpeedPxPerSec,
176
+ maxScrollSpeedPxPerSec = DEFAULT_CONVERSATION_CONFIG.maxScrollSpeedPxPerSec,
177
+ node,
178
+ sessionId,
179
+ useTurnData,
180
+ openFile,
181
+ renderMessageImages,
182
+ fileMentions,
183
+ t,
184
+ }: AssistantProps & {
185
+ mode?: ConversationMode
186
+ preset?: ConversationSmoothingPreset
187
+ revealCharsPerSec?: number
188
+ scrollSpeedPxPerSec?: number
189
+ maxScrollSpeedPxPerSec?: number
190
+ thinkAutoExpand?: boolean
191
+ }) {
192
+ const data = node.data
193
+ const streaming = data.status === 'running'
194
+ const reducedMotion = usePrefersReducedMotion()
195
+ const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming)
196
+ const rootSpeedRef = useRef(35)
197
+ const turn = node.location.kind === 'turn' || node.location.kind === 'step'
198
+ ? node.location.turn
199
+ : undefined
200
+ const tail = useTurnData('turn-tail')
201
+ const finalAnswer = data.status === 'settled'
202
+ && data.finalNode !== undefined
203
+ && tail?.closing?.finalNode.seq === data.finalNode.seq
204
+ const phase = streaming
205
+ ? 'streaming'
206
+ : data.status === 'interrupted' ? 'interrupted' : finalAnswer ? 'final' : 'process'
207
+ const owner = useMemo<TurnTailOwnerProps | undefined>(() => {
208
+ if (turn?.status !== 'closed' || data.finalNode === undefined) return undefined
209
+ if (tail?.closing?.finalNode.seq !== data.finalNode.seq) return undefined
210
+ return { turn, seq: data.finalNode.seq, openFile }
211
+ }, [data.finalNode, openFile, tail, turn])
212
+ const mentions = useMemo(
213
+ () => owner === undefined ? undefined : fileMentions(owner),
214
+ [fileMentions, owner],
215
+ )
216
+ const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
217
+ const hasVisible = streaming
218
+ || data.status === 'interrupted'
219
+ || data.blocks.some(block => block.kind !== 'tool-call')
220
+ const address = turn === undefined ? undefined : { sessionId, turn: turn.turn }
221
+ const processMemberId = (phase === 'streaming' || phase === 'process') && address !== undefined && hasVisible
222
+ ? turnProcessMemberId(address, node.key)
223
+ : undefined
224
+ const successfulFinal = finalAnswer
225
+ && turn?.status === 'closed'
226
+ && turn.start !== undefined
227
+ && turn.end !== undefined
228
+ && turn.end.data.reason.kind === 'completed'
229
+ const completedLabel = successfulFinal && turn?.start !== undefined && turn.end !== undefined
230
+ ? formatTurnElapsed(turn.end.time - turn.start.time, t)
231
+ : undefined
232
+ const fold = useTurnProcessFold(address, {
233
+ completedLabel,
234
+ })
235
+ const memberOrder = typeof node.anchorSeq === 'number'
236
+ ? node.anchorSeq
237
+ : data.finalNode?.seq ?? 0
238
+ const formatRunningElapsed = useMemo(
239
+ () => (ms: number) => formatTurnProcessed(ms, t),
240
+ [t],
241
+ )
242
+ if (!hasVisible) return null
243
+
244
+ const rendered: ReactNode[] = []
245
+ const last = data.blocks.length - 1
246
+ let lastFollow = -1
247
+ for (let index = 0; index < data.blocks.length; index += 1) {
248
+ const kind = data.blocks[index]?.kind
249
+ if (kind === 'text' || kind === 'reasoning') lastFollow = index
250
+ }
251
+ for (let index = 0; index < data.blocks.length; index += 1) {
252
+ const block = data.blocks[index]
253
+ if (block === undefined) continue
254
+ switch (block.kind) {
255
+ case 'text':
256
+ rendered.push(
257
+ <AnimatedMarkdownText
258
+ key={index}
259
+ text={block.text}
260
+ codeLabels={codeLabels}
261
+ fileMentions={mentions}
262
+ streaming={streaming}
263
+ announce={index === last}
264
+ ownFollow={!streaming && index === lastFollow}
265
+ mode={mode}
266
+ preset={preset}
267
+ revealCharsPerSec={revealCharsPerSec}
268
+ scrollSpeedPxPerSec={scrollSpeedPxPerSec}
269
+ maxScrollSpeedPxPerSec={maxScrollSpeedPxPerSec}
270
+ shouldHoldBack={shouldHoldBack}
271
+ />,
272
+ )
273
+ break
274
+ case 'reasoning':
275
+ {
276
+ const reasoning = (
277
+ <AnimatedReasoning
278
+ key={index}
279
+ text={block.text}
280
+ running={streaming && index === last}
281
+ mode={mode}
282
+ preset={preset}
283
+ revealCharsPerSec={revealCharsPerSec}
284
+ shouldHoldBack={shouldHoldBack}
285
+ />
286
+ )
287
+ // A closing assistant node is intentionally kept visible so its
288
+ // final answer cannot disappear with the completed Turn fold. Its
289
+ // private reasoning is still process content, however, and must be
290
+ // registered as a fold member rather than left in the final prose.
291
+ if (finalAnswer && address !== undefined) {
292
+ rendered.push(
293
+ <TurnProcessMember
294
+ key={index}
295
+ address={address}
296
+ memberId={turnProcessMemberId(address, `${node.key}:reasoning:${index}`)}
297
+ // Keep final-node reasoning after any earlier process member;
298
+ // otherwise equal synthetic seqs could move the disclosure
299
+ // button into the final-answer row.
300
+ memberOrder={memberOrder + 0.5 + index}
301
+ >
302
+ {reasoning}
303
+ </TurnProcessMember>,
304
+ )
305
+ } else {
306
+ rendered.push(reasoning)
307
+ }
308
+ }
309
+ break
310
+ case 'image': {
311
+ const start = index
312
+ const group = [block]
313
+ while (index + 1 < data.blocks.length) {
314
+ const next = data.blocks[index + 1]
315
+ if (next === undefined || next.kind !== 'image') break
316
+ group.push(next)
317
+ index += 1
318
+ }
319
+ rendered.push(
320
+ <span key={start}>{renderMessageImages({ images: group, align: 'start' })}</span>,
321
+ )
322
+ break
323
+ }
324
+ case 'tool-call':
325
+ break
326
+ case 'other':
327
+ rendered.push(
328
+ <JsonBlock
329
+ key={index}
330
+ label={t('message.unknownBlock')}
331
+ payload={block.block}
332
+ truncatedLabel={total => t('json.truncated', { total })}
333
+ />,
334
+ )
335
+ break
336
+ }
337
+ }
338
+
339
+ const content = (
340
+ <>
341
+ {successfulFinal && fold.controls === '' && completedLabel !== undefined && (
342
+ <div className={css.turnFoldRow} data-turn-fold-row="">
343
+ <span className={css.turnFoldLabel}>{completedLabel}</span>
344
+ </div>
345
+ )}
346
+ <FollowHost
347
+ active={streaming && !reducedMotion}
348
+ speedCpsRef={rootSpeedRef}
349
+ minSpeedPxPerSec={scrollSpeedPxPerSec}
350
+ maxSpeedPxPerSec={maxScrollSpeedPxPerSec}
351
+ >
352
+ <div className={css.body}>
353
+ {rendered}
354
+ {data.status === 'interrupted' && <span className={css.stopped}>{t('message.stopped')}</span>}
355
+ </div>
356
+ </FollowHost>
357
+ </>
358
+ )
359
+
360
+ if (processMemberId !== undefined && address !== undefined) {
361
+ return (
362
+ <TurnProcessMember
363
+ address={address}
364
+ memberId={processMemberId}
365
+ memberOrder={memberOrder}
366
+ turnStartTime={turn?.start?.time}
367
+ formatRunningElapsed={formatRunningElapsed}
368
+ memberRef={guardRef}
369
+ className={css.root}
370
+ data-stream-node="assistant-step"
371
+ data-stream-phase={phase}
372
+ data-streaming={streaming || undefined}
373
+ >
374
+ {content}
375
+ </TurnProcessMember>
376
+ )
377
+ }
378
+
379
+ return (
380
+ <div
381
+ ref={guardRef}
382
+ className={css.root}
383
+ data-stream-node="assistant-step"
384
+ data-stream-phase={phase}
385
+ data-streaming={streaming || undefined}
386
+ >
387
+ {content}
388
+ </div>
389
+ )
390
+ })