@sakki_chin/dsh-codex-orchestrate 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,396 @@
1
+ import React from 'react'
2
+ import { XMarkdown } from '@ant-design/x-markdown'
3
+ import { Icon, Spinner } from './Icons.jsx'
4
+ import { styles } from '../styles.js'
5
+
6
+ const markdownComponents = {
7
+ p: props => <p {...props} style={{ margin: '0 0 10px' }} />,
8
+ a: props => <a {...props} style={{ color: 'var(--co-running)' }} />,
9
+ pre: props => <pre {...props} style={{ ...styles.code, margin: '10px 0' }} />,
10
+ code: props => <code {...props} style={{ padding: '1px 4px', borderRadius: 4, background: 'var(--co-code)', fontFamily: 'var(--co-font-mono)', fontSize: '.92em' }} />,
11
+ blockquote: props => <blockquote {...props} style={{ margin: '10px 0', paddingLeft: 10, borderLeft: '2px solid var(--co-border-strong)', color: 'var(--co-ink-secondary)' }} />,
12
+ h1: props => <h1 {...props} style={{ margin: '16px 0 7px', fontSize: 19, lineHeight: 1.35 }} />,
13
+ h2: props => <h2 {...props} style={{ margin: '16px 0 7px', fontSize: 16, lineHeight: 1.35 }} />,
14
+ h3: props => <h3 {...props} style={{ margin: '14px 0 6px', fontSize: 14, lineHeight: 1.35 }} />,
15
+ ul: props => <ul {...props} style={{ margin: '0 0 10px', paddingLeft: 21 }} />,
16
+ ol: props => <ol {...props} style={{ margin: '0 0 10px', paddingLeft: 21 }} />,
17
+ table: props => <div style={{ overflowX: 'auto' }}><table {...props} style={{ width: '100%', margin: '10px 0', borderCollapse: 'collapse', fontSize: 12 }} /></div>,
18
+ th: props => <th {...props} style={{ padding: '6px 8px', border: '1px solid var(--co-border)', textAlign: 'left', background: 'var(--co-raised)' }} />,
19
+ td: props => <td {...props} style={{ padding: '6px 8px', border: '1px solid var(--co-border)', textAlign: 'left' }} />,
20
+ }
21
+
22
+ function legacyTurns(node) {
23
+ if (Array.isArray(node?.turns) && node.turns.length) return node.turns
24
+ if (!node) return []
25
+ return [{
26
+ id: 'legacy_turn',
27
+ kind: 'initial',
28
+ input: node.prompt,
29
+ status: node.status,
30
+ items: node.items || [],
31
+ finalMessage: node.finalMessage,
32
+ usage: node.usage,
33
+ error: node.error,
34
+ startedAt: node.startedAt,
35
+ finishedAt: node.finishedAt,
36
+ }]
37
+ }
38
+
39
+ function orderedTurnEntries(turn) {
40
+ const entries = Array.isArray(turn.items) ? [...turn.items] : []
41
+ if (turn.finalMessage && !entries.some(entry => entry.type === 'agent_message' && entry.text === turn.finalMessage)) {
42
+ entries.push({ id: `${turn.id || 'turn'}-final`, type: 'agent_message', text: turn.finalMessage, live: false })
43
+ }
44
+ return entries
45
+ }
46
+
47
+ function timelineSegments(turn) {
48
+ const segments = []
49
+ let processItems = []
50
+ let processIndex = 0
51
+ const flush = () => {
52
+ if (!processItems.length) return
53
+ segments.push({
54
+ kind: 'process',
55
+ key: `process:${processItems[0]?.id || processIndex}`,
56
+ items: processItems,
57
+ index: processIndex,
58
+ })
59
+ processItems = []
60
+ processIndex += 1
61
+ }
62
+ for (const entry of orderedTurnEntries(turn)) {
63
+ if (!['agent_message', 'error'].includes(entry.type)) {
64
+ processItems.push(entry)
65
+ continue
66
+ }
67
+ flush()
68
+ segments.push({ kind: entry.type, key: `entry:${entry.id || segments.length}`, entry })
69
+ }
70
+ flush()
71
+ return segments
72
+ }
73
+
74
+ function processSummary(entries, active) {
75
+ if (active) {
76
+ const current = [...entries].reverse().find(item => item.live || item.status === 'in_progress') || entries.at(-1)
77
+ if (current?.type === 'reasoning') return '正在思考…'
78
+ if (current?.type === 'command_execution') return '正在运行命令…'
79
+ if (current?.type === 'file_change') return '正在修改文件…'
80
+ if (['mcp_tool_call', 'web_search', 'todo_list'].includes(current?.type)) return '正在使用工具…'
81
+ return '正在执行任务…'
82
+ }
83
+ const counts = { reasoning: 0, command_execution: 0, tool: 0, file_change: 0 }
84
+ for (const entry of entries) {
85
+ if (entry.type === 'reasoning') counts.reasoning += 1
86
+ else if (entry.type === 'command_execution') counts.command_execution += 1
87
+ else if (entry.type === 'file_change') counts.file_change += 1
88
+ else counts.tool += 1
89
+ }
90
+ return [
91
+ counts.reasoning ? '已思考' : '',
92
+ counts.command_execution ? `运行了${counts.command_execution > 1 ? ` ${counts.command_execution} 个` : ''}命令` : '',
93
+ counts.tool ? `使用了${counts.tool > 1 ? ` ${counts.tool} 个` : ''}工具` : '',
94
+ counts.file_change ? `修改了${counts.file_change > 1 ? ` ${counts.file_change} 个` : ''}文件` : '',
95
+ ].filter(Boolean).join(' · ') || '查看执行过程'
96
+ }
97
+
98
+ function entryPresentation(entry) {
99
+ const live = Boolean(entry.live) || entry.status === 'in_progress'
100
+ const failed = entry.status === 'failed' || (entry.exit_code !== undefined && entry.exit_code !== 0) || Boolean(entry.error)
101
+ if (entry.type === 'reasoning') return { label: live ? '正在思考…' : '思考过程', body: entry.text || '(暂无思考摘要)', live, failed }
102
+ if (entry.type === 'command_execution') return { label: live ? '正在运行' : '已运行', meta: entry.command || '', body: entry.aggregated_output || '(无输出)', live, failed }
103
+ if (entry.type === 'file_change') {
104
+ const paths = (entry.changes || []).map(change => change.path)
105
+ return { label: failed ? '文件变更失败' : '已修改文件', meta: paths.length > 1 ? `${paths[0]} 等 ${paths.length} 个文件` : paths[0] || '', body: entry.changes || [], live, failed }
106
+ }
107
+ if (entry.type === 'mcp_tool_call') return { label: live ? '正在调用工具' : '已调用工具', meta: [entry.server, entry.tool].filter(Boolean).join(' · '), body: entry.error || entry.result || entry.arguments || {}, live, failed }
108
+ if (entry.type === 'web_search') return { label: '已搜索网页', meta: entry.query || '', live, failed }
109
+ if (entry.type === 'todo_list') return { label: '已更新计划', meta: `${(entry.items || []).filter(item => item.completed).length}/${(entry.items || []).length}`, body: entry.items || [], live, failed }
110
+ return { label: entry.type || '工具活动', body: entry, live, failed }
111
+ }
112
+
113
+ function Disclosure({ disclosureKey, expandedKeys, onToggle, summary, children, bodyStyle = styles.activityBody }) {
114
+ const open = expandedKeys.has(disclosureKey)
115
+ return (
116
+ <div>
117
+ <button
118
+ type="button"
119
+ aria-expanded={open}
120
+ onClick={() => onToggle(disclosureKey)}
121
+ style={{ ...styles.buttonReset, ...styles.activitySummary }}
122
+ >
123
+ {summary(open)}
124
+ </button>
125
+ {open && <div style={bodyStyle}>{children}</div>}
126
+ </div>
127
+ )
128
+ }
129
+
130
+ function ActivityBody({ entry, presentation }) {
131
+ if (entry.type === 'reasoning') return <div style={styles.prose}>{presentation.body}</div>
132
+ if (entry.type === 'command_execution') return <pre style={styles.code}>{presentation.body}</pre>
133
+ if (entry.type === 'file_change') {
134
+ return <div style={{ display: 'grid', gap: 3 }}>{presentation.body.map((change, index) => (
135
+ <div key={`${change.path}:${index}`} style={{ display: 'flex', justifyContent: 'space-between', gap: 10, color: 'var(--co-ink-muted)', fontSize: 11 }}>
136
+ <code style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{change.path}</code>
137
+ <span>{change.kind}</span>
138
+ </div>
139
+ ))}</div>
140
+ }
141
+ if (entry.type === 'todo_list') {
142
+ return <div style={{ display: 'grid', gap: 3 }}>{presentation.body.map((item, index) => (
143
+ <div key={index} style={{ color: item.completed ? 'var(--co-ink-faint)' : 'var(--co-ink-muted)', fontSize: 11, textDecoration: item.completed ? 'line-through' : 'none' }}>
144
+ {item.completed ? '✓' : '○'} {item.text}
145
+ </div>
146
+ ))}</div>
147
+ }
148
+ return <pre style={styles.code}>{JSON.stringify(presentation.body, null, 2)}</pre>
149
+ }
150
+
151
+ function ActivityRow({ entry, rowKey, expandedKeys, onToggle }) {
152
+ const presentation = entryPresentation(entry)
153
+ const hasBody = presentation.body !== undefined
154
+ const line = open => (
155
+ <>
156
+ {hasBody && <Icon kind="chevron" size={12} style={{ transform: open ? 'rotate(90deg)' : 'none' }} />}
157
+ <Icon kind={entry.type} size={14} />
158
+ <span style={{ color: presentation.failed ? 'var(--co-error)' : 'var(--co-ink-secondary)', flex: 'none' }}>{presentation.label}</span>
159
+ {presentation.meta && <span style={styles.activityMeta}>{presentation.meta}</span>}
160
+ {entry.type === 'command_execution' && entry.exit_code !== undefined && <span style={{ marginLeft: 'auto', color: presentation.failed ? 'var(--co-error)' : 'var(--co-ink-faint)', fontFamily: 'var(--co-font-mono)', fontSize: 10 }}>exit {entry.exit_code}</span>}
161
+ {presentation.live && <Spinner size={12} />}
162
+ </>
163
+ )
164
+ if (!hasBody) return <div style={styles.activityRow}>{line(false)}</div>
165
+ return (
166
+ <Disclosure disclosureKey={rowKey} expandedKeys={expandedKeys} onToggle={onToggle} summary={line}>
167
+ <ActivityBody entry={entry} presentation={presentation} />
168
+ </Disclosure>
169
+ )
170
+ }
171
+
172
+ function ActivityGroup({ segment, workflowId, nodeId, turnId, expandedKeys, onToggle }) {
173
+ const groupKey = `${workflowId}/${nodeId}/${turnId}/activity/${segment.key}`
174
+ const active = segment.items.some(entry => entry.live || entry.status === 'in_progress')
175
+ const types = [...new Set(segment.items.map(entry => entry.type))]
176
+ const groupIcon = types.length === 1 ? types[0] : 'tool'
177
+ return (
178
+ <div data-scroll-key={`${turnId}/${segment.key}`}>
179
+ <Disclosure
180
+ disclosureKey={groupKey}
181
+ expandedKeys={expandedKeys}
182
+ onToggle={onToggle}
183
+ summary={open => <>
184
+ <Icon kind={groupIcon} size={14} />
185
+ <span>{processSummary(segment.items, active)}</span>
186
+ {active && <Spinner size={12} />}
187
+ <Icon kind="chevron" size={12} style={{ marginLeft: 1, transform: open ? 'rotate(90deg)' : 'none' }} />
188
+ </>}
189
+ >
190
+ {segment.items.map((entry, index) => (
191
+ <ActivityRow
192
+ key={entry.id || `${entry.type}:${index}`}
193
+ entry={entry}
194
+ rowKey={`${groupKey}/${entry.id || `${entry.type}:${index}`}`}
195
+ expandedKeys={expandedKeys}
196
+ onToggle={onToggle}
197
+ />
198
+ ))}
199
+ </Disclosure>
200
+ </div>
201
+ )
202
+ }
203
+
204
+ function MarkdownMessage({ entry, scrollKey }) {
205
+ return (
206
+ <div data-entry-id={entry.id} data-scroll-key={scrollKey} style={styles.agentMessage}>
207
+ <XMarkdown
208
+ content={String(entry.text || '')}
209
+ components={markdownComponents}
210
+ openLinksInNewTab
211
+ escapeRawHtml
212
+ disableDefaultStyles
213
+ style={{ color: 'var(--co-ink)', fontSize: 13, lineHeight: 1.75 }}
214
+ streaming={entry.live ? { hasNextChunk: true, enableAnimation: false, tail: false } : undefined}
215
+ />
216
+ {entry.live && <span aria-label="输出中" style={{ color: 'var(--co-running)' }}> ▍</span>}
217
+ </div>
218
+ )
219
+ }
220
+
221
+ function formatTokens(value) {
222
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`
223
+ if (value >= 1000) return `${(value / 1000).toFixed(value >= 100_000 ? 0 : 1)}K`
224
+ return String(value)
225
+ }
226
+
227
+ function formatDuration(startedAt, finishedAt) {
228
+ const milliseconds = new Date(finishedAt) - new Date(startedAt)
229
+ if (!Number.isFinite(milliseconds) || milliseconds < 0) return ''
230
+ const seconds = Math.round(milliseconds / 1000)
231
+ if (seconds < 60) return `${seconds}秒`
232
+ return `${Math.floor(seconds / 60)}分 ${seconds % 60}秒`
233
+ }
234
+
235
+ function turnText(turn) {
236
+ const messages = (turn.items || []).filter(item => item.type === 'agent_message').map(item => item.text).filter(Boolean)
237
+ if (turn.finalMessage && !messages.includes(turn.finalMessage)) messages.push(turn.finalMessage)
238
+ return messages.join('\n\n')
239
+ }
240
+
241
+ async function copyText(text) {
242
+ if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(text)
243
+ const textarea = document.createElement('textarea')
244
+ textarea.value = text
245
+ textarea.style.position = 'fixed'
246
+ textarea.style.opacity = '0'
247
+ document.body.append(textarea)
248
+ textarea.select()
249
+ document.execCommand('copy')
250
+ textarea.remove()
251
+ }
252
+
253
+ function TurnActions({ turn, toast }) {
254
+ const text = turnText(turn)
255
+ const duration = turn.startedAt && turn.finishedAt ? formatDuration(turn.startedAt, turn.finishedAt) : ''
256
+ const input = turn.usage?.input_tokens || 0
257
+ const output = turn.usage?.output_tokens || 0
258
+ return (
259
+ <div style={styles.actions} aria-label="本轮操作与统计">
260
+ <button
261
+ type="button"
262
+ disabled={!text}
263
+ aria-label="复制本轮回复"
264
+ title="复制"
265
+ onClick={async () => { await copyText(text); toast('已复制本轮回复') }}
266
+ style={{ ...styles.buttonReset, ...styles.action, width: 26, justifyContent: 'center', opacity: text ? 1 : 0.35 }}
267
+ >
268
+ <Icon kind="copy" size={16} />
269
+ </button>
270
+ {turn.usage && <span style={styles.action} title={`输入 ${input} · 输出 ${output}`}><Icon kind="usage" size={15} />用量 {formatTokens(input + output)} tok</span>}
271
+ {duration && <span style={styles.action}><Icon kind="clock" size={15} />用时 {duration}</span>}
272
+ {turn.finishedAt && <time dateTime={turn.finishedAt} title={`完成于 ${new Date(turn.finishedAt).toLocaleString('zh-CN')}`}>{new Date(turn.finishedAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })}</time>}
273
+ </div>
274
+ )
275
+ }
276
+
277
+ function ConversationTurn({ turn, workflowId, node, expandedKeys, onToggle, toast }) {
278
+ const segments = timelineSegments(turn)
279
+ const entries = orderedTurnEntries(turn)
280
+ const messageCount = entries.filter(entry => entry.type === 'agent_message').length
281
+ const errors = entries.filter(entry => entry.type === 'error')
282
+ return (
283
+ <section style={styles.turn} data-status={turn.status} data-scroll-key={`turn:${turn.id || 'turn'}`}>
284
+ <div style={styles.user}>{turn.input || node.prompt}</div>
285
+ <div style={styles.assistant}>
286
+ <div style={styles.stream} role="group" aria-label="Codex 输出时间线">
287
+ {segments.map(segment => {
288
+ if (segment.kind === 'process') return (
289
+ <ActivityGroup key={segment.key} segment={segment} workflowId={workflowId} nodeId={node.id} turnId={turn.id || 'turn'} expandedKeys={expandedKeys} onToggle={onToggle} />
290
+ )
291
+ if (segment.kind === 'agent_message') return <MarkdownMessage key={segment.key} entry={segment.entry} scrollKey={`${turn.id}/${segment.key}`} />
292
+ return <div key={segment.key} data-scroll-key={`${turn.id}/${segment.key}`} style={styles.agentError}>{segment.entry.message || ''}</div>
293
+ })}
294
+ </div>
295
+ {turn.error && !errors.some(entry => entry.message === turn.error) && <div style={styles.agentError}>{turn.error}</div>}
296
+ {!entries.length && ['pending', 'blocked', 'queued'].includes(turn.status) && <div style={styles.turnState}>{turn.status === 'blocked' ? '等待前置任务…' : '等待 Codex 开始…'}</div>}
297
+ {!messageCount && turn.status === 'running' && <div style={{ ...styles.turnState, display: 'flex', gap: 6, alignItems: 'center' }}><Spinner size={13} />Codex 正在处理…</div>}
298
+ {!['pending', 'blocked'].includes(turn.status) && <TurnActions turn={turn} toast={toast} />}
299
+ </div>
300
+ </section>
301
+ )
302
+ }
303
+
304
+ function captureViewport(container, nodeId) {
305
+ const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight
306
+ const snapshot = { nodeId, scrollTop: container.scrollTop, stickToBottom: distanceFromBottom < 80, anchorKey: null, anchorOffset: 0 }
307
+ if (snapshot.stickToBottom) return snapshot
308
+ const viewport = container.getBoundingClientRect()
309
+ const anchor = [...container.querySelectorAll('[data-scroll-key]')].find(candidate => {
310
+ const rect = candidate.getBoundingClientRect()
311
+ return rect.bottom > viewport.top + 1 && rect.top < viewport.bottom - 1
312
+ })
313
+ if (anchor) {
314
+ snapshot.anchorKey = anchor.dataset.scrollKey
315
+ snapshot.anchorOffset = anchor.getBoundingClientRect().top - viewport.top
316
+ }
317
+ return snapshot
318
+ }
319
+
320
+ export function ConversationThread({ workflow, node, expandedKeys, onToggleExpanded, toast }) {
321
+ const containerRef = React.useRef(null)
322
+ const threadRef = React.useRef(null)
323
+ const followOutputRef = React.useRef(true)
324
+ const pendingViewportRef = React.useRef(null)
325
+ const previousNodeIdRef = React.useRef(null)
326
+ const turns = legacyTurns(node)
327
+ const contentSignature = JSON.stringify(node ? {
328
+ id: node.id,
329
+ status: node.status,
330
+ activeTurnId: node.activeTurnId,
331
+ turns,
332
+ } : null)
333
+
334
+ React.useLayoutEffect(() => {
335
+ const container = containerRef.current
336
+ if (!container) return undefined
337
+ const nodeChanged = previousNodeIdRef.current !== node?.id
338
+ previousNodeIdRef.current = node?.id || null
339
+ const snapshot = pendingViewportRef.current
340
+ if (nodeChanged || followOutputRef.current || snapshot?.stickToBottom) {
341
+ container.scrollTop = container.scrollHeight
342
+ } else if (snapshot?.nodeId === node?.id) {
343
+ const anchor = snapshot.anchorKey
344
+ ? [...container.querySelectorAll('[data-scroll-key]')].find(item => item.dataset.scrollKey === snapshot.anchorKey)
345
+ : null
346
+ if (anchor) {
347
+ const currentOffset = anchor.getBoundingClientRect().top - container.getBoundingClientRect().top
348
+ container.scrollTop += currentOffset - snapshot.anchorOffset
349
+ } else {
350
+ container.scrollTop = snapshot.scrollTop
351
+ }
352
+ }
353
+ pendingViewportRef.current = null
354
+ return () => {
355
+ if (containerRef.current) pendingViewportRef.current = captureViewport(containerRef.current, node?.id)
356
+ }
357
+ }, [contentSignature, node?.id])
358
+
359
+ React.useEffect(() => {
360
+ if (!globalThis.ResizeObserver || !threadRef.current) return undefined
361
+ const observer = new ResizeObserver(() => {
362
+ const container = containerRef.current
363
+ if (container && followOutputRef.current) container.scrollTop = container.scrollHeight
364
+ })
365
+ observer.observe(threadRef.current)
366
+ return () => observer.disconnect()
367
+ }, [])
368
+
369
+ const handleScroll = event => {
370
+ if (!event.nativeEvent?.isTrusted) return
371
+ const container = event.currentTarget
372
+ followOutputRef.current = container.scrollHeight - container.scrollTop - container.clientHeight < 80
373
+ }
374
+
375
+ return (
376
+ <main ref={containerRef} onScroll={handleScroll} style={styles.conversation} aria-live="polite">
377
+ <div ref={threadRef} style={styles.thread}>
378
+ {!workflow && <div style={styles.empty}>工作流派发后,这里显示每个任务的 Codex 对话。</div>}
379
+ {workflow && !node && <div style={styles.empty}>选择一个任务查看对话。</div>}
380
+ {workflow && node && turns.map(turn => (
381
+ <ConversationTurn
382
+ key={turn.id || 'legacy_turn'}
383
+ turn={turn}
384
+ workflowId={workflow.workflowId}
385
+ node={node}
386
+ expandedKeys={expandedKeys}
387
+ onToggle={onToggleExpanded}
388
+ toast={toast}
389
+ />
390
+ ))}
391
+ </div>
392
+ </main>
393
+ )
394
+ }
395
+
396
+ export { legacyTurns, orderedTurnEntries, timelineSegments }
@@ -0,0 +1,47 @@
1
+ import React from 'react'
2
+
3
+ const paths = {
4
+ chevron: ['M5.5 3.5 10 8l-4.5 4.5'],
5
+ reasoning: ['M8 1.5a5 5 0 0 0-3.2 8.8c.5.4.8 1 .8 1.7v.5h4.8V12c0-.7.3-1.3.8-1.7A5 5 0 0 0 8 1.5Z', 'M5.8 14.5h4.4'],
6
+ command_execution: ['M2 2.5h12v11H2z', 'm4 5 2 2-2 2', 'M8.5 9.5H11'],
7
+ file_change: ['M3 1.5h6l3 3v10H3z', 'M9 1.5v3h3', 'M5 8h5M5 10.5h5'],
8
+ tool: ['M8 2.2a2.9 2.9 0 0 0-3.4 3.7L1.9 8.6l2 2 2.7-2.7A2.9 2.9 0 0 0 10.3 4L14 7.7l-6.3 6.3-2-2L8.4 9.3'],
9
+ web_search: ['M7 2a5 5 0 1 0 0 10A5 5 0 0 0 7 2Z', 'm10.8 10.8 3 3'],
10
+ todo_list: ['M3 4h.01M6 4h7M3 8h.01M6 8h7M3 12h.01M6 12h7'],
11
+ copy: ['M5.5 5.5h8v8h-8z', 'M10.5 5.5v-3h-8v8h3'],
12
+ usage: ['M3 4.2c0 1 2.2 1.8 5 1.8s5-.8 5-1.8-2.2-1.8-5-1.8-5 .8-5 1.8Z', 'M3 4.2v3.6c0 1 2.2 1.8 5 1.8s5-.8 5-1.8V4.2M3 7.8v3.6c0 1 2.2 1.8 5 1.8s5-.8 5-1.8V7.8'],
13
+ clock: ['M8 1.8a6.2 6.2 0 1 0 0 12.4A6.2 6.2 0 0 0 8 1.8Z', 'M8 4.5v3.8l2.5 1.5'],
14
+ send: ['M2.5 8h11M9 3.5 13.5 8 9 12.5'],
15
+ stop: ['M4.5 4.5h7v7h-7z'],
16
+ plus: ['M8 2v12M2 8h12'],
17
+ shield: ['M8 1.5 13 3.6v3.8c0 3.1-2 5.3-5 6.6-3-1.3-5-3.5-5-6.6V3.6L8 1.5Z'],
18
+ }
19
+
20
+ export function Icon({ kind, size = 15, style }) {
21
+ return (
22
+ <svg
23
+ viewBox="0 0 16 16"
24
+ aria-hidden="true"
25
+ fill="none"
26
+ stroke="currentColor"
27
+ strokeWidth="1.35"
28
+ strokeLinecap="round"
29
+ strokeLinejoin="round"
30
+ style={{ width: size, height: size, flex: 'none', ...style }}
31
+ >
32
+ {(paths[kind] || paths.tool).map((d, index) => <path d={d} key={index} />)}
33
+ </svg>
34
+ )
35
+ }
36
+
37
+ export function Spinner({ size = 16 }) {
38
+ return (
39
+ <svg viewBox="0 0 16 16" aria-hidden="true" style={{ width: size, height: size, flex: 'none' }}>
40
+ <circle cx="8" cy="8" r="5.5" fill="none" stroke="var(--co-border-strong)" strokeWidth="2.5" />
41
+ <path d="M8 2.5a5.5 5.5 0 0 1 5.5 5.5" fill="none" stroke="var(--co-ink-secondary)" strokeWidth="2.5" strokeLinecap="round">
42
+ <animateTransform attributeName="transform" type="rotate" from="0 8 8" to="360 8 8" dur=".9s" repeatCount="indefinite" />
43
+ </path>
44
+ </svg>
45
+ )
46
+ }
47
+
@@ -0,0 +1,68 @@
1
+ import React from 'react'
2
+ import { useOrchestrateData } from '../hooks/useOrchestrateData.jsx'
3
+ import { styles } from '../styles.js'
4
+ import { Composer } from './Composer.jsx'
5
+ import { ConversationThread } from './ConversationThread.jsx'
6
+ import { ToastProvider, useToast } from './Toast.jsx'
7
+ import { WorkflowRail } from './WorkflowRail.jsx'
8
+
9
+ function WorkbenchContent({ initialWorkflowId, tabId }) {
10
+ const data = useOrchestrateData(initialWorkflowId)
11
+ const toast = useToast()
12
+ const [currentNodeId, setCurrentNodeId] = React.useState(null)
13
+ const [expandedKeys, setExpandedKeys] = React.useState(() => new Set())
14
+ const workflowId = data.workflow?.workflowId || null
15
+
16
+ React.useEffect(() => {
17
+ setCurrentNodeId(data.workflow?.nodes?.[0]?.id || null)
18
+ }, [workflowId])
19
+
20
+ React.useEffect(() => {
21
+ if (!data.workflow) return
22
+ setCurrentNodeId(current => data.workflow.nodes.some(node => node.id === current)
23
+ ? current
24
+ : data.workflow.nodes[0]?.id || null)
25
+ }, [data.workflow])
26
+
27
+ const currentNode = data.workflow?.nodes?.find(node => node.id === currentNodeId) || null
28
+ const toggleExpanded = React.useCallback(key => {
29
+ setExpandedKeys(current => {
30
+ const next = new Set(current)
31
+ if (next.has(key)) next.delete(key)
32
+ else next.add(key)
33
+ return next
34
+ })
35
+ }, [])
36
+
37
+ return (
38
+ <section style={{ ...styles.workbench, position: 'relative' }} data-tab-id={tabId} aria-busy={data.loading}>
39
+ <WorkflowRail
40
+ workflow={data.workflow}
41
+ workflows={data.workflows}
42
+ selectedWorkflowId={data.selectedWorkflowId}
43
+ currentNodeId={currentNodeId}
44
+ listError={data.listError}
45
+ onSelectWorkflow={data.selectWorkflow}
46
+ onSelectNode={setCurrentNodeId}
47
+ />
48
+ <ConversationThread
49
+ workflow={data.workflow}
50
+ node={currentNode}
51
+ expandedKeys={expandedKeys}
52
+ onToggleExpanded={toggleExpanded}
53
+ toast={toast}
54
+ />
55
+ <Composer workflow={data.workflow} node={currentNode} refresh={data.refresh} toast={toast} />
56
+ {(data.listError || data.stateError) && (
57
+ <div role="alert" style={{ ...styles.alert, position: 'absolute', zIndex: 5, top: 68, left: 0, right: 0, background: 'var(--co-bg)' }}>
58
+ {data.stateError || data.listError}
59
+ </div>
60
+ )}
61
+ </section>
62
+ )
63
+ }
64
+
65
+ export function OrchestrateWorkbench(props) {
66
+ return <ToastProvider><WorkbenchContent {...props} /></ToastProvider>
67
+ }
68
+
@@ -0,0 +1,26 @@
1
+ import React from 'react'
2
+ import { styles } from '../styles.js'
3
+
4
+ const ToastContext = React.createContext(() => {})
5
+
6
+ export function ToastProvider({ children }) {
7
+ const [message, setMessage] = React.useState('')
8
+ const timerRef = React.useRef(null)
9
+ const toast = React.useCallback(text => {
10
+ window.clearTimeout(timerRef.current)
11
+ setMessage(String(text || ''))
12
+ timerRef.current = window.setTimeout(() => setMessage(''), 2600)
13
+ }, [])
14
+ React.useEffect(() => () => window.clearTimeout(timerRef.current), [])
15
+ return (
16
+ <ToastContext.Provider value={toast}>
17
+ {children}
18
+ {message && <div role="status" aria-live="polite" style={styles.toast}>{message}</div>}
19
+ </ToastContext.Provider>
20
+ )
21
+ }
22
+
23
+ export function useToast() {
24
+ return React.useContext(ToastContext)
25
+ }
26
+