@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,916 @@
1
+ import { createElement, useCallback, useLayoutEffect, useRef, useState, type ComponentType, type MouseEvent } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { FiTool } from 'react-icons/fi'
4
+ import {
5
+ IconAgentPresetOutline16,
6
+ IconApiOutline14,
7
+ IconBrowseOutline16,
8
+ IconChecklistOutline14,
9
+ IconCordisPluginOutline14,
10
+ IconDataOutline16,
11
+ IconEditOutline16,
12
+ IconGlobeOutline14,
13
+ IconQuestionOutline14,
14
+ IconSearchOutline16,
15
+ IconSettingsOutline16,
16
+ IconSkillOutline16,
17
+ StateDot,
18
+ type IconProps,
19
+ } from '@deepseek-ai/dsh-client-ui-primitives'
20
+ import { FollowHost } from './FollowHost.tsx'
21
+ import { ToolActivityGroupHeader, ToolActivityGroupMember, toolActivityGroupId, type ToolActivityGroupInfo } from './ToolActivityGroup.tsx'
22
+ import { TurnProcessMember } from './TurnProcessMember.tsx'
23
+ import { turnProcessMemberId, type TurnFoldAddress } from './turnProcessFold.ts'
24
+ import { formatTurnProcessed, type TurnElapsedTranslator } from './turnElapsed.ts'
25
+ import css from './TypewriterAssistantNodeView.module.css'
26
+
27
+ /** Props forwarded through a follow wrap; extra kit seats pass through. */
28
+ export type FollowWrapProps = {
29
+ node?: unknown
30
+ renderSlot?: unknown
31
+ } & Record<string, unknown>
32
+
33
+ interface FollowMotionConfig {
34
+ readonly minSpeedPxPerSec?: number | undefined
35
+ readonly maxSpeedPxPerSec?: number | undefined
36
+ }
37
+
38
+ export type ToolSemanticKind =
39
+ | 'search'
40
+ | 'read'
41
+ | 'edit'
42
+ | 'terminal'
43
+ | 'data'
44
+ | 'web'
45
+ | 'code'
46
+ | 'skill'
47
+ | 'agent'
48
+ | 'plugin'
49
+ | 'question'
50
+ | 'settings'
51
+ | 'checklist'
52
+ | 'other'
53
+
54
+ const TOOL_ICONS: Record<ToolSemanticKind, ComponentType<IconProps>> = {
55
+ search: IconSearchOutline16,
56
+ read: IconBrowseOutline16,
57
+ edit: IconEditOutline16,
58
+ terminal: IconApiOutline14,
59
+ data: IconDataOutline16,
60
+ web: IconGlobeOutline14,
61
+ code: FiTool as ComponentType<IconProps>,
62
+ skill: IconSkillOutline16,
63
+ agent: IconAgentPresetOutline16,
64
+ plugin: IconCordisPluginOutline14,
65
+ question: IconQuestionOutline14,
66
+ settings: IconSettingsOutline16,
67
+ checklist: IconChecklistOutline14,
68
+ other: FiTool as ComponentType<IconProps>,
69
+ }
70
+
71
+ const CONVERSATION_NODE_ICONS: Readonly<Partial<Record<string, ComponentType<IconProps>>>> = {
72
+ context: IconBrowseOutline16,
73
+ }
74
+
75
+ function toolTokens(toolName: string): readonly string[] {
76
+ return toolName
77
+ .replace(/([a-z\d])([A-Z])/g, '$1_$2')
78
+ .toLowerCase()
79
+ .split(/[^a-z\d]+/)
80
+ .filter(Boolean)
81
+ }
82
+
83
+ /**
84
+ * Map a wire Tool name onto the closest Harness icon family. Custom plugins
85
+ * do not have to register UI code: descriptive names such as
86
+ * `smops_knowledge_search` still receive a meaningful search glyph.
87
+ */
88
+ export function semanticToolKind(toolName: string): ToolSemanticKind {
89
+ const tokens = toolTokens(toolName)
90
+ const has = (...terms: readonly string[]) => terms.some(term => tokens.includes(term))
91
+
92
+ // Domain nouns outrank generic action verbs: `load_skill` is a Skill action,
93
+ // and `create_subagent` is an Agent action—not a file read/edit operation.
94
+ if (has('skill', 'skills')) return 'skill'
95
+ if (has('agent', 'subagent', 'delegate')) return 'agent'
96
+ if (has('cordis', 'plugin', 'plugins')) return 'plugin'
97
+ if (has('question', 'ask', 'clarify', 'input')) return 'question'
98
+ if (has('scope', 'config', 'settings', 'setting')) return 'settings'
99
+ if (has('database', 'db', 'sql', 'schema', 'table', 'record', 'records', 'dataset', 'memory')) return 'data'
100
+ if (has('plan', 'todo', 'task', 'checklist', 'goal')) return 'checklist'
101
+ if (has('edit', 'write', 'patch', 'replace', 'create', 'delete', 'remove', 'move', 'copy')) return 'edit'
102
+ if (has('bash', 'shell', 'terminal', 'exec', 'command', 'cmd', 'powershell', 'pwsh')) return 'terminal'
103
+ if (has('web', 'browser', 'url', 'http', 'https', 'crawl', 'scrape')) return 'web'
104
+ if (has('read', 'load', 'open', 'file', 'files')) return 'read'
105
+ if (has('search', 'find', 'grep', 'glob', 'query', 'lookup', 'knowledge', 'retrieve', 'index', 'log', 'logs', 'trace')) return 'search'
106
+ if (has('code', 'codegraph', 'graph', 'python', 'javascript', 'typescript', 'node', 'program')) return 'code'
107
+ return 'other'
108
+ }
109
+
110
+ type ToolPresentation = {
111
+ readonly name: string
112
+ readonly semantic: ToolSemanticKind
113
+ readonly state: 'error' | 'stopped' | null
114
+ }
115
+
116
+ function toolPresentation(node: unknown): ToolPresentation | null {
117
+ if (node === null || typeof node !== 'object' || !('data' in node)) return null
118
+ const data = (node as { data: unknown }).data
119
+ if (data === null || typeof data !== 'object' || !('root' in data)) return null
120
+ const root = (data as { root: unknown }).root
121
+ if (root === null || typeof root !== 'object') return null
122
+
123
+ const name = 'name' in root && typeof (root as { name: unknown }).name === 'string'
124
+ ? (root as { name: string }).name
125
+ : 'call' in root
126
+ && (root as { call: unknown }).call !== null
127
+ && typeof (root as { call: unknown }).call === 'object'
128
+ && 'name' in ((root as { call: object }).call)
129
+ && typeof ((root as { call: { name: unknown } }).call.name) === 'string'
130
+ ? (root as { call: { name: string } }).call.name
131
+ : ''
132
+ if (name === '') return null
133
+
134
+ const isError = ('isError' in root && (root as { isError: unknown }).isError === true)
135
+ || ('error' in root && (root as { error: unknown }).error !== null && (root as { error: unknown }).error !== undefined)
136
+ const errorCode = 'error' in root
137
+ && (root as { error: unknown }).error !== null
138
+ && typeof (root as { error: unknown }).error === 'object'
139
+ && 'code' in ((root as { error: object }).error)
140
+ && typeof ((root as { error: { code: unknown } }).error.code) === 'string'
141
+ ? (root as { error: { code: string } }).error.code.toLowerCase()
142
+ : ''
143
+ const stopped = /cancel|interrupt|abort|stop/.test(errorCode)
144
+ return {
145
+ name,
146
+ semantic: semanticToolKind(name),
147
+ state: stopped ? 'stopped' : isError ? 'error' : null,
148
+ }
149
+ }
150
+
151
+ function diffLineCount(value: unknown): number {
152
+ if (typeof value !== 'string' || value === '') return 0
153
+ const body = value.endsWith('\n')
154
+ ? value.slice(0, -1)
155
+ : value.endsWith('\\n') ? value.slice(0, -2) : value
156
+ return body === '' ? 0 : body.split(/\r?\n|\\n/).length
157
+ }
158
+
159
+ /**
160
+ * Derive the compact +/- suffix shown beside an edit file path. The native
161
+ * edit card already owns the actual diff body; this only mirrors its counts
162
+ * into the collapsed summary row.
163
+ */
164
+ export function editDiffFilePath(node: unknown): string | undefined {
165
+ const presentation = toolPresentation(node)
166
+ if (presentation?.semantic !== 'edit' || presentation.state !== null || node === null || typeof node !== 'object') return undefined
167
+ const data = (node as { data?: unknown }).data
168
+ if (data === null || typeof data !== 'object' || !('root' in data)) return undefined
169
+ const root = (data as { root: unknown }).root
170
+ if (root === null || typeof root !== 'object') return undefined
171
+ const view = 'resultView' in root
172
+ ? (root as { resultView: unknown; callView?: unknown }).resultView ?? (root as { callView?: unknown }).callView
173
+ : 'callView' in root ? (root as { callView: unknown }).callView : undefined
174
+ if (view === null || typeof view !== 'object' || (view as { card?: unknown }).card !== 'diff') return undefined
175
+ const diffs = (view as { diffs?: unknown }).diffs
176
+ if (!Array.isArray(diffs)) return undefined
177
+ const first = diffs.find(diff => diff !== null && typeof diff === 'object' && typeof (diff as { path?: unknown }).path === 'string')
178
+ return first === undefined ? undefined : (first as { path: string }).path
179
+ }
180
+
181
+ export function editDiffStats(node: unknown): string | undefined {
182
+ const presentation = toolPresentation(node)
183
+ if (presentation?.semantic !== 'edit' || presentation.state !== null || node === null || typeof node !== 'object') return undefined
184
+ const data = (node as { data?: unknown }).data
185
+ if (data === null || typeof data !== 'object' || !('root' in data)) return undefined
186
+ const root = (data as { root: unknown }).root
187
+ if (root === null || typeof root !== 'object') return undefined
188
+ const view = 'resultView' in root
189
+ ? (root as { resultView: unknown; callView?: unknown }).resultView ?? (root as { callView?: unknown }).callView
190
+ : 'callView' in root ? (root as { callView: unknown }).callView : undefined
191
+ if (view === null || typeof view !== 'object' || (view as { card?: unknown }).card !== 'diff') return undefined
192
+ const diffs = (view as { diffs?: unknown }).diffs
193
+ if (!Array.isArray(diffs)) return undefined
194
+ let added = 0
195
+ let removed = 0
196
+ for (const diff of diffs) {
197
+ if (diff === null || typeof diff !== 'object') continue
198
+ added += diffLineCount((diff as { newText?: unknown }).newText)
199
+ removed += diffLineCount((diff as { oldText?: unknown }).oldText)
200
+ }
201
+ return added === 0 && removed === 0 ? undefined : `+${added} -${removed}`
202
+ }
203
+
204
+ const TOOL_GROUP_LABELS: Readonly<Record<ToolSemanticKind, string>> = {
205
+ search: '搜索',
206
+ read: '读取了文件',
207
+ edit: '编辑了文件',
208
+ terminal: '运行了命令',
209
+ data: '查询了数据',
210
+ web: '浏览了网页',
211
+ code: '运行了代码',
212
+ skill: '使用了技能',
213
+ agent: '调用了智能体',
214
+ plugin: '调用了插件',
215
+ question: '发起了提问',
216
+ settings: '更新了设置',
217
+ checklist: '更新了清单',
218
+ other: '执行了操作',
219
+ }
220
+
221
+ const NATIVE_GROUP_LABELS: Readonly<Record<string, string>> = {
222
+ context: '注入了 Prompt',
223
+ }
224
+
225
+ function visibleNode(node: unknown): boolean {
226
+ if (node === null || typeof node !== 'object') return false
227
+ return !('visibility' in node) || (node as { visibility: unknown }).visibility === 'visible'
228
+ }
229
+
230
+ function sameActivityGroupNode(
231
+ node: unknown,
232
+ nodeKind: string,
233
+ turn: number,
234
+ semantic: string,
235
+ requiresTurn: boolean,
236
+ ): boolean {
237
+ return visibleNode(node)
238
+ && chatNodeKind(node) === nodeKind
239
+ && (!requiresTurn || turnDetailsFromNode(node)?.turn === turn)
240
+ && (nodeKind !== 'tool-call' || toolPresentation(node)?.semantic === semantic)
241
+ }
242
+
243
+ function activityGroup(
244
+ snapshot: unknown,
245
+ currentKey: string,
246
+ sessionId: string,
247
+ turn: number,
248
+ nodeKind: string,
249
+ semantic: string,
250
+ label: string,
251
+ requiresTurn: boolean,
252
+ ): ToolActivityGroupInfo | undefined {
253
+ if (snapshot === null || typeof snapshot !== 'object' || !('chat' in snapshot)) return undefined
254
+ const chat = (snapshot as { chat: unknown }).chat
255
+ if (chat === null || typeof chat !== 'object' || !('order' in chat) || !('nodes' in chat)) return undefined
256
+ const order = (chat as { order: unknown }).order
257
+ const nodes = (chat as { nodes: unknown }).nodes
258
+ if (!Array.isArray(order) || nodes === null || typeof nodes !== 'object' || !('get' in nodes)) return undefined
259
+ const get = (nodes as { get: unknown }).get
260
+ if (typeof get !== 'function') return undefined
261
+ const currentIndex = order.indexOf(currentKey)
262
+ if (currentIndex === -1) return undefined
263
+ const read = (index: number): unknown => {
264
+ const key = order[index]
265
+ return typeof key === 'string' ? get.call(nodes, key) : undefined
266
+ }
267
+ const same = (node: unknown) => sameActivityGroupNode(node, nodeKind, turn, semantic, requiresTurn)
268
+ if (!same(read(currentIndex))) return undefined
269
+ let first = currentIndex
270
+ let last = currentIndex
271
+ while (first > 0 && same(read(first - 1))) first -= 1
272
+ while (last + 1 < order.length && same(read(last + 1))) last += 1
273
+ const count = last - first + 1
274
+ if (count < 2) return undefined
275
+ const firstKey = order[first]
276
+ if (typeof firstKey !== 'string') return undefined
277
+ let hasGrowing = false
278
+ for (let index = first; index <= last; index += 1) {
279
+ if (isGrowingChatNode(read(index))) {
280
+ hasGrowing = true
281
+ break
282
+ }
283
+ }
284
+ const groupId = toolActivityGroupId(sessionId, turn, semantic, firstKey)
285
+ return {
286
+ id: groupId,
287
+ domId: `${groupId}-frame`,
288
+ semantic,
289
+ label,
290
+ count,
291
+ position: currentIndex === first ? 'start' : currentIndex === last ? 'end' : 'middle',
292
+ hasGrowing,
293
+ }
294
+ }
295
+
296
+ /** Find one contiguous same-kind Tool run inside a Turn. */
297
+ export function toolActivityGroup(
298
+ snapshot: unknown,
299
+ currentKey: string,
300
+ sessionId: string,
301
+ turn: number,
302
+ semantic: ToolSemanticKind,
303
+ ): ToolActivityGroupInfo | undefined {
304
+ return activityGroup(
305
+ snapshot,
306
+ currentKey,
307
+ sessionId,
308
+ turn,
309
+ 'tool-call',
310
+ semantic,
311
+ TOOL_GROUP_LABELS[semantic],
312
+ true,
313
+ )
314
+ }
315
+
316
+ /** Find one contiguous run of a native Stream activity such as Prompt context. */
317
+ export function nativeActivityGroup(
318
+ snapshot: unknown,
319
+ currentKey: string,
320
+ sessionId: string,
321
+ turn: number,
322
+ kind: string,
323
+ ): ToolActivityGroupInfo | undefined {
324
+ const label = NATIVE_GROUP_LABELS[kind]
325
+ return label === undefined ? undefined : activityGroup(
326
+ snapshot,
327
+ currentKey,
328
+ sessionId,
329
+ turn,
330
+ kind,
331
+ kind,
332
+ label,
333
+ false,
334
+ )
335
+ }
336
+
337
+ function ToolSemanticIcon({
338
+ presentation,
339
+ className = css.toolSemanticIcon,
340
+ }: {
341
+ readonly presentation: ToolPresentation
342
+ readonly className?: string | undefined
343
+ }) {
344
+ const Icon = TOOL_ICONS[presentation.semantic]
345
+ const glyph = presentation.semantic === 'code' || presentation.semantic === 'other'
346
+ ? 'wrench'
347
+ : presentation.semantic
348
+ return (
349
+ <span
350
+ className={className}
351
+ data-tool-semantic-icon={presentation.semantic}
352
+ data-tool-glyph={glyph}
353
+ aria-hidden
354
+ >
355
+ {presentation.state === 'error'
356
+ ? <StateDot state="error" />
357
+ : presentation.state === 'stopped'
358
+ ? <StateDot state="warning" />
359
+ : <Icon size={14} />}
360
+ </span>
361
+ )
362
+ }
363
+
364
+ interface NestedToolIconTarget {
365
+ readonly key: string
366
+ readonly target: HTMLElement
367
+ readonly presentation: ToolPresentation
368
+ }
369
+
370
+ function sameNestedToolIconTargets(
371
+ current: readonly NestedToolIconTarget[],
372
+ next: readonly NestedToolIconTarget[],
373
+ ): boolean {
374
+ return current.length === next.length && current.every((item, index) => {
375
+ const candidate = next[index]
376
+ return candidate !== undefined
377
+ && item.key === candidate.key
378
+ && item.target === candidate.target
379
+ && item.presentation.name === candidate.presentation.name
380
+ && item.presentation.semantic === candidate.presentation.semantic
381
+ && item.presentation.state === candidate.presentation.state
382
+ })
383
+ }
384
+
385
+ /** Find expandable nested Tool headers rendered by the Harness Tool tree. */
386
+ export function nestedToolIconTargets(root: HTMLElement): NestedToolIconTarget[] {
387
+ const targets: NestedToolIconTarget[] = []
388
+ const callRows = root.querySelectorAll<HTMLElement>('[data-subcalls] [data-chat-call-id]')
389
+ callRows.forEach((callRow, index) => {
390
+ const slot = Array.from(callRow.children).find(child => (
391
+ child instanceof HTMLElement && child.matches('[data-slot="tool.call.toolview"]')
392
+ )) as HTMLElement | undefined
393
+ if (slot === undefined) return
394
+
395
+ const toolRoot = slot.querySelector<HTMLElement>(':scope > [data-tool]')
396
+ const bashRoot = slot.querySelector<HTMLElement>(':scope > * > [data-sample="bash"]')
397
+ const target = toolRoot?.querySelector<HTMLElement>(
398
+ ':scope > * > [data-disclosure-row][aria-expanded]',
399
+ ) ?? (bashRoot?.matches('[aria-expanded]') === true ? bashRoot : null)
400
+ if (target === null) return
401
+
402
+ const name = toolRoot?.getAttribute('data-tool') ?? 'bash'
403
+ const stateName = toolRoot?.getAttribute('data-state') ?? bashRoot?.getAttribute('data-state')
404
+ const state = stateName === 'error'
405
+ ? 'error'
406
+ : stateName === 'stopped' ? 'stopped' : null
407
+ targets.push({
408
+ key: callRow.getAttribute('data-chat-call-id') ?? `${name}-${index}`,
409
+ target,
410
+ presentation: { name, semantic: semanticToolKind(name), state },
411
+ })
412
+ })
413
+ return targets
414
+ }
415
+
416
+ /**
417
+ * Nested DisclosureRows replace their leading icon with a chevron when open.
418
+ * A portal owned by this plugin keeps a semantic icon mounted in each header
419
+ * across that internal state change while Harness continues to own the body.
420
+ */
421
+ function NestedToolSemanticIcons({
422
+ owner,
423
+ revision,
424
+ }: {
425
+ readonly owner: HTMLDivElement | null
426
+ readonly revision: unknown
427
+ }) {
428
+ const [targets, setTargets] = useState<readonly NestedToolIconTarget[]>([])
429
+ useLayoutEffect(() => {
430
+ const next = owner === null ? [] : nestedToolIconTargets(owner)
431
+ setTargets(current => sameNestedToolIconTargets(current, next) ? current : next)
432
+ }, [owner, revision])
433
+
434
+ return targets.map(({ key, target, presentation }) => {
435
+ const Icon = TOOL_ICONS[presentation.semantic]
436
+ return createPortal(
437
+ <span
438
+ className={css.nestedToolSemanticIcon}
439
+ data-stream-nested-tool-icon={presentation.semantic}
440
+ aria-hidden
441
+ >
442
+ {presentation.state === 'error'
443
+ ? <StateDot state="error" />
444
+ : presentation.state === 'stopped'
445
+ ? <StateDot state="warning" />
446
+ : <Icon size={14} />}
447
+ </span>,
448
+ target,
449
+ key,
450
+ )
451
+ })
452
+ }
453
+
454
+ function ConversationNodeSemanticIcon({
455
+ kind,
456
+ className = css.streamSemanticIcon,
457
+ }: {
458
+ readonly kind: string
459
+ readonly className?: string | undefined
460
+ }) {
461
+ const Icon = CONVERSATION_NODE_ICONS[kind]
462
+ if (Icon === undefined) return null
463
+ return (
464
+ <span
465
+ className={className}
466
+ data-stream-semantic-icon={kind}
467
+ aria-hidden
468
+ >
469
+ <Icon size={14} />
470
+ </span>
471
+ )
472
+ }
473
+
474
+ function chatNodeKind(node: unknown): string {
475
+ if (node === null || typeof node !== 'object' || !('kind' in node)) return 'unknown'
476
+ const kind = (node as { kind: unknown }).kind
477
+ return typeof kind === 'string' ? kind : 'unknown'
478
+ }
479
+
480
+ const KEEP_VISIBLE_KINDS = new Set(['turn-tail', 'turn-error', 'turn-max-tokens'])
481
+
482
+ interface TurnDetails {
483
+ readonly turn: number
484
+ readonly startTime: number | undefined
485
+ }
486
+
487
+ function turnDetailsFromNode(node: unknown): TurnDetails | undefined {
488
+ if (node === null || typeof node !== 'object' || !('location' in node)) return undefined
489
+ const location = (node as { location: unknown }).location
490
+ if (location === null || typeof location !== 'object' || !('kind' in location)) return undefined
491
+ const kind = (location as { kind: unknown }).kind
492
+ if (kind !== 'turn' && kind !== 'step') return undefined
493
+ const turn = (location as { turn?: unknown }).turn
494
+ if (turn === null || typeof turn !== 'object' || !('turn' in turn)) return undefined
495
+ const turnNumber = (turn as { turn: unknown }).turn
496
+ if (typeof turnNumber !== 'number') return undefined
497
+ const start = 'start' in turn ? (turn as { start: unknown }).start : undefined
498
+ const startTime = start !== null
499
+ && typeof start === 'object'
500
+ && 'time' in start
501
+ && typeof (start as { time: unknown }).time === 'number'
502
+ ? (start as { time: number }).time
503
+ : undefined
504
+ return { turn: turnNumber, startTime }
505
+ }
506
+
507
+ function processAddress(node: unknown, sessionId: unknown): TurnFoldAddress | undefined {
508
+ if (typeof sessionId !== 'string') return undefined
509
+ const details = turnDetailsFromNode(node)
510
+ return details === undefined ? undefined : { sessionId, turn: details.turn }
511
+ }
512
+
513
+ function nodeKey(node: unknown): string | undefined {
514
+ if (node === null || typeof node !== 'object' || !('key' in node)) return undefined
515
+ const key = (node as { key: unknown }).key
516
+ return typeof key === 'string' ? key : undefined
517
+ }
518
+
519
+ function nodeOrder(node: unknown): number {
520
+ if (node === null || typeof node !== 'object' || !('anchorSeq' in node)) return 0
521
+ const anchorSeq = (node as { anchorSeq: unknown }).anchorSeq
522
+ return typeof anchorSeq === 'number' ? anchorSeq : 0
523
+ }
524
+
525
+ function followingTurn(snapshot: unknown, currentKey: string): TurnDetails | undefined {
526
+ if (snapshot === null || typeof snapshot !== 'object' || !('chat' in snapshot)) return undefined
527
+ const chat = (snapshot as { chat: unknown }).chat
528
+ if (chat === null || typeof chat !== 'object' || !('order' in chat) || !('nodes' in chat)) return undefined
529
+ const order = (chat as { order: unknown }).order
530
+ const nodes = (chat as { nodes: unknown }).nodes
531
+ if (!Array.isArray(order) || nodes === null || typeof nodes !== 'object' || !('get' in nodes)) return undefined
532
+ const get = (nodes as { get: unknown }).get
533
+ if (typeof get !== 'function') return undefined
534
+ const start = order.indexOf(currentKey)
535
+ if (start === -1) return undefined
536
+ for (let index = start + 1; index < order.length; index += 1) {
537
+ const key = order[index]
538
+ if (typeof key !== 'string') continue
539
+ const turn = turnDetailsFromNode(get.call(nodes, key))
540
+ if (turn !== undefined) return turn
541
+ }
542
+ return undefined
543
+ }
544
+
545
+ /**
546
+ * Keep the latest settled Tool visibly active while the Agent is still
547
+ * running but has not published its next Chat row yet. A later visible row
548
+ * ends this handoff state; `turn-tail` is presentation metadata rather than
549
+ * the next piece of model/process output.
550
+ */
551
+ function isAwaitingNextTurnNode(snapshot: unknown, currentKey: string, turnNumber: number): boolean {
552
+ if (
553
+ snapshot === null
554
+ || typeof snapshot !== 'object'
555
+ || !('running' in snapshot)
556
+ || (snapshot as { running: unknown }).running !== true
557
+ || !('chat' in snapshot)
558
+ ) return false
559
+ const chat = (snapshot as { chat: unknown }).chat
560
+ if (chat === null || typeof chat !== 'object' || !('order' in chat) || !('nodes' in chat)) return false
561
+ const order = (chat as { order: unknown }).order
562
+ const nodes = (chat as { nodes: unknown }).nodes
563
+ if (!Array.isArray(order) || nodes === null || typeof nodes !== 'object' || !('get' in nodes)) return false
564
+ const get = (nodes as { get: unknown }).get
565
+ if (typeof get !== 'function') return false
566
+
567
+ if ('timeline' in chat) {
568
+ const timeline = (chat as { timeline: unknown }).timeline
569
+ if (timeline !== null && typeof timeline === 'object' && 'turns' in timeline) {
570
+ const turns = (timeline as { turns: unknown }).turns
571
+ if (turns instanceof Map) {
572
+ const turn = turns.get(turnNumber)
573
+ if (
574
+ turn !== undefined
575
+ && turn !== null
576
+ && typeof turn === 'object'
577
+ && 'status' in turn
578
+ && (turn as { status: unknown }).status !== 'open'
579
+ ) return false
580
+ }
581
+ }
582
+ }
583
+
584
+ const currentIndex = order.indexOf(currentKey)
585
+ if (currentIndex === -1) return false
586
+ for (let index = currentIndex + 1; index < order.length; index += 1) {
587
+ const key = order[index]
588
+ if (typeof key !== 'string') continue
589
+ const candidate = get.call(nodes, key)
590
+ if (candidate === null || typeof candidate !== 'object') continue
591
+ if ('visibility' in candidate && (candidate as { visibility: unknown }).visibility !== 'visible') continue
592
+ if ('kind' in candidate && (candidate as { kind: unknown }).kind === 'turn-tail') continue
593
+ return false
594
+ }
595
+ return true
596
+ }
597
+
598
+ /**
599
+ * True while a Chat node is still growing: an assistant/workflow `status:
600
+ * 'running'` payload, a Tool root that has not settled (`kind` absent), or a
601
+ * model-retry whose current attempt is still `scheduled`.
602
+ * @param node - The Chat node's view `node` prop.
603
+ * @returns whether this row should own conversation follow.
604
+ */
605
+ export function isGrowingChatNode(node: unknown): boolean {
606
+ if (node === null || typeof node !== 'object' || !('data' in node)) return false
607
+ const data = (node as { data: unknown }).data
608
+ if (data === null || typeof data !== 'object') return false
609
+ if ('status' in data && (data as { status: unknown }).status === 'running') return true
610
+ if ('root' in data) {
611
+ const root = (data as { root: unknown }).root
612
+ if (root !== null && typeof root === 'object' && !('kind' in root)) return true
613
+ }
614
+ if ('current' in data) {
615
+ const current = (data as { current: unknown }).current
616
+ if (
617
+ current !== null
618
+ && typeof current === 'object'
619
+ && 'retryState' in current
620
+ && (current as { retryState: unknown }).retryState === 'scheduled'
621
+ ) return true
622
+ }
623
+ return false
624
+ }
625
+
626
+ /**
627
+ * Wrap a prior Chat node renderer so a growing row shares conversation
628
+ * follow. Presentation stays with the wrapped component; kit seats
629
+ * (`renderSlot`, locale, inject) pass through unchanged.
630
+ * @param Inner - The already-registered row component.
631
+ * @returns A follow-hosted row.
632
+ */
633
+ export function wrapFollowNodeView(Inner: ComponentType<FollowWrapProps>, motion: FollowMotionConfig = {}) {
634
+ return function TypewriterFollowNodeView(props: FollowWrapProps) {
635
+ const kind = chatNodeKind(props.node)
636
+ const directAddress = KEEP_VISIBLE_KINDS.has(kind) ? undefined : processAddress(props.node, props.sessionId)
637
+ const key = nodeKey(props.node)
638
+ const useSession = props.useSession
639
+ if (
640
+ directAddress !== undefined
641
+ && kind === 'tool-call'
642
+ && key !== undefined
643
+ && typeof useSession === 'function'
644
+ ) {
645
+ return (
646
+ <TailAwareToolFollowNodeView
647
+ Inner={Inner}
648
+ motion={motion}
649
+ props={props}
650
+ address={directAddress}
651
+ nodeKey={key}
652
+ useSession={useSession as (selector: (snapshot: unknown) => unknown) => unknown}
653
+ />
654
+ )
655
+ }
656
+ if (
657
+ directAddress === undefined
658
+ && !KEEP_VISIBLE_KINDS.has(kind)
659
+ && key !== undefined
660
+ && typeof props.sessionId === 'string'
661
+ && typeof useSession === 'function'
662
+ ) {
663
+ return (
664
+ <InferredTurnFollowNodeView
665
+ Inner={Inner}
666
+ motion={motion}
667
+ props={props}
668
+ nodeKey={key}
669
+ sessionId={props.sessionId}
670
+ useSession={useSession as (selector: (snapshot: unknown) => unknown) => unknown}
671
+ />
672
+ )
673
+ }
674
+ return <FollowNodeView Inner={Inner} motion={motion} props={props} address={directAddress} />
675
+ }
676
+ }
677
+
678
+ function TailAwareToolFollowNodeView({
679
+ Inner,
680
+ motion,
681
+ props,
682
+ address,
683
+ nodeKey: currentKey,
684
+ useSession,
685
+ }: {
686
+ readonly Inner: ComponentType<FollowWrapProps>
687
+ readonly motion: FollowMotionConfig
688
+ readonly props: FollowWrapProps
689
+ readonly address: TurnFoldAddress
690
+ readonly nodeKey: string
691
+ readonly useSession: (selector: (snapshot: unknown) => unknown) => unknown
692
+ }) {
693
+ const awaitingNext = useSession(snapshot => isAwaitingNextTurnNode(snapshot, currentKey, address.turn)) as boolean
694
+ const presentation = toolPresentation(props.node)
695
+ const group = typeof props.sessionId === 'string' && presentation !== null
696
+ ? useSession(snapshot => toolActivityGroup(
697
+ snapshot,
698
+ currentKey,
699
+ props.sessionId as string,
700
+ address.turn,
701
+ presentation.semantic,
702
+ )) as ToolActivityGroupInfo | undefined
703
+ : undefined
704
+ return (
705
+ <FollowNodeView
706
+ Inner={Inner}
707
+ motion={motion}
708
+ props={props}
709
+ address={address}
710
+ awaitingNext={awaitingNext}
711
+ toolGroup={group}
712
+ />
713
+ )
714
+ }
715
+
716
+ function InferredTurnFollowNodeView({
717
+ Inner,
718
+ motion,
719
+ props,
720
+ nodeKey: currentKey,
721
+ sessionId,
722
+ useSession,
723
+ }: {
724
+ readonly Inner: ComponentType<FollowWrapProps>
725
+ readonly motion: FollowMotionConfig
726
+ readonly props: FollowWrapProps
727
+ readonly nodeKey: string
728
+ readonly sessionId: string
729
+ readonly useSession: (selector: (snapshot: unknown) => unknown) => unknown
730
+ }) {
731
+ const turn = useSession(snapshot => followingTurn(snapshot, currentKey)) as TurnDetails | undefined
732
+ const address = turn === undefined ? undefined : { sessionId, turn: turn.turn }
733
+ const kind = chatNodeKind(props.node)
734
+ const group = useSession(snapshot => kind === 'context' && turn !== undefined
735
+ ? nativeActivityGroup(snapshot, currentKey, sessionId, turn.turn, kind)
736
+ : undefined) as ToolActivityGroupInfo | undefined
737
+ return (
738
+ <FollowNodeView
739
+ Inner={Inner}
740
+ motion={motion}
741
+ props={props}
742
+ address={address}
743
+ toolGroup={group}
744
+ turnStartTime={turn?.startTime}
745
+ />
746
+ )
747
+ }
748
+
749
+ function FollowNodeView({
750
+ Inner,
751
+ motion,
752
+ props,
753
+ address,
754
+ awaitingNext = false,
755
+ toolGroup,
756
+ turnStartTime = turnDetailsFromNode(props.node)?.startTime,
757
+ }: {
758
+ readonly Inner: ComponentType<FollowWrapProps>
759
+ readonly motion: FollowMotionConfig
760
+ readonly props: FollowWrapProps
761
+ readonly address: TurnFoldAddress | undefined
762
+ readonly awaitingNext?: boolean | undefined
763
+ readonly toolGroup?: ToolActivityGroupInfo | undefined
764
+ readonly turnStartTime?: number | undefined
765
+ }) {
766
+ const speedCpsRef = useRef(35)
767
+ const [eventElement, setEventElement] = useState<HTMLDivElement | null>(null)
768
+ const eventRef = useCallback((element: HTMLDivElement | null) => {
769
+ setEventElement(element)
770
+ }, [])
771
+ const growing = isGrowingChatNode(props.node)
772
+ const kind = chatNodeKind(props.node)
773
+ const staticCompaction = kind === 'compaction' || kind === 'manual-compaction'
774
+ useLayoutEffect(() => {
775
+ if (!staticCompaction || eventElement === null) return
776
+ const disclosure = eventElement.querySelector<HTMLElement>('[data-compaction-disclosure]')
777
+ const button = disclosure?.closest<HTMLButtonElement>('button')
778
+ if (button === null || button === undefined) return
779
+ const previousDisabled = button.disabled
780
+ const previousExpanded = button.getAttribute('aria-expanded')
781
+ button.disabled = true
782
+ button.removeAttribute('aria-expanded')
783
+ return () => {
784
+ button.disabled = previousDisabled
785
+ if (previousExpanded === null) button.removeAttribute('aria-expanded')
786
+ else button.setAttribute('aria-expanded', previousExpanded)
787
+ }
788
+ }, [eventElement, props.node, staticCompaction])
789
+ const preventCompactionExpand = staticCompaction
790
+ ? (event: MouseEvent<HTMLDivElement>) => {
791
+ event.preventDefault()
792
+ event.stopPropagation()
793
+ }
794
+ : undefined
795
+ const tool = kind === 'tool-call' ? toolPresentation(props.node) : null
796
+ const editStats = tool === null ? undefined : editDiffStats(props.node)
797
+ const [editStatsRow, setEditStatsRow] = useState<HTMLElement | null>(null)
798
+ useLayoutEffect(() => {
799
+ const row = editStats === undefined || eventElement === null
800
+ ? null
801
+ : eventElement.querySelector<HTMLElement>('[data-disclosure-row]')
802
+ setEditStatsRow(previous => previous === row ? previous : row)
803
+ }, [editStats, eventElement, props.node])
804
+ const ownsStreamSemanticIcon = tool === null && CONVERSATION_NODE_ICONS[kind] !== undefined
805
+ const toolProgressActive = tool !== null && (growing || awaitingNext)
806
+ const key = nodeKey(props.node)
807
+ const memberId = address === undefined || key === undefined ? undefined : turnProcessMemberId(address, key)
808
+ const translate = typeof props.t === 'function' ? props.t as TurnElapsedTranslator : undefined
809
+ const formatRunningElapsed = translate === undefined
810
+ ? undefined
811
+ : (ms: number) => formatTurnProcessed(ms, translate)
812
+ const inner = (
813
+ <>
814
+ {tool !== null && <ToolSemanticIcon presentation={tool} />}
815
+ {ownsStreamSemanticIcon && <ConversationNodeSemanticIcon kind={kind} />}
816
+ {createElement(Inner, props)}
817
+ {tool !== null && <NestedToolSemanticIcons owner={eventElement} revision={props.node} />}
818
+ {editStats !== undefined && editStatsRow !== null && createPortal(
819
+ <span
820
+ className={css.editStats}
821
+ data-stream-edit-stats=""
822
+ data-stream-edit-file={editDiffFilePath(props.node)}
823
+ onClick={event => {
824
+ event.stopPropagation()
825
+ event.currentTarget.closest<HTMLElement>('[data-disclosure-row][data-expandable]')?.click()
826
+ }}
827
+ >
828
+ {editStats}
829
+ </span>,
830
+ editStatsRow,
831
+ )}
832
+ </>
833
+ )
834
+ const activityRunning = growing || toolGroup?.hasGrowing === true
835
+ const headerIcon = tool !== null
836
+ ? <ToolSemanticIcon presentation={tool} className={css.toolGroupHeaderGlyph} />
837
+ : ownsStreamSemanticIcon
838
+ ? <ConversationNodeSemanticIcon kind={kind} className={css.toolGroupHeaderGlyph} />
839
+ : null
840
+ const eventAttributes = {
841
+ 'data-stream-node': kind,
842
+ 'data-stream-turn': address === undefined ? undefined : String(address.turn),
843
+ 'data-stream-state': activityRunning ? 'running' : 'settled',
844
+ 'data-stream-progress': toolProgressActive ? 'active' : undefined,
845
+ 'data-stream-tool-row': tool === null ? undefined : '',
846
+ 'data-stream-native-disclosure': tool === null ? '' : undefined,
847
+ 'data-stream-semantic-icon-owned': ownsStreamSemanticIcon ? kind : undefined,
848
+ 'data-tool-semantic': tool?.semantic,
849
+ 'data-tool-name': tool?.name,
850
+ }
851
+ const activityHeader = toolGroup !== undefined && headerIcon !== null
852
+ ? <ToolActivityGroupHeader label={toolGroup.label} icon={headerIcon} />
853
+ : null
854
+ return (
855
+ <FollowHost
856
+ active={activityRunning}
857
+ speedCpsRef={speedCpsRef}
858
+ minSpeedPxPerSec={motion.minSpeedPxPerSec}
859
+ maxSpeedPxPerSec={motion.maxSpeedPxPerSec}
860
+ >
861
+ {memberId !== undefined && address !== undefined && toolGroup !== undefined && activityHeader !== null ? (
862
+ <ToolActivityGroupMember
863
+ group={toolGroup}
864
+ address={address}
865
+ memberId={memberId}
866
+ memberOrder={nodeOrder(props.node)}
867
+ turnStartTime={turnStartTime}
868
+ formatRunningElapsed={formatRunningElapsed}
869
+ memberRef={eventRef}
870
+ eventAttributes={eventAttributes}
871
+ header={activityHeader}
872
+ >
873
+ {inner}
874
+ </ToolActivityGroupMember>
875
+ ) : memberId !== undefined && address !== undefined ? (
876
+ <TurnProcessMember
877
+ address={address}
878
+ memberId={memberId}
879
+ memberOrder={nodeOrder(props.node)}
880
+ turnStartTime={turnStartTime}
881
+ formatRunningElapsed={formatRunningElapsed}
882
+ memberRef={eventRef}
883
+ className={css.eventRow}
884
+ onClickCapture={preventCompactionExpand}
885
+ data-stream-node={kind}
886
+ data-stream-turn={address.turn}
887
+ data-stream-state={growing ? 'running' : 'settled'}
888
+ data-stream-progress={toolProgressActive ? 'active' : undefined}
889
+ data-stream-tool-row={tool === null ? undefined : ''}
890
+ data-stream-native-disclosure={tool === null ? '' : undefined}
891
+ data-stream-semantic-icon-owned={ownsStreamSemanticIcon ? kind : undefined}
892
+ data-tool-semantic={tool?.semantic}
893
+ data-tool-name={tool?.name}
894
+ >
895
+ {inner}
896
+ </TurnProcessMember>
897
+ ) : (
898
+ <div
899
+ ref={eventRef}
900
+ className={css.eventRow}
901
+ onClickCapture={preventCompactionExpand}
902
+ data-stream-node={kind}
903
+ data-stream-state={growing ? 'running' : 'settled'}
904
+ data-stream-progress={toolProgressActive ? 'active' : undefined}
905
+ data-stream-tool-row={tool === null ? undefined : ''}
906
+ data-stream-native-disclosure={tool === null ? '' : undefined}
907
+ data-stream-semantic-icon-owned={ownsStreamSemanticIcon ? kind : undefined}
908
+ data-tool-semantic={tool?.semantic}
909
+ data-tool-name={tool?.name}
910
+ >
911
+ {inner}
912
+ </div>
913
+ )}
914
+ </FollowHost>
915
+ )
916
+ }