@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.
- package/LICENSE +21 -0
- package/README.en.md +123 -0
- package/README.md +123 -0
- package/conversation-ui-off.yml +3 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +3672 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +302 -0
- package/package.json +182 -0
- package/src/boot-config.ts +28 -0
- package/src/client/AnimatedDisclosure.tsx +89 -0
- package/src/client/ConversationCard.module.css +253 -0
- package/src/client/ConversationCard.tsx +122 -0
- package/src/client/DeliverablesCard.module.css +187 -0
- package/src/client/DeliverablesCard.tsx +142 -0
- package/src/client/FollowHost.tsx +25 -0
- package/src/client/ToolActivityGroup.tsx +341 -0
- package/src/client/TurnPreludeUserNodeView.tsx +258 -0
- package/src/client/TurnProcessMember.tsx +92 -0
- package/src/client/TypewriterAssistantNodeView.module.css +1260 -0
- package/src/client/TypewriterAssistantNodeView.tsx +390 -0
- package/src/client/TypewriterToolNodeView.tsx +916 -0
- package/src/client/conversation-ui-card-controller.ts +162 -0
- package/src/client/conversation-ui-settings-api.ts +65 -0
- package/src/client/deliverables.ts +263 -0
- package/src/client/index.ts +242 -0
- package/src/client/locales.ts +74 -0
- package/src/client/teleprompterGlide.ts +590 -0
- package/src/client/turnElapsed.ts +27 -0
- package/src/client/turnProcessFold.ts +224 -0
- package/src/client/useConversationContent.ts +471 -0
- package/src/client/useFpsGuard.ts +84 -0
- package/src/config.ts +44 -0
- package/src/css-modules.d.ts +9 -0
- package/src/index.ts +2 -0
- package/src/package-meta.ts +39 -0
- package/src/plugin.ts +155 -0
- package/src/profile-installation.ts +121 -0
- package/src/settings-api.ts +33 -0
- 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
|
+
}
|