@huaqiu/component-gen-app 0.3.6

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,127 @@
1
+ /**
2
+ * `@huaqiu/component-gen-app` — reusable generation job runner.
3
+ *
4
+ * Encapsulates the start → SSE-stream → terminal-state lifecycle for one
5
+ * page's active generation. The page calls `run(req)` and reacts to `phase`.
6
+ * `ports.jobEvents` is expected to replay the job's current state first, so a
7
+ * job that finished before subscription still lands correctly.
8
+ */
9
+ import { useCallback, useEffect, useRef, useState } from 'react'
10
+ import type { ComponentGenPorts, HistoryEntry, JobEvent, JobKind, JobState, StartJobRequest } from '../ports.js'
11
+
12
+ export type GenPhase = 'idle' | 'running' | 'needs_confirmation' | 'completed' | 'failed' | 'cancelled'
13
+
14
+ export interface UseJobRunnerResult {
15
+ phase: GenPhase
16
+ progress: string
17
+ dimensions: Record<string, unknown> | null
18
+ pkgType: string | null
19
+ fileName: string | null
20
+ result: Record<string, unknown>
21
+ error: string
22
+ jobId: string | null
23
+ run: (req: StartJobRequest) => Promise<void>
24
+ cancel: () => Promise<void>
25
+ clear: () => void
26
+ /** Render an already-generated history entry in the 'completed' stage. */
27
+ loadHistory: (entry: HistoryEntry) => void
28
+ }
29
+
30
+ export function useJobRunner(ports: ComponentGenPorts): UseJobRunnerResult {
31
+ const [phase, setPhase] = useState<GenPhase>('idle')
32
+ const [progress, setProgress] = useState('')
33
+ const [dimensions, setDimensions] = useState<Record<string, unknown> | null>(null)
34
+ const [pkgType, setPkgType] = useState<string | null>(null)
35
+ const [fileName, setFileName] = useState<string | null>(null)
36
+ const [result, setResult] = useState<Record<string, unknown>>({})
37
+ const [error, setError] = useState('')
38
+ const [jobId, setJobId] = useState<string | null>(null)
39
+ const unsubRef = useRef<(() => void) | null>(null)
40
+
41
+ const cleanup = useCallback((): void => {
42
+ unsubRef.current?.()
43
+ unsubRef.current = null
44
+ }, [])
45
+
46
+ useEffect(() => cleanup, [cleanup])
47
+
48
+ const onEvent = useCallback((e: JobEvent): void => {
49
+ switch (e.type) {
50
+ case 'progress':
51
+ setProgress(e.message)
52
+ break
53
+ case 'needs_confirmation':
54
+ setPhase('needs_confirmation')
55
+ setDimensions(e.dimensions)
56
+ setPkgType(e.pkgType ?? null)
57
+ setFileName(e.fileName ?? null)
58
+ break
59
+ case 'completed':
60
+ setPhase('completed')
61
+ setResult(e.job.result ?? {})
62
+ setProgress('')
63
+ break
64
+ case 'failed':
65
+ setPhase('failed')
66
+ setError(e.error)
67
+ setResult(e.result ?? {})
68
+ setProgress('')
69
+ break
70
+ case 'cancelled':
71
+ setPhase('cancelled')
72
+ setProgress('')
73
+ break
74
+ }
75
+ }, [])
76
+
77
+ const run = useCallback(async (req: StartJobRequest): Promise<void> => {
78
+ cleanup()
79
+ setPhase('running')
80
+ setProgress('')
81
+ setDimensions(null)
82
+ setPkgType(null)
83
+ setFileName(null)
84
+ setResult({})
85
+ setError('')
86
+ const job = await ports.startJob(req)
87
+ setJobId(job.id)
88
+ unsubRef.current = ports.jobEvents(job.id, onEvent)
89
+ }, [ports, cleanup, onEvent])
90
+
91
+ const cancel = useCallback(async (): Promise<void> => {
92
+ const id = jobId
93
+ if (!id) return
94
+ try { await ports.abortJob(id) } catch { /* best effort */ }
95
+ cleanup()
96
+ setPhase('cancelled')
97
+ }, [ports, jobId, cleanup])
98
+
99
+ const clear = useCallback((): void => {
100
+ cleanup()
101
+ setPhase('idle')
102
+ setProgress('')
103
+ setDimensions(null)
104
+ setResult({})
105
+ setError('')
106
+ setJobId(null)
107
+ }, [cleanup])
108
+
109
+ const loadHistory = useCallback((entry: HistoryEntry): void => {
110
+ cleanup()
111
+ setPhase('completed')
112
+ setProgress('')
113
+ setDimensions(null)
114
+ setPkgType(entry.input?.packageType ?? null)
115
+ setFileName(null)
116
+ setResult({
117
+ artifact: { id: entry.result?.artifactId },
118
+ filename: entry.result?.filename,
119
+ ...(entry.input?.dimensions ? { dimensions: entry.input.dimensions } : {}),
120
+ })
121
+ setError('')
122
+ // jobId keys the result stage so re-opening a different entry re-renders.
123
+ setJobId(`history:${entry.id}`)
124
+ }, [cleanup])
125
+
126
+ return { phase, progress, dimensions, pkgType, fileName, result, error, jobId, run, cancel, clear, loadHistory }
127
+ }
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `@huaqiu/component-gen-app` — public exports.
3
+ *
4
+ * The app is a portable React library: the DSH plugin imports these components
5
+ * and wires `ComponentGenPorts`; the standalone build mounts `main.tsx`.
6
+ */
7
+ export { ComponentGenApp, type ComponentGenAppProps } from './App.js'
8
+ export { SymbolGenPage, type SymbolGenPageProps } from './pages/SymbolGenPage.js'
9
+ export { FootprintGenPage, type FootprintGenPageProps } from './pages/FootprintGenPage.js'
10
+
11
+ export { GeometryEditor, type GeometryEditorProps } from './components/GeometryEditor.js'
12
+ export { PreviewStage, type PreviewStageProps } from './components/PreviewStage.js'
13
+ export { ResultStage, type ResultStageProps } from './components/ResultStage.js'
14
+ export { UploadInput, type UploadInputProps, fileToDataUrl } from './components/UploadInput.js'
15
+ export { HistoryPanel, type HistoryPanelProps } from './components/HistoryPanel.js'
16
+
17
+ export { createHttpPorts, defaultArtifactsBase, type HttpPortsOptions } from './api/component-gen-client.js'
18
+ export type {
19
+ ComponentGenPorts,
20
+ ComponentGenAuthPort,
21
+ ComponentGenConfig,
22
+ ComponentGenPage,
23
+ HistoryEntry,
24
+ HistoryPage,
25
+ HistoryPatch,
26
+ HistoryQuery,
27
+ JobEvent,
28
+ JobInput,
29
+ JobKind,
30
+ JobState,
31
+ StartJobRequest,
32
+ } from './ports.js'
33
+
34
+ export { translateFor, translate, defaultT, type Translate } from './copy/index.js'
35
+ export { ZH, EN } from './copy/index.js'
36
+
37
+ export { injectAppStyles, removeAppStyles, APP_STYLE_ID } from './styles/inject.js'
package/src/main.tsx ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `@huaqiu/component-gen-app` — standalone browser entry (vite).
3
+ *
4
+ * Served by `@huaqiu/component-gen-server` alongside the API. Opens the page
5
+ * from `?page=symbol|footprint`. Auth is read from the dsh-auth session route
6
+ * the standalone server mounts (`/api/v1/huaqiu/auth/session`); login opens
7
+ * the official auth.eda.cn page. See the standalone README for the auth
8
+ * flow (the production DSH/EDA integrations use the real dsh-auth client).
9
+ */
10
+ import { StrictMode } from 'react'
11
+ import { createRoot } from 'react-dom/client'
12
+ import { ComponentGenApp } from './App.js'
13
+ import { createHttpPorts } from './api/component-gen-client.js'
14
+ import { injectAppStyles } from './styles/inject.js'
15
+ import type { ComponentGenAuthPort, ComponentGenPage, ComponentGenPorts } from './ports.js'
16
+
17
+ injectAppStyles()
18
+
19
+ const AUTH_BASE = '/api/v1/huaqiu/auth'
20
+
21
+ /** Read auth state from the dsh-auth session route (standalone server). */
22
+ function createStandaloneAuth(): ComponentGenAuthPort {
23
+ let pollTimer: ReturnType<typeof setInterval> | null = null
24
+ const listeners = new Set<(authenticated: boolean) => void>()
25
+ const readState = async (): Promise<{ authenticated: boolean; user: { nickname?: string } | null }> => {
26
+ try {
27
+ const res = await fetch(`${AUTH_BASE}/session`, { headers: { accept: 'application/json' } })
28
+ if (!res.ok) return { authenticated: false, user: null }
29
+ const body = (await res.json()) as { authenticated?: boolean; user?: { nickname?: string } | null }
30
+ return { authenticated: body.authenticated === true, user: body.user ?? null }
31
+ } catch {
32
+ return { authenticated: false, user: null }
33
+ }
34
+ }
35
+ const startPolling = (): void => {
36
+ if (pollTimer) return
37
+ let last = false
38
+ void readState().then((s) => { last = s.authenticated })
39
+ pollTimer = setInterval(() => {
40
+ void readState().then((s) => {
41
+ if (s.authenticated !== last) {
42
+ last = s.authenticated
43
+ for (const cb of [...listeners]) cb(s.authenticated)
44
+ }
45
+ })
46
+ }, 2000)
47
+ }
48
+ startPolling()
49
+ return {
50
+ async isAuthenticated() { return (await readState()).authenticated },
51
+ async getUserInfo() { return (await readState()).user },
52
+ async login() {
53
+ // Official login page in a new tab; the dsh-auth session route is the
54
+ // source of truth the poller watches.
55
+ window.open('https://auth.eda.cn/', '_blank', 'noopener')
56
+ },
57
+ onAuthStateChanged(cb) {
58
+ listeners.add(cb)
59
+ return () => { listeners.delete(cb) }
60
+ },
61
+ }
62
+ }
63
+
64
+ function params(): URLSearchParams {
65
+ return new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '')
66
+ }
67
+
68
+ function mount(): void {
69
+ const rootEl = document.getElementById('root')
70
+ if (!rootEl) return
71
+ const page: ComponentGenPage = params().get('page') === 'symbol' ? 'symbol' : 'footprint'
72
+ const lang = params().get('lang') ?? undefined
73
+ const ports: ComponentGenPorts = createHttpPorts({
74
+ base: '/api/v1/huaqiu/component-gen',
75
+ artifactsBase: '/api/v1/huaqiu/artifacts',
76
+ auth: createStandaloneAuth(),
77
+ })
78
+ createRoot(rootEl).render(
79
+ <StrictMode>
80
+ <ComponentGenApp ports={ports} page={page} lang={lang} />
81
+ </StrictMode>,
82
+ )
83
+ }
84
+
85
+ mount()
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `@huaqiu/component-gen-app` — Footprint generation page.
3
+ *
4
+ * Two workflows, both driven by the app itself (single-HIL):
5
+ * 1. needs_confirmation — extract → dimension editor → confirm →
6
+ * generate-footprint from the human-approved values.
7
+ * 2. direct generation — extract returns a standard footprint immediately
8
+ * (fast path) → preview + download.
9
+ */
10
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
11
+ import type { ComponentGenConfig, ComponentGenPorts, ReopenRequest } from '../ports.js'
12
+ import type { Translate } from '../copy/index.js'
13
+ import type { DimensionValues } from '../utils/dims.js'
14
+ import { UploadInput } from '../components/UploadInput.js'
15
+ import { GeometryEditor } from '../components/GeometryEditor.js'
16
+ import { ResultStage } from '../components/ResultStage.js'
17
+ import { useAuthGate } from '../hooks/useAuthGate.js'
18
+ import { useJobRunner } from '../hooks/useJobRunner.js'
19
+
20
+ export interface FootprintGenPageProps {
21
+ ports: ComponentGenPorts
22
+ t: Translate
23
+ /** reopen a generated history entry into the completed stage. */
24
+ reopen?: ReopenRequest | null
25
+ }
26
+
27
+ export function FootprintGenPage({ ports, t, reopen = null }: FootprintGenPageProps): ReactElement {
28
+ const auth = useAuthGate(ports)
29
+ const runner = useJobRunner(ports)
30
+ const [config, setConfig] = useState<ComponentGenConfig | null>(null)
31
+ const [imageDataUrl, setImageDataUrl] = useState<string | null>(null)
32
+ const [file, setFile] = useState<File | null>(null)
33
+ const [hint, setHint] = useState('')
34
+
35
+ // Stable refs so the reopen effect only re-runs when the request changes.
36
+ const runnerRef = useRef(runner)
37
+ runnerRef.current = runner
38
+ const portsRef = useRef(ports)
39
+ portsRef.current = ports
40
+
41
+ // Reopen: load the generated artifact into the result stage and restore the
42
+ // source image + package-type hint so the user can inspect / regenerate.
43
+ useEffect(() => {
44
+ if (!reopen) return
45
+ const { entry } = reopen
46
+ runnerRef.current.loadHistory(entry)
47
+ if (entry.input?.imageId) {
48
+ portsRef.current.inputImage(entry.input.imageId)
49
+ .then((dataUrl) => setImageDataUrl(dataUrl))
50
+ .catch(() => { /* best effort */ })
51
+ }
52
+ if (entry.input?.packageType) setHint(entry.input.packageType)
53
+ }, [reopen])
54
+
55
+ useEffect(() => {
56
+ ports.config().then(setConfig).catch(() => { /* best effort */ })
57
+ }, [ports])
58
+
59
+ const maxBytes = config?.limits.imageBytes ?? 4 * 1024 * 1024
60
+ const authed = auth.phase === 'authenticated'
61
+ const busy = runner.phase === 'running'
62
+
63
+ const extract = useCallback((): void => {
64
+ if (!imageDataUrl) return
65
+ void runner.run({
66
+ kind: 'extract-footprint',
67
+ input: {
68
+ imageDataUrl,
69
+ ...(hint.trim() ? { packageType: hint.trim() } : {}),
70
+ },
71
+ })
72
+ }, [imageDataUrl, hint, runner])
73
+
74
+ const confirmDimensions = useCallback((values: DimensionValues, edited: Record<string, boolean>): void => {
75
+ void runner.run({
76
+ kind: 'generate-footprint',
77
+ input: {
78
+ // Re-stamp the source image so the server keeps an authoritative copy
79
+ // and the generated history entry can reopen it (the extract job's
80
+ // entry is not the one shown in history).
81
+ imageDataUrl: imageDataUrl ?? undefined,
82
+ packageType: (runner.pkgType ?? hint.trim()) || undefined,
83
+ fileName: runner.fileName ?? undefined,
84
+ dimensions: values,
85
+ edited,
86
+ },
87
+ })
88
+ }, [runner, hint, imageDataUrl])
89
+
90
+ const cancelConfirm = useCallback((): void => {
91
+ runner.clear()
92
+ }, [runner])
93
+
94
+ const resultKey = useMemo(() => `${runner.jobId ?? 'none'}:${runner.phase}`, [runner.jobId, runner.phase])
95
+
96
+ return (
97
+ <div className="cga-app">
98
+ <div className="cga-panel">
99
+ <div className="cga-panel__body">
100
+ {auth.phase === 'unknown' ? <div className="cga-progress"><span className="cga-spinner" />{t('app.loading')}</div> : null}
101
+ {auth.phase === 'unauthenticated'
102
+ ? (
103
+ <div className="cga-auth">
104
+ <span>{t('auth.loginRequired')}</span>
105
+ <button type="button" className="cga-btn" onClick={auth.login}>{t('auth.login')}</button>
106
+ </div>
107
+ )
108
+ : null}
109
+
110
+ {runner.phase !== 'needs_confirmation'
111
+ ? (
112
+ <>
113
+ <UploadInput
114
+ maxBytes={maxBytes}
115
+ t={t}
116
+ disabled={!authed || busy}
117
+ imageDataUrl={imageDataUrl}
118
+ file={file}
119
+ onFile={(f, url) => { setFile(f); setImageDataUrl(url) }}
120
+ />
121
+ <div className="cga-field">
122
+ <label className="cga-field__label">{t('footprint.hintPlaceholder')}</label>
123
+ <input
124
+ className="cga-field__input"
125
+ type="text"
126
+ value={hint}
127
+ disabled={!authed || busy}
128
+ onChange={(ev) => setHint(ev.target.value)}
129
+ />
130
+ </div>
131
+ <div className="cga-actions">
132
+ <button
133
+ type="button"
134
+ className="cga-btn cga-btn--primary"
135
+ disabled={!authed || !imageDataUrl || busy}
136
+ onClick={extract}
137
+ >
138
+ {busy ? t('footprint.extractProgress') : t('footprint.extract')}
139
+ </button>
140
+ {busy
141
+ ? <button type="button" className="cga-btn" onClick={() => void runner.cancel()}>{t('editor.cancelLabel')}</button>
142
+ : null}
143
+ </div>
144
+ </>
145
+ )
146
+ : null}
147
+
148
+ {runner.phase === 'running' && runner.jobId
149
+ ? <div className="cga-progress"><span className="cga-spinner" />{runner.progress || t('footprint.extractProgress')}</div>
150
+ : null}
151
+
152
+ {runner.phase === 'needs_confirmation' && runner.dimensions
153
+ ? (
154
+ <>
155
+ <div className="cga-banner cga-banner--info">{t('footprint.needsConfirmation')}</div>
156
+ <GeometryEditor
157
+ dimensions={runner.dimensions}
158
+ pkgType={runner.pkgType}
159
+ fileName={runner.fileName}
160
+ disabled={false}
161
+ t={t}
162
+ onConfirm={confirmDimensions}
163
+ onCancel={cancelConfirm}
164
+ />
165
+ </>
166
+ )
167
+ : null}
168
+
169
+ {runner.phase === 'failed'
170
+ ? (
171
+ <div className="cga-banner cga-banner--error">
172
+ {t('footprint.failed')}: {runner.error}
173
+ {runner.result?.status === 'needs_auth' ? ` (${t('auth.loginRequired')})` : ''}
174
+ </div>
175
+ )
176
+ : null}
177
+ </div>
178
+ </div>
179
+
180
+ {runner.phase === 'completed'
181
+ ? <ResultStage ports={ports} kind="footprint" result={runner.result} t={t} srcKey={resultKey} />
182
+ : null}
183
+ </div>
184
+ )
185
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `@huaqiu/component-gen-app` — Symbol generation page.
3
+ *
4
+ * Upload an image (required by the symbol-from-image generator) + optional
5
+ * instruction → `runGenerateSymbol` via the component-gen server → live
6
+ * progress → preview + download + history.
7
+ */
8
+ import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
9
+ import type { ComponentGenConfig, ComponentGenPorts, ReopenRequest } from '../ports.js'
10
+ import type { Translate } from '../copy/index.js'
11
+ import { UploadInput } from '../components/UploadInput.js'
12
+ import { ResultStage } from '../components/ResultStage.js'
13
+ import { useAuthGate } from '../hooks/useAuthGate.js'
14
+ import { useJobRunner } from '../hooks/useJobRunner.js'
15
+
16
+ export interface SymbolGenPageProps {
17
+ ports: ComponentGenPorts
18
+ t: Translate
19
+ /** reopen a generated history entry into the completed stage. */
20
+ reopen?: ReopenRequest | null
21
+ /** the app calls back to switch tabs (not used on the symbol page). */
22
+ onClose?: () => void
23
+ }
24
+
25
+ export function SymbolGenPage({ ports, t, reopen = null }: SymbolGenPageProps): ReactElement {
26
+ const auth = useAuthGate(ports)
27
+ const runner = useJobRunner(ports)
28
+ const [config, setConfig] = useState<ComponentGenConfig | null>(null)
29
+ const [imageDataUrl, setImageDataUrl] = useState<string | null>(null)
30
+ const [file, setFile] = useState<File | null>(null)
31
+ const [instruction, setInstruction] = useState('')
32
+
33
+ // Stable refs so the reopen effect only re-runs when the request changes.
34
+ const runnerRef = useRef(runner)
35
+ runnerRef.current = runner
36
+ const portsRef = useRef(ports)
37
+ portsRef.current = ports
38
+
39
+ // Reopen: load the generated artifact into the result stage and restore the
40
+ // source image + instruction so the user can inspect / regenerate.
41
+ useEffect(() => {
42
+ if (!reopen) return
43
+ const { entry } = reopen
44
+ runnerRef.current.loadHistory(entry)
45
+ if (entry.input?.imageId) {
46
+ portsRef.current.inputImage(entry.input.imageId)
47
+ .then((dataUrl) => setImageDataUrl(dataUrl))
48
+ .catch(() => { /* best effort */ })
49
+ }
50
+ if (entry.input?.instruction) setInstruction(entry.input.instruction)
51
+ }, [reopen])
52
+
53
+ useEffect(() => {
54
+ ports.config().then(setConfig).catch(() => { /* best effort */ })
55
+ }, [ports])
56
+
57
+ const maxBytes = config?.limits.imageBytes ?? 4 * 1024 * 1024
58
+ const authed = auth.phase === 'authenticated'
59
+ const canGenerate = authed && !!imageDataUrl && runner.phase !== 'running'
60
+
61
+ const generate = (): void => {
62
+ if (!imageDataUrl) return
63
+ void runner.run({
64
+ kind: 'symbol',
65
+ input: { imageDataUrl, ...(instruction.trim() ? { instruction: instruction.trim() } : {}) },
66
+ })
67
+ }
68
+
69
+ const resultKey = useMemo(() => `${runner.jobId ?? 'none'}:${runner.phase}`, [runner.jobId, runner.phase])
70
+
71
+ return (
72
+ <div className="cga-app">
73
+ <div className="cga-panel">
74
+ <div className="cga-panel__body">
75
+ {auth.phase === 'unknown' ? <div className="cga-progress"><span className="cga-spinner" />{t('app.loading')}</div> : null}
76
+ {auth.phase === 'unauthenticated'
77
+ ? (
78
+ <div className="cga-auth">
79
+ <span>{t('auth.loginRequired')}</span>
80
+ <button type="button" className="cga-btn" onClick={auth.login}>{t('auth.login')}</button>
81
+ </div>
82
+ )
83
+ : null}
84
+
85
+ <UploadInput
86
+ maxBytes={maxBytes}
87
+ t={t}
88
+ disabled={!authed || runner.phase === 'running'}
89
+ imageDataUrl={imageDataUrl}
90
+ file={file}
91
+ onFile={(f, url) => { setFile(f); setImageDataUrl(url) }}
92
+ />
93
+
94
+ <div className="cga-field">
95
+ <label className="cga-field__label">{t('symbol.instructionPlaceholder')}</label>
96
+ <input
97
+ className="cga-field__input"
98
+ type="text"
99
+ value={instruction}
100
+ disabled={!authed || runner.phase === 'running'}
101
+ onChange={(ev) => setInstruction(ev.target.value)}
102
+ />
103
+ </div>
104
+
105
+ <div className="cga-actions">
106
+ <button
107
+ type="button"
108
+ className="cga-btn cga-btn--primary"
109
+ disabled={!canGenerate}
110
+ onClick={generate}
111
+ >
112
+ {runner.phase === 'running' ? t('symbol.progress') : (runner.phase === 'completed' ? t('symbol.regenerate') : t('symbol.generate'))}
113
+ </button>
114
+ {runner.phase === 'running'
115
+ ? <button type="button" className="cga-btn" onClick={() => void runner.cancel()}>{t('editor.cancelLabel')}</button>
116
+ : null}
117
+ </div>
118
+
119
+ {runner.phase === 'running' ? <div className="cga-progress"><span className="cga-spinner" />{runner.progress || t('symbol.progress')}</div> : null}
120
+ {runner.phase === 'failed'
121
+ ? (
122
+ <div className="cga-banner cga-banner--error">
123
+ {t('symbol.failed')}: {runner.error}
124
+ {runner.result?.status === 'needs_auth' ? ` (${t('auth.loginRequired')})` : ''}
125
+ </div>
126
+ )
127
+ : null}
128
+ </div>
129
+ </div>
130
+
131
+ {runner.phase === 'completed'
132
+ ? <ResultStage ports={ports} kind="symbol" result={runner.result} t={t} srcKey={resultKey} />
133
+ : null}
134
+ </div>
135
+ )
136
+ }