@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,25 @@
1
+ import { useRef, type ReactNode } from 'react'
2
+ import { useConversationFollow } from './teleprompterGlide.ts'
3
+ import css from './TypewriterAssistantNodeView.module.css'
4
+
5
+ /**
6
+ * Document-flow host that owns conversation-port follow while `active`.
7
+ * Shared by assistant blocks and every other growing Chat row.
8
+ */
9
+ export function FollowHost({
10
+ active,
11
+ speedCpsRef,
12
+ minSpeedPxPerSec,
13
+ maxSpeedPxPerSec,
14
+ children,
15
+ }: {
16
+ active: boolean
17
+ speedCpsRef: { current: number }
18
+ minSpeedPxPerSec?: number | undefined
19
+ maxSpeedPxPerSec?: number | undefined
20
+ children: ReactNode
21
+ }) {
22
+ const rootRef = useRef<HTMLDivElement>(null)
23
+ useConversationFollow(rootRef, active, speedCpsRef, minSpeedPxPerSec, maxSpeedPxPerSec)
24
+ return <div ref={rootRef} className={css.follow}>{children}</div>
25
+ }
@@ -0,0 +1,341 @@
1
+ import { createPortal } from 'react-dom'
2
+ import {
3
+ useCallback,
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useSyncExternalStore,
8
+ type ReactNode,
9
+ type Ref,
10
+ } from 'react'
11
+ import {
12
+ IconChevronDownOutline14,
13
+ IconChevronRightOutline14,
14
+ } from '@deepseek-ai/dsh-client-ui-primitives'
15
+ import {
16
+ useTurnProcessFold,
17
+ type TurnFoldAddress,
18
+ } from './turnProcessFold.ts'
19
+ import { TurnProcessMember } from './TurnProcessMember.tsx'
20
+ import css from './TypewriterAssistantNodeView.module.css'
21
+
22
+ export type ToolActivityGroupPosition = 'start' | 'middle' | 'end'
23
+
24
+ export interface ToolActivityGroupInfo {
25
+ readonly id: string
26
+ readonly domId: string
27
+ readonly semantic: string
28
+ readonly label: string
29
+ readonly count: number
30
+ readonly position: ToolActivityGroupPosition
31
+ readonly hasGrowing: boolean
32
+ }
33
+
34
+ interface ToolActivityGroupState {
35
+ collapsed: boolean
36
+ readonly listeners: Set<() => void>
37
+ }
38
+
39
+ interface ToolActivityMount {
40
+ target: HTMLElement | null
41
+ readonly listeners: Set<() => void>
42
+ }
43
+
44
+ const groupStates = new Map<string, ToolActivityGroupState>()
45
+ const groupMounts = new Map<string, ToolActivityMount>()
46
+
47
+ function groupState(id: string): ToolActivityGroupState {
48
+ const current = groupStates.get(id)
49
+ if (current !== undefined) return current
50
+ const created: ToolActivityGroupState = { collapsed: false, listeners: new Set() }
51
+ groupStates.set(id, created)
52
+ return created
53
+ }
54
+
55
+ function groupMount(id: string): ToolActivityMount {
56
+ const current = groupMounts.get(id)
57
+ if (current !== undefined) return current
58
+ const created: ToolActivityMount = { target: null, listeners: new Set() }
59
+ groupMounts.set(id, created)
60
+ return created
61
+ }
62
+
63
+ function notify(listeners: Set<() => void>): void {
64
+ for (const listener of listeners) listener()
65
+ }
66
+
67
+ function subscribeGroup(id: string, listener: () => void): () => void {
68
+ const state = groupState(id)
69
+ state.listeners.add(listener)
70
+ return () => {
71
+ state.listeners.delete(listener)
72
+ if (state.listeners.size === 0) groupStates.delete(id)
73
+ }
74
+ }
75
+
76
+ function subscribeMount(id: string, listener: () => void): () => void {
77
+ const mount = groupMount(id)
78
+ mount.listeners.add(listener)
79
+ return () => {
80
+ mount.listeners.delete(listener)
81
+ if (mount.listeners.size === 0 && mount.target === null) groupMounts.delete(id)
82
+ }
83
+ }
84
+
85
+ function setMount(id: string, target: HTMLElement | null): void {
86
+ const mount = groupMount(id)
87
+ if (mount.target === target) return
88
+ mount.target = target
89
+ notify(mount.listeners)
90
+ }
91
+
92
+ function hash(value: string): string {
93
+ let result = 2_166_136_261
94
+ for (let index = 0; index < value.length; index += 1) {
95
+ result ^= value.charCodeAt(index)
96
+ result = Math.imul(result, 16_777_619)
97
+ }
98
+ return (result >>> 0).toString(36)
99
+ }
100
+
101
+ /** Stable identity for one contiguous semantic tool group in a Turn. */
102
+ export function toolActivityGroupId(
103
+ sessionId: string,
104
+ turn: number,
105
+ semantic: string,
106
+ firstNodeKey: string,
107
+ ): string {
108
+ return `dsh-tool-group-${hash(`${sessionId}\u0000${turn}\u0000${semantic}\u0000${firstNodeKey}`)}`
109
+ }
110
+
111
+ function useToolActivityFold(id: string): { collapsed: boolean; toggle: () => void; collapse: () => void } {
112
+ const subscribe = useCallback((listener: () => void) => subscribeGroup(id, listener), [id])
113
+ const collapsed = useSyncExternalStore(
114
+ subscribe,
115
+ () => groupState(id).collapsed,
116
+ () => false,
117
+ )
118
+ const toggle = useCallback(() => {
119
+ const state = groupState(id)
120
+ state.collapsed = !state.collapsed
121
+ notify(state.listeners)
122
+ }, [id])
123
+ const collapse = useCallback(() => {
124
+ const state = groupState(id)
125
+ if (state.collapsed) return
126
+ state.collapsed = true
127
+ notify(state.listeners)
128
+ }, [id])
129
+ return { collapsed, toggle, collapse }
130
+ }
131
+
132
+ function useToolActivityMount(id: string): HTMLElement | null {
133
+ const subscribe = useCallback((listener: () => void) => subscribeMount(id, listener), [id])
134
+ return useSyncExternalStore(
135
+ subscribe,
136
+ () => groupMount(id).target,
137
+ () => null,
138
+ )
139
+ }
140
+
141
+ interface ScrollPositionSnapshot {
142
+ readonly port: HTMLElement
143
+ readonly top: number
144
+ readonly bottomGap: number
145
+ }
146
+
147
+ function restoreScrollPosition(snapshot: ScrollPositionSnapshot): void {
148
+ const floor = Math.max(0, snapshot.port.scrollHeight - snapshot.port.clientHeight)
149
+ const target = snapshot.bottomGap <= 25
150
+ ? floor
151
+ : Math.min(snapshot.top, floor)
152
+ snapshot.port.scrollTop = target
153
+ }
154
+
155
+ function ToolActivityGroupFrame({
156
+ group,
157
+ collapsed,
158
+ toggle,
159
+ header,
160
+ children,
161
+ }: {
162
+ readonly group: ToolActivityGroupInfo
163
+ readonly collapsed: boolean
164
+ readonly toggle: () => void
165
+ readonly header: ReactNode
166
+ readonly children: ReactNode
167
+ }) {
168
+ const frameRef = useRef<HTMLDivElement | null>(null)
169
+ const pendingScroll = useRef<ScrollPositionSnapshot | null>(null)
170
+ const register = useCallback((element: HTMLDivElement | null) => {
171
+ frameRef.current = element
172
+ setMount(group.id, element)
173
+ }, [group.id])
174
+ const toggleWithScroll = useCallback(() => {
175
+ const port = frameRef.current?.closest<HTMLElement>('[data-conversation-scroll]')
176
+ if (port !== null && port !== undefined) {
177
+ const floor = Math.max(0, port.scrollHeight - port.clientHeight)
178
+ pendingScroll.current = {
179
+ port,
180
+ top: port.scrollTop,
181
+ bottomGap: floor - port.scrollTop,
182
+ }
183
+ }
184
+ toggle()
185
+ }, [toggle])
186
+ useLayoutEffect(() => {
187
+ const snapshot = pendingScroll.current
188
+ if (snapshot === null) return
189
+ pendingScroll.current = null
190
+ restoreScrollPosition(snapshot)
191
+ if (typeof window === 'undefined') return
192
+ const frame = window.requestAnimationFrame(() => restoreScrollPosition(snapshot))
193
+ return () => window.cancelAnimationFrame(frame)
194
+ }, [collapsed])
195
+ return (
196
+ <div
197
+ ref={register}
198
+ id={group.domId}
199
+ className={css.toolGroupFrame}
200
+ data-stream-tool-group={group.semantic}
201
+ data-stream-tool-group-frame=""
202
+ data-stream-tool-group-position={group.position}
203
+ data-stream-tool-group-count={group.count}
204
+ data-stream-tool-group-collapsed={collapsed || undefined}
205
+ >
206
+ <button
207
+ type="button"
208
+ className={css.toolGroupHeader}
209
+ aria-expanded={!collapsed}
210
+ aria-controls={group.domId}
211
+ data-stream-tool-group-toggle=""
212
+ onClick={toggleWithScroll}
213
+ >
214
+ {header}
215
+ <span className={css.toolGroupHeaderChevron} aria-hidden>
216
+ {collapsed ? <IconChevronRightOutline14 size={14} /> : <IconChevronDownOutline14 size={14} />}
217
+ </span>
218
+ </button>
219
+ {children}
220
+ </div>
221
+ )
222
+ }
223
+
224
+ interface ToolActivityGroupMemberProps {
225
+ readonly group: ToolActivityGroupInfo
226
+ readonly address: TurnFoldAddress
227
+ readonly memberId: string
228
+ readonly memberOrder: number
229
+ readonly turnStartTime?: number | undefined
230
+ readonly formatRunningElapsed?: ((ms: number) => string) | undefined
231
+ readonly memberRef?: Ref<HTMLDivElement> | undefined
232
+ readonly eventAttributes?: Readonly<Record<string, string | undefined>>
233
+ readonly header: ReactNode
234
+ readonly children: ReactNode
235
+ }
236
+
237
+ /**
238
+ * Mount the first member as a group host and portal subsequent tool members
239
+ * into the same host. The Turn fold remains shared with ordinary process rows,
240
+ * while a contiguous run of commands gets one Codex-style disclosure block.
241
+ */
242
+ export function ToolActivityGroupMember({
243
+ group,
244
+ address,
245
+ memberId,
246
+ memberOrder,
247
+ turnStartTime,
248
+ formatRunningElapsed,
249
+ memberRef,
250
+ eventAttributes,
251
+ header,
252
+ children,
253
+ }: ToolActivityGroupMemberProps) {
254
+ const fold = useTurnProcessFold(address, { memberId, memberOrder })
255
+ const activity = useToolActivityFold(group.id)
256
+ const mount = useToolActivityMount(group.id)
257
+
258
+ // Codex leaves a running tool group open while work is arriving, then folds
259
+ // the completed batch automatically. A manual toggle after completion still
260
+ // wins because this effect only reacts to the growing -> settled transition.
261
+ useEffect(() => {
262
+ if (group.position === 'start' && !group.hasGrowing) activity.collapse()
263
+ }, [activity.collapse, group.hasGrowing, group.position])
264
+
265
+ if (group.position === 'start') {
266
+ return (
267
+ <TurnProcessMember
268
+ address={address}
269
+ memberId={memberId}
270
+ memberOrder={memberOrder}
271
+ turnStartTime={turnStartTime}
272
+ formatRunningElapsed={formatRunningElapsed}
273
+ className={css.toolGroupAnchor}
274
+ data-stream-tool-group-anchor=""
275
+ >
276
+ <ToolActivityGroupFrame
277
+ group={group}
278
+ collapsed={activity.collapsed}
279
+ toggle={activity.toggle}
280
+ header={header}
281
+ >
282
+ <div
283
+ {...eventAttributes}
284
+ ref={memberRef}
285
+ className={css.eventRow}
286
+ data-stream-tool-group-member=""
287
+ data-stream-tool-group-position="start"
288
+ data-stream-tool-group-collapsed={activity.collapsed || undefined}
289
+ >
290
+ {children}
291
+ </div>
292
+ </ToolActivityGroupFrame>
293
+ </TurnProcessMember>
294
+ )
295
+ }
296
+
297
+ const hidden = fold.collapsed || activity.collapsed
298
+ const member = (
299
+ <div
300
+ id={memberId}
301
+ data-turn-process=""
302
+ data-tool-group-member=""
303
+ data-tool-group-hidden={hidden || undefined}
304
+ hidden={hidden}
305
+ >
306
+ <div
307
+ {...eventAttributes}
308
+ ref={memberRef}
309
+ className={css.eventRow}
310
+ data-stream-tool-group-member=""
311
+ data-stream-tool-group-position={group.position}
312
+ data-stream-tool-group-collapsed={activity.collapsed || undefined}
313
+ >
314
+ {children}
315
+ </div>
316
+ </div>
317
+ )
318
+
319
+ if (mount === null) return member
320
+ return (
321
+ <span data-tool-group-proxy="" aria-hidden>
322
+ {createPortal(member, mount)}
323
+ </span>
324
+ )
325
+ }
326
+
327
+ /** Render the group heading with the semantic icon supplied by the tool view. */
328
+ export function ToolActivityGroupHeader({
329
+ label,
330
+ icon,
331
+ }: {
332
+ readonly label: string
333
+ readonly icon: ReactNode
334
+ }) {
335
+ return (
336
+ <>
337
+ <span className={css.toolGroupHeaderIcon} aria-hidden>{icon}</span>
338
+ <span className={css.toolGroupHeaderLabel}>{label}</span>
339
+ </>
340
+ )
341
+ }
@@ -0,0 +1,258 @@
1
+ import { createElement, useEffect, useState, type ComponentType } from 'react'
2
+ import {
3
+ IconChevronDownOutline14,
4
+ IconChevronRightOutline14,
5
+ } from '@deepseek-ai/dsh-client-ui-primitives'
6
+ import type { FollowWrapProps } from './TypewriterToolNodeView.tsx'
7
+ import {
8
+ turnProcessBoundaryId,
9
+ useTurnProcessFold,
10
+ type TurnFoldAddress,
11
+ } from './turnProcessFold.ts'
12
+ import { formatTurnProcessed, type TurnElapsedTranslator } from './turnElapsed.ts'
13
+ import css from './TypewriterAssistantNodeView.module.css'
14
+
15
+ interface TimelineTurn {
16
+ readonly turn: number
17
+ readonly status: 'open' | 'closed' | 'unknown'
18
+ readonly start?: { readonly seq?: number; readonly time?: number } | undefined
19
+ readonly end?: { readonly seq?: number } | undefined
20
+ }
21
+
22
+ function nodeKey(node: unknown): string | undefined {
23
+ if (node === null || typeof node !== 'object' || !('key' in node)) return undefined
24
+ return typeof (node as { key: unknown }).key === 'string' ? (node as { key: string }).key : undefined
25
+ }
26
+
27
+ function userSeq(node: unknown): number | undefined {
28
+ if (node === null || typeof node !== 'object' || !('data' in node)) return undefined
29
+ const data = (node as { data: unknown }).data
30
+ if (data === null || typeof data !== 'object' || !('seq' in data)) return undefined
31
+ return typeof (data as { seq: unknown }).seq === 'number' ? (data as { seq: number }).seq : undefined
32
+ }
33
+
34
+ function userTime(node: unknown): number | undefined {
35
+ if (node === null || typeof node !== 'object' || !('data' in node)) return undefined
36
+ const data = (node as { data: unknown }).data
37
+ if (data === null || typeof data !== 'object' || !('time' in data)) return undefined
38
+ return typeof (data as { time: unknown }).time === 'number' ? (data as { time: number }).time : undefined
39
+ }
40
+
41
+ function chatParts(snapshot: unknown): {
42
+ readonly order: readonly unknown[]
43
+ readonly get: (key: string) => unknown
44
+ readonly turns: ReadonlyMap<number, TimelineTurn>
45
+ readonly turnOrder: readonly number[]
46
+ } | undefined {
47
+ if (snapshot === null || typeof snapshot !== 'object' || !('chat' in snapshot)) return undefined
48
+ const chat = (snapshot as { chat: unknown }).chat
49
+ if (chat === null || typeof chat !== 'object') return undefined
50
+ if (!('order' in chat) || !Array.isArray((chat as { order: unknown }).order)) return undefined
51
+ if (!('nodes' in chat) || !('timeline' in chat)) return undefined
52
+ const nodes = (chat as { nodes: unknown }).nodes
53
+ const timeline = (chat as { timeline: unknown }).timeline
54
+ if (nodes === null || typeof nodes !== 'object' || !('get' in nodes)) return undefined
55
+ if (timeline === null || typeof timeline !== 'object' || !('turns' in timeline) || !('turnOrder' in timeline)) return undefined
56
+ const get = (nodes as { get: unknown }).get
57
+ const turns = (timeline as { turns: unknown }).turns
58
+ const turnOrder = (timeline as { turnOrder: unknown }).turnOrder
59
+ if (typeof get !== 'function' || !(turns instanceof Map) || !Array.isArray(turnOrder)) return undefined
60
+ return {
61
+ order: (chat as { order: readonly unknown[] }).order,
62
+ get: (key: string) => get.call(nodes, key),
63
+ turns: turns as ReadonlyMap<number, TimelineTurn>,
64
+ turnOrder: turnOrder as readonly number[],
65
+ }
66
+ }
67
+
68
+ function associatedTurn(snapshot: unknown, currentKey: string, currentSeq: number): TimelineTurn | undefined {
69
+ const chat = chatParts(snapshot)
70
+ if (chat === undefined) return undefined
71
+ const currentIndex = chat.order.indexOf(currentKey)
72
+ if (currentIndex === -1) return undefined
73
+ const current = chat.get(currentKey)
74
+ if (current !== null && typeof current === 'object' && 'location' in current) {
75
+ const location = (current as { location: unknown }).location
76
+ if (
77
+ location !== null
78
+ && typeof location === 'object'
79
+ && 'kind' in location
80
+ && ((location as { kind: unknown }).kind === 'turn' || (location as { kind: unknown }).kind === 'step')
81
+ && 'turn' in location
82
+ ) {
83
+ const turn = (location as { turn: unknown }).turn
84
+ if (turn !== null && typeof turn === 'object' && 'turn' in turn) return turn as TimelineTurn
85
+ }
86
+ }
87
+ let closest: TimelineTurn | undefined
88
+ let closestStartSeq = Number.NEGATIVE_INFINITY
89
+ for (const turnNumber of chat.turnOrder) {
90
+ const turn = chat.turns.get(turnNumber)
91
+ const startSeq = turn?.start?.seq
92
+ const endSeq = turn?.end?.seq
93
+ if (
94
+ turn !== undefined
95
+ && typeof startSeq === 'number'
96
+ && startSeq < currentSeq
97
+ && (typeof endSeq !== 'number' || currentSeq < endSeq)
98
+ && startSeq > closestStartSeq
99
+ ) {
100
+ closest = turn
101
+ closestStartSeq = startSeq
102
+ }
103
+ }
104
+ return closest
105
+ }
106
+
107
+ function isLatestUser(snapshot: unknown, currentKey: string): boolean {
108
+ const chat = chatParts(snapshot)
109
+ if (chat === undefined) return false
110
+ for (let index = chat.order.length - 1; index >= 0; index -= 1) {
111
+ const key = chat.order[index]
112
+ if (typeof key !== 'string') continue
113
+ const candidate = chat.get(key)
114
+ if (candidate === null || typeof candidate !== 'object' || !('kind' in candidate)) continue
115
+ if ((candidate as { kind: unknown }).kind === 'user') return key === currentKey
116
+ }
117
+ return false
118
+ }
119
+
120
+ function isRunning(snapshot: unknown): boolean {
121
+ return snapshot !== null
122
+ && typeof snapshot === 'object'
123
+ && 'running' in snapshot
124
+ && (snapshot as { running: unknown }).running === true
125
+ }
126
+
127
+ function useElapsedNow(active: boolean): number {
128
+ const [now, setNow] = useState(() => Date.now())
129
+ useEffect(() => {
130
+ if (!active) return
131
+ setNow(Date.now())
132
+ const timer = window.setInterval(() => setNow(Date.now()), 1_000)
133
+ return () => window.clearInterval(timer)
134
+ }, [active])
135
+ return now
136
+ }
137
+
138
+ function TurnPrelude({
139
+ active,
140
+ address,
141
+ boundaryId,
142
+ boundaryOrder,
143
+ startTime,
144
+ t,
145
+ }: {
146
+ readonly active: boolean
147
+ readonly address: TurnFoldAddress | undefined
148
+ readonly boundaryId: string
149
+ readonly boundaryOrder: number
150
+ readonly startTime: number
151
+ readonly t: TurnElapsedTranslator
152
+ }) {
153
+ const fold = useTurnProcessFold(address, { boundaryId, boundaryOrder })
154
+ const running = active && !fold.completed
155
+ const now = useElapsedNow(running)
156
+ if (!running && !fold.completed) return null
157
+ const label = fold.completed ? fold.label : formatTurnProcessed(now - startTime, t)
158
+ if (label === undefined) return null
159
+ return (
160
+ <div className={css.turnPrelude} data-turn-prelude="">
161
+ <div
162
+ className={css.turnFoldRow}
163
+ data-turn-fold-row=""
164
+ data-turn-fold-state={fold.completed ? 'completed' : 'running'}
165
+ >
166
+ {fold.completed ? (
167
+ <button
168
+ type="button"
169
+ className={css.turnFoldButton}
170
+ aria-expanded={!fold.collapsed}
171
+ aria-controls={fold.controls}
172
+ onClick={fold.toggle}
173
+ >
174
+ <span>{label}</span>
175
+ {fold.collapsed ? <IconChevronRightOutline14 /> : <IconChevronDownOutline14 />}
176
+ </button>
177
+ ) : (
178
+ <span className={css.turnFoldLabel} aria-live="off">{label}</span>
179
+ )}
180
+ </div>
181
+ {running && !fold.hasMembers && (
182
+ <div className={css.turnWaiting} data-turn-waiting="" role="status" aria-live="polite">
183
+ 思考中
184
+ </div>
185
+ )}
186
+ </div>
187
+ )
188
+ }
189
+
190
+ /** Add an immediate Turn status boundary directly after an ordinary user row. */
191
+ export function wrapTurnPreludeNodeView(Inner: ComponentType<FollowWrapProps>) {
192
+ return function TurnPreludeUserNodeView(props: FollowWrapProps) {
193
+ const key = nodeKey(props.node)
194
+ const seq = userSeq(props.node)
195
+ const time = userTime(props.node)
196
+ const useSession = props.useSession
197
+ if (
198
+ key === undefined
199
+ || seq === undefined
200
+ || time === undefined
201
+ || typeof props.sessionId !== 'string'
202
+ || typeof useSession !== 'function'
203
+ || typeof props.t !== 'function'
204
+ ) return createElement(Inner, props)
205
+ return (
206
+ <TurnPreludeUserWithSession
207
+ Inner={Inner}
208
+ props={props}
209
+ nodeKey={key}
210
+ nodeSeq={seq}
211
+ nodeTime={time}
212
+ sessionId={props.sessionId}
213
+ useSession={useSession as (selector: (snapshot: unknown) => unknown) => unknown}
214
+ t={props.t as TurnElapsedTranslator}
215
+ />
216
+ )
217
+ }
218
+ }
219
+
220
+ function TurnPreludeUserWithSession({
221
+ Inner,
222
+ props,
223
+ nodeKey: currentKey,
224
+ nodeSeq,
225
+ nodeTime,
226
+ sessionId,
227
+ useSession,
228
+ t,
229
+ }: {
230
+ readonly Inner: ComponentType<FollowWrapProps>
231
+ readonly props: FollowWrapProps
232
+ readonly nodeKey: string
233
+ readonly nodeSeq: number
234
+ readonly nodeTime: number
235
+ readonly sessionId: string
236
+ readonly useSession: (selector: (snapshot: unknown) => unknown) => unknown
237
+ readonly t: TurnElapsedTranslator
238
+ }) {
239
+ const turn = useSession(snapshot => associatedTurn(snapshot, currentKey, nodeSeq)) as TimelineTurn | undefined
240
+ const running = useSession(isRunning) as boolean
241
+ const latestUser = useSession(snapshot => isLatestUser(snapshot, currentKey)) as boolean
242
+ const address = turn === undefined ? undefined : { sessionId, turn: turn.turn }
243
+ const active = turn?.status === 'open' || (turn === undefined && running && latestUser)
244
+ const startTime = typeof turn?.start?.time === 'number' ? turn.start.time : nodeTime
245
+ return (
246
+ <>
247
+ {createElement(Inner, props)}
248
+ <TurnPrelude
249
+ active={active}
250
+ address={address}
251
+ boundaryId={turnProcessBoundaryId(sessionId, currentKey)}
252
+ boundaryOrder={nodeSeq}
253
+ startTime={startTime}
254
+ t={t}
255
+ />
256
+ </>
257
+ )
258
+ }