@dfosco/storyboard-react 4.2.4 → 4.2.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.
- package/package.json +3 -3
- package/src/CommandPalette/CommandPalette.jsx +46 -14
- package/src/Viewfinder.jsx +7 -4
- package/src/canvas/CanvasPage.jsx +60 -2
- package/src/canvas/WebGLContextPool.jsx +292 -0
- package/src/canvas/WebGLContextPool.test.jsx +165 -0
- package/src/canvas/componentIsolate.jsx +45 -15
- package/src/canvas/componentSetIsolate.jsx +257 -0
- package/src/canvas/widgets/ComponentSetWidget.jsx +2 -208
- package/src/canvas/widgets/ComponentWidget.jsx +7 -132
- package/src/canvas/widgets/ComponentWidget.module.css +0 -26
- package/src/canvas/widgets/FrozenTerminalOverlay.jsx +151 -0
- package/src/canvas/widgets/FrozenTerminalOverlay.module.css +83 -0
- package/src/canvas/widgets/PromptWidget.jsx +16 -2
- package/src/canvas/widgets/StorySetWidget.jsx +208 -0
- package/src/canvas/widgets/StorySetWidget.module.css +89 -0
- package/src/canvas/widgets/StoryWidget.jsx +3 -4
- package/src/canvas/widgets/TerminalWidget.jsx +146 -100
- package/src/canvas/widgets/TerminalWidget.module.css +23 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +1 -61
- package/src/canvas/widgets/expandUtils.js +3 -4
- package/src/canvas/widgets/index.js +2 -2
- package/src/canvas/widgets/snapshotDisplay.test.jsx +1 -1
- package/src/context.jsx +70 -7
- package/src/vite/data-plugin.js +8 -2
|
@@ -1,208 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* Instead of N iframes (one per export), this widget loads one iframe pointing
|
|
5
|
-
* to the story's ComponentSetPage. Each export renders in a grid cell inside
|
|
6
|
-
* that single page. The user can select a cell (via label click) which updates
|
|
7
|
-
* `props.selected` — visible to connected agents.
|
|
8
|
-
*
|
|
9
|
-
* Props: { storyId, layout, selected, width, height }
|
|
10
|
-
*/
|
|
11
|
-
import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
|
|
12
|
-
import { getStoryData } from '@dfosco/storyboard-core'
|
|
13
|
-
import Icon from '../../Icon.jsx'
|
|
14
|
-
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
15
|
-
import ResizeHandle from './ResizeHandle.jsx'
|
|
16
|
-
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
17
|
-
import styles from './ComponentSetWidget.module.css'
|
|
18
|
-
import overlayStyles from './embedOverlay.module.css'
|
|
19
|
-
|
|
20
|
-
function GridIcon({ size = 16 }) {
|
|
21
|
-
return <Icon name="iconoir/view-grid" size={size} />
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function resolveComponentSetUrl(storyId, layout, selected) {
|
|
25
|
-
const story = getStoryData(storyId)
|
|
26
|
-
if (!story?._route) return ''
|
|
27
|
-
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
28
|
-
const params = new URLSearchParams()
|
|
29
|
-
params.set('_sb_embed', '')
|
|
30
|
-
params.set('_sb_hide_branch_bar', '')
|
|
31
|
-
params.set('_sb_component_set', '')
|
|
32
|
-
if (layout) params.set('layout', layout)
|
|
33
|
-
if (selected) params.set('selected', selected)
|
|
34
|
-
return `${base}${story._route}?${params}`
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export default forwardRef(function ComponentSetWidget({ id: widgetId, props, onUpdate, resizable }, ref) {
|
|
38
|
-
const storyId = props?.storyId || ''
|
|
39
|
-
const layout = props?.layout || 'horizontal'
|
|
40
|
-
const selected = props?.selected || ''
|
|
41
|
-
const width = props?.width
|
|
42
|
-
const height = props?.height
|
|
43
|
-
|
|
44
|
-
const containerRef = useRef(null)
|
|
45
|
-
const iframeRef = useRef(null)
|
|
46
|
-
const [interactive, setInteractive] = useState(false)
|
|
47
|
-
const [storyIndexKey, setStoryIndexKey] = useState(0)
|
|
48
|
-
|
|
49
|
-
// Re-resolve when story index is live-patched
|
|
50
|
-
useEffect(() => {
|
|
51
|
-
const handler = () => setStoryIndexKey((k) => k + 1)
|
|
52
|
-
document.addEventListener('storyboard:story-index-changed', handler)
|
|
53
|
-
return () => document.removeEventListener('storyboard:story-index-changed', handler)
|
|
54
|
-
}, [])
|
|
55
|
-
|
|
56
|
-
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
57
|
-
|
|
58
|
-
// Exit interactive mode when clicking outside
|
|
59
|
-
useEffect(() => {
|
|
60
|
-
if (!interactive) return
|
|
61
|
-
function handlePointerDown(e) {
|
|
62
|
-
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
63
|
-
const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
|
|
64
|
-
if (chromeEl) return
|
|
65
|
-
setInteractive(false)
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
document.addEventListener('pointerdown', handlePointerDown)
|
|
69
|
-
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
70
|
-
}, [interactive, widgetId])
|
|
71
|
-
|
|
72
|
-
// Listen for selection messages from the embedded ComponentSetPage
|
|
73
|
-
useEffect(() => {
|
|
74
|
-
function handleMessage(e) {
|
|
75
|
-
if (e.source !== iframeRef.current?.contentWindow) return
|
|
76
|
-
if (e.data?.type === 'storyboard:component-set:select') {
|
|
77
|
-
const newSelected = e.data.exportName || ''
|
|
78
|
-
if (newSelected !== selected) {
|
|
79
|
-
onUpdate?.({ selected: newSelected })
|
|
80
|
-
}
|
|
81
|
-
} else if (e.data?.type === 'storyboard:component-set:resize') {
|
|
82
|
-
// Auto-size widget to fit the grid content (+ header height)
|
|
83
|
-
const headerH = 32
|
|
84
|
-
const newW = Math.max(200, Math.ceil(e.data.width))
|
|
85
|
-
const newH = Math.max(60, Math.ceil(e.data.height) + headerH)
|
|
86
|
-
if (newW !== width || newH !== height) {
|
|
87
|
-
onUpdate?.({ width: newW, height: newH })
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
window.addEventListener('message', handleMessage)
|
|
92
|
-
return () => window.removeEventListener('message', handleMessage)
|
|
93
|
-
}, [selected, width, height, onUpdate])
|
|
94
|
-
|
|
95
|
-
const handleResize = useCallback((w, h) => {
|
|
96
|
-
onUpdate?.({ width: w, height: h })
|
|
97
|
-
}, [onUpdate])
|
|
98
|
-
|
|
99
|
-
useImperativeHandle(ref, () => ({
|
|
100
|
-
handleAction(actionId) {
|
|
101
|
-
if (actionId === 'flip-layout') {
|
|
102
|
-
const next = layout === 'horizontal' ? 'vertical' : 'horizontal'
|
|
103
|
-
onUpdate?.({ layout: next })
|
|
104
|
-
return true
|
|
105
|
-
} else if (actionId === 'open-external') {
|
|
106
|
-
const story = getStoryData(storyId)
|
|
107
|
-
if (story?._route) {
|
|
108
|
-
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
109
|
-
window.open(`${base}${story._route}`, '_blank', 'noopener')
|
|
110
|
-
}
|
|
111
|
-
return true
|
|
112
|
-
}
|
|
113
|
-
},
|
|
114
|
-
}), [storyId, layout, onUpdate])
|
|
115
|
-
|
|
116
|
-
const iframeSrc = useMemo(
|
|
117
|
-
() => resolveComponentSetUrl(storyId, layout, selected),
|
|
118
|
-
// storyIndexKey forces re-evaluation when HMR mutates the story index
|
|
119
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
120
|
-
[storyId, layout, selected, storyIndexKey],
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
useIframeDevLogs({
|
|
124
|
-
widget: 'ComponentSetWidget',
|
|
125
|
-
loaded: interactive && Boolean(iframeSrc),
|
|
126
|
-
src: iframeSrc,
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
const displayName = storyId || 'Component Set'
|
|
130
|
-
|
|
131
|
-
if (!storyId) {
|
|
132
|
-
return (
|
|
133
|
-
<WidgetWrapper>
|
|
134
|
-
<div className={styles.container} ref={containerRef}>
|
|
135
|
-
<div className={styles.error}>
|
|
136
|
-
<span className={styles.errorIcon}><GridIcon size={20} /></span>
|
|
137
|
-
<span className={styles.errorText}>Missing story ID</span>
|
|
138
|
-
</div>
|
|
139
|
-
</div>
|
|
140
|
-
</WidgetWrapper>
|
|
141
|
-
)
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (!iframeSrc) {
|
|
145
|
-
return (
|
|
146
|
-
<WidgetWrapper>
|
|
147
|
-
<div className={styles.container} ref={containerRef}>
|
|
148
|
-
<div className={styles.error}>
|
|
149
|
-
<span className={styles.errorIcon}><GridIcon size={20} /></span>
|
|
150
|
-
<span className={styles.errorText}>Story “{storyId}” not found or has no route</span>
|
|
151
|
-
</div>
|
|
152
|
-
</div>
|
|
153
|
-
</WidgetWrapper>
|
|
154
|
-
)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const sizeStyle = {}
|
|
158
|
-
if (typeof width === 'number') sizeStyle.width = `${width}px`
|
|
159
|
-
if (typeof height === 'number') sizeStyle.height = `${height}px`
|
|
160
|
-
|
|
161
|
-
return (
|
|
162
|
-
<WidgetWrapper>
|
|
163
|
-
<div ref={containerRef} className={styles.container} style={sizeStyle}>
|
|
164
|
-
<div className={styles.header}>
|
|
165
|
-
<span className={styles.headerIcon}><GridIcon size={16} /></span>
|
|
166
|
-
<span className={styles.headerTitle}>{displayName}</span>
|
|
167
|
-
{selected && (
|
|
168
|
-
<span className={styles.headerSelected}>· {selected}</span>
|
|
169
|
-
)}
|
|
170
|
-
<span className={styles.headerLayout} title={`Layout: ${layout}`}>
|
|
171
|
-
{layout === 'horizontal' ? '⇔' : '⇕'}
|
|
172
|
-
</span>
|
|
173
|
-
</div>
|
|
174
|
-
<div className={styles.content}>
|
|
175
|
-
<iframe
|
|
176
|
-
ref={iframeRef}
|
|
177
|
-
src={iframeSrc}
|
|
178
|
-
className={styles.iframe}
|
|
179
|
-
title={`${displayName} component set`}
|
|
180
|
-
onLoad={(e) => e.target.blur()}
|
|
181
|
-
/>
|
|
182
|
-
</div>
|
|
183
|
-
{!interactive && (
|
|
184
|
-
<div
|
|
185
|
-
className={overlayStyles.interactOverlay}
|
|
186
|
-
onClick={(e) => {
|
|
187
|
-
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
188
|
-
enterInteractive()
|
|
189
|
-
}}
|
|
190
|
-
role="button"
|
|
191
|
-
tabIndex={0}
|
|
192
|
-
onKeyDown={(e) => {
|
|
193
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
194
|
-
e.preventDefault()
|
|
195
|
-
e.stopPropagation()
|
|
196
|
-
enterInteractive()
|
|
197
|
-
}
|
|
198
|
-
}}
|
|
199
|
-
aria-label="Click to interact"
|
|
200
|
-
>
|
|
201
|
-
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
202
|
-
</div>
|
|
203
|
-
)}
|
|
204
|
-
</div>
|
|
205
|
-
{resizable && <ResizeHandle targetRef={containerRef} width={width} height={height} onResize={handleResize} />}
|
|
206
|
-
</WidgetWrapper>
|
|
207
|
-
)
|
|
208
|
-
})
|
|
1
|
+
// Deprecated — renamed to StorySetWidget. This re-export exists for backward compatibility.
|
|
2
|
+
export { default } from './StorySetWidget.jsx'
|
|
@@ -1,139 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
// Deprecated stub — ComponentWidget was removed in 4.2.5.
|
|
2
|
+
// This file exists only to prevent crashes in CanvasPage's jsx- code path,
|
|
3
|
+
// which still references ComponentWidget for legacy canvas companion files.
|
|
4
|
+
// The jsx- system is dormant (no canvases define a `jsx` field).
|
|
2
5
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
3
|
-
import ResizeHandle from './ResizeHandle.jsx'
|
|
4
|
-
import ComponentErrorBoundary from '../ComponentErrorBoundary.jsx'
|
|
5
|
-
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
6
|
-
import styles from './ComponentWidget.module.css'
|
|
7
|
-
import overlayStyles from './embedOverlay.module.css'
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Renders a live JSX export from a .story.jsx file.
|
|
11
|
-
*
|
|
12
|
-
* In dev mode (isLocalDev), each component is rendered inside an iframe
|
|
13
|
-
* via the /_storyboard/canvas/isolate middleware. This isolates broken
|
|
14
|
-
* components so they cannot crash the entire canvas page.
|
|
15
|
-
*
|
|
16
|
-
* In production, the component is rendered directly with an ErrorBoundary
|
|
17
|
-
* as a fallback safety net.
|
|
18
|
-
*
|
|
19
|
-
* Double-click the overlay to enter interactive mode (dropdowns, buttons work).
|
|
20
|
-
* Click outside to exit interactive mode.
|
|
21
|
-
*/
|
|
22
|
-
export default function ComponentWidget({
|
|
23
|
-
component: Component,
|
|
24
|
-
jsxModule,
|
|
25
|
-
exportName,
|
|
26
|
-
canvasTheme,
|
|
27
|
-
isLocalDev,
|
|
28
|
-
width,
|
|
29
|
-
height,
|
|
30
|
-
onUpdate,
|
|
31
|
-
resizable,
|
|
32
|
-
}) {
|
|
33
|
-
const containerRef = useRef(null)
|
|
34
|
-
const [interactive, setInteractive] = useState(false)
|
|
35
|
-
const [showIframe, setShowIframe] = useState(false)
|
|
36
|
-
|
|
37
|
-
const handleResize = useCallback((w, h) => {
|
|
38
|
-
onUpdate?.({ width: w, height: h })
|
|
39
|
-
}, [onUpdate])
|
|
40
|
-
|
|
41
|
-
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
42
|
-
|
|
43
|
-
// Exit interactive mode when clicking outside the component
|
|
44
|
-
useEffect(() => {
|
|
45
|
-
if (!interactive) return
|
|
46
|
-
function handlePointerDown(e) {
|
|
47
|
-
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
48
|
-
setInteractive(false)
|
|
49
|
-
setShowIframe(false)
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
document.addEventListener('pointerdown', handlePointerDown)
|
|
53
|
-
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
54
|
-
}, [interactive])
|
|
55
|
-
|
|
56
|
-
// Build iframe src for dev isolation
|
|
57
|
-
const iframeSrc = useMemo(() => {
|
|
58
|
-
if (!isLocalDev || !jsxModule || !exportName) return null
|
|
59
|
-
const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
60
|
-
const params = new URLSearchParams({
|
|
61
|
-
module: jsxModule,
|
|
62
|
-
export: exportName,
|
|
63
|
-
theme: canvasTheme || 'light',
|
|
64
|
-
})
|
|
65
|
-
return `${basePath}/_storyboard/canvas/isolate?${params}`
|
|
66
|
-
}, [isLocalDev, jsxModule, exportName, canvasTheme])
|
|
67
|
-
|
|
68
|
-
const useIframe = isLocalDev && iframeSrc
|
|
69
|
-
|
|
70
|
-
useIframeDevLogs({
|
|
71
|
-
widget: 'ComponentWidget',
|
|
72
|
-
loaded: Boolean(useIframe && showIframe),
|
|
73
|
-
src: iframeSrc,
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
if (!useIframe && !Component) return null
|
|
77
|
-
|
|
78
|
-
const sizeStyle = {}
|
|
79
|
-
if (typeof width === 'number') sizeStyle.width = `${width}px`
|
|
80
|
-
if (typeof height === 'number') sizeStyle.height = `${height}px`
|
|
81
6
|
|
|
7
|
+
export default function ComponentWidget({ component: Component, exportName }) {
|
|
8
|
+
if (!Component) return null
|
|
82
9
|
return (
|
|
83
10
|
<WidgetWrapper>
|
|
84
|
-
<
|
|
85
|
-
<div className={styles.content}>
|
|
86
|
-
{useIframe ? (
|
|
87
|
-
showIframe ? (
|
|
88
|
-
<iframe
|
|
89
|
-
src={iframeSrc}
|
|
90
|
-
className={styles.iframe}
|
|
91
|
-
title={exportName || 'Component widget'}
|
|
92
|
-
sandbox="allow-same-origin allow-scripts"
|
|
93
|
-
onLoad={(e) => e.target.blur()}
|
|
94
|
-
/>
|
|
95
|
-
) : (
|
|
96
|
-
<div className={styles.placeholder} />
|
|
97
|
-
)
|
|
98
|
-
) : Component ? (
|
|
99
|
-
<ComponentErrorBoundary name={exportName}>
|
|
100
|
-
<Component />
|
|
101
|
-
</ComponentErrorBoundary>
|
|
102
|
-
) : null}
|
|
103
|
-
</div>
|
|
104
|
-
{!interactive && (
|
|
105
|
-
<div
|
|
106
|
-
className={overlayStyles.interactOverlay}
|
|
107
|
-
onClick={(e) => {
|
|
108
|
-
// Don't enter interactive mode for modifier clicks (shift/meta/ctrl for multi-select)
|
|
109
|
-
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
110
|
-
if (useIframe) setShowIframe(true)
|
|
111
|
-
enterInteractive()
|
|
112
|
-
}}
|
|
113
|
-
role="button"
|
|
114
|
-
tabIndex={0}
|
|
115
|
-
onKeyDown={(e) => {
|
|
116
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
117
|
-
e.preventDefault()
|
|
118
|
-
e.stopPropagation()
|
|
119
|
-
if (useIframe) setShowIframe(true)
|
|
120
|
-
enterInteractive()
|
|
121
|
-
}
|
|
122
|
-
}}
|
|
123
|
-
aria-label="Click to interact with component"
|
|
124
|
-
>
|
|
125
|
-
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
126
|
-
</div>
|
|
127
|
-
)}
|
|
128
|
-
{resizable && (
|
|
129
|
-
<ResizeHandle
|
|
130
|
-
targetRef={containerRef}
|
|
131
|
-
minWidth={100}
|
|
132
|
-
minHeight={60}
|
|
133
|
-
onResize={handleResize}
|
|
134
|
-
/>
|
|
135
|
-
)}
|
|
136
|
-
</div>
|
|
11
|
+
<Component />
|
|
137
12
|
</WidgetWrapper>
|
|
138
13
|
)
|
|
139
14
|
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
.container {
|
|
2
|
-
position: relative;
|
|
3
|
-
overflow: hidden;
|
|
4
|
-
min-width: 100px;
|
|
5
|
-
min-height: 60px;
|
|
6
|
-
background: var(--bgColor-default, #ffffff);
|
|
7
|
-
width: 100%;
|
|
8
|
-
height: 100%;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
.content {
|
|
12
|
-
width: 100%;
|
|
13
|
-
height: 100%;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
.iframe {
|
|
17
|
-
display: block;
|
|
18
|
-
width: 100%;
|
|
19
|
-
height: 100%;
|
|
20
|
-
border: none;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
.placeholder {
|
|
24
|
-
width: 100%;
|
|
25
|
-
height: 100%;
|
|
26
|
-
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react'
|
|
2
|
+
import styles from './FrozenTerminalOverlay.module.css'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Renders a frozen terminal preview from the latest server snapshot.
|
|
6
|
+
* Shown when a terminal widget loses its live WebGL slot but the
|
|
7
|
+
* server-side tmux session is still running.
|
|
8
|
+
*
|
|
9
|
+
* The snapshot text is rendered at a base font size then CSS-scaled to
|
|
10
|
+
* fill the widget width — matching the live ghostty terminal's sizing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
let Convert = null
|
|
14
|
+
let ansiLoadAttempted = false
|
|
15
|
+
|
|
16
|
+
async function getConverter() {
|
|
17
|
+
if (Convert) return new Convert({ fg: '#e6edf3', bg: '#0d1117', newline: true })
|
|
18
|
+
if (ansiLoadAttempted) return null
|
|
19
|
+
ansiLoadAttempted = true
|
|
20
|
+
try {
|
|
21
|
+
const mod = await import(/* @vite-ignore */ 'ansi-to-html')
|
|
22
|
+
Convert = mod.default || mod
|
|
23
|
+
return new Convert({ fg: '#e6edf3', bg: '#0d1117', newline: true })
|
|
24
|
+
} catch {
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stripAnsi(text) {
|
|
30
|
+
// eslint-disable-next-line no-control-regex
|
|
31
|
+
return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getBaseUrl() {
|
|
35
|
+
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
36
|
+
return base.endsWith('/') ? base : base + '/'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function FrozenTerminalOverlay({ widgetId, onActivate }) {
|
|
40
|
+
const [html, setHtml] = useState(null)
|
|
41
|
+
const [plainText, setPlainText] = useState(null)
|
|
42
|
+
const [scale, setScale] = useState(1)
|
|
43
|
+
const containerRef = useRef(null)
|
|
44
|
+
const preRef = useRef(null)
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
let cancelled = false
|
|
48
|
+
|
|
49
|
+
async function fetchSnapshot() {
|
|
50
|
+
const baseUrl = getBaseUrl()
|
|
51
|
+
const urls = [
|
|
52
|
+
`${baseUrl}_storyboard/canvas/terminal-snapshot/${widgetId}`,
|
|
53
|
+
`${baseUrl}_storyboard/terminal-snapshots/${widgetId}.snapshot.json`,
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
for (const url of urls) {
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(url)
|
|
59
|
+
if (!res.ok) continue
|
|
60
|
+
const data = await res.json()
|
|
61
|
+
if (cancelled) return
|
|
62
|
+
const text = data.paneContent || data.content || data.output || ''
|
|
63
|
+
if (!text) continue
|
|
64
|
+
|
|
65
|
+
const converter = await getConverter()
|
|
66
|
+
if (cancelled) return
|
|
67
|
+
if (converter) {
|
|
68
|
+
setHtml(converter.toHtml(text))
|
|
69
|
+
} else {
|
|
70
|
+
setPlainText(stripAnsi(text))
|
|
71
|
+
}
|
|
72
|
+
return
|
|
73
|
+
} catch {
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
fetchSnapshot()
|
|
80
|
+
return () => { cancelled = true }
|
|
81
|
+
}, [widgetId])
|
|
82
|
+
|
|
83
|
+
// Scale the pre to fill the padded content area width
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const container = containerRef.current
|
|
86
|
+
const pre = preRef.current
|
|
87
|
+
if (!container || !pre) return
|
|
88
|
+
|
|
89
|
+
function updateScale() {
|
|
90
|
+
const style = getComputedStyle(container)
|
|
91
|
+
const padL = parseFloat(style.paddingLeft) || 0
|
|
92
|
+
const padR = parseFloat(style.paddingRight) || 0
|
|
93
|
+
const availableWidth = container.clientWidth - padL - padR
|
|
94
|
+
const naturalWidth = pre.scrollWidth
|
|
95
|
+
if (naturalWidth > 0 && availableWidth > 0) {
|
|
96
|
+
setScale(availableWidth / naturalWidth)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const observer = new ResizeObserver(updateScale)
|
|
101
|
+
observer.observe(container)
|
|
102
|
+
updateScale()
|
|
103
|
+
|
|
104
|
+
return () => observer.disconnect()
|
|
105
|
+
}, [html, plainText])
|
|
106
|
+
|
|
107
|
+
const hasSnapshot = !!(html || plainText)
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
className={styles.overlay}
|
|
112
|
+
onClick={onActivate}
|
|
113
|
+
role="button"
|
|
114
|
+
tabIndex={0}
|
|
115
|
+
onKeyDown={(e) => { if (e.key === 'Enter') onActivate?.() }}
|
|
116
|
+
aria-label="Click to resume terminal"
|
|
117
|
+
>
|
|
118
|
+
{/* Faded snapshot background — scaled to fill widget width */}
|
|
119
|
+
{hasSnapshot && (
|
|
120
|
+
<div ref={containerRef} className={styles.snapshotContent}>
|
|
121
|
+
{html && (
|
|
122
|
+
<pre
|
|
123
|
+
ref={preRef}
|
|
124
|
+
className={styles.snapshotPre}
|
|
125
|
+
style={{ transform: `scale(${scale})` }}
|
|
126
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
127
|
+
/>
|
|
128
|
+
)}
|
|
129
|
+
{!html && plainText && (
|
|
130
|
+
<pre
|
|
131
|
+
ref={preRef}
|
|
132
|
+
className={styles.snapshotPre}
|
|
133
|
+
style={{ transform: `scale(${scale})` }}
|
|
134
|
+
>
|
|
135
|
+
{plainText}
|
|
136
|
+
</pre>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
{/* Status + action */}
|
|
142
|
+
<div className={styles.statusLayer}>
|
|
143
|
+
<span className={styles.actionButton}>Click to resume</span>
|
|
144
|
+
<span className={styles.statusBadge}>
|
|
145
|
+
Running in background
|
|
146
|
+
<span className={styles.orbitSpinner} />
|
|
147
|
+
</span>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
.overlay {
|
|
2
|
+
position: absolute;
|
|
3
|
+
inset: 0;
|
|
4
|
+
background: var(--term-bg, #181b22);
|
|
5
|
+
overflow: hidden;
|
|
6
|
+
cursor: pointer;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/* ── Faded snapshot background ── */
|
|
10
|
+
|
|
11
|
+
.snapshotContent {
|
|
12
|
+
position: absolute;
|
|
13
|
+
inset: 0;
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
padding: var(--base-size-16, 16px);
|
|
16
|
+
font-family: 'Ghostty', 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace;
|
|
17
|
+
font-size: 12px;
|
|
18
|
+
line-height: 1.4;
|
|
19
|
+
color: #e6edf3;
|
|
20
|
+
opacity: 0.35;
|
|
21
|
+
pointer-events: none;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.snapshotPre {
|
|
25
|
+
margin: 0;
|
|
26
|
+
white-space: pre;
|
|
27
|
+
display: inline-block;
|
|
28
|
+
font-family: inherit;
|
|
29
|
+
font-size: inherit;
|
|
30
|
+
line-height: inherit;
|
|
31
|
+
transform-origin: top left;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* ── Status + action layer ── */
|
|
35
|
+
|
|
36
|
+
.statusLayer {
|
|
37
|
+
position: absolute;
|
|
38
|
+
inset: 0;
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
align-items: center;
|
|
42
|
+
justify-content: center;
|
|
43
|
+
gap: 12px;
|
|
44
|
+
pointer-events: none;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Matches interactHint from embedOverlay — solid pill button, always visible */
|
|
48
|
+
.actionButton {
|
|
49
|
+
color: var(--fgColor-onInverse, #fff);
|
|
50
|
+
background-color: var(--bgColor-inverse, #21262d);
|
|
51
|
+
padding: var(--base-size-12, 12px) var(--base-size-16, 16px);
|
|
52
|
+
border-radius: var(--base-size-6, 6px);
|
|
53
|
+
font-size: 14px;
|
|
54
|
+
font-weight: 600;
|
|
55
|
+
pointer-events: none;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* Transparent badge — no opaque bg so snapshot text stays readable */
|
|
59
|
+
.statusBadge {
|
|
60
|
+
display: inline-flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
gap: 8px;
|
|
63
|
+
color: #8b949e;
|
|
64
|
+
font-size: 12px;
|
|
65
|
+
padding: 4px 0;
|
|
66
|
+
letter-spacing: 0.01em;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ── Orbit spinner ── */
|
|
70
|
+
|
|
71
|
+
.orbitSpinner {
|
|
72
|
+
display: inline-block;
|
|
73
|
+
width: 12px;
|
|
74
|
+
height: 12px;
|
|
75
|
+
border: 1.5px solid transparent;
|
|
76
|
+
border-top-color: #8b949e;
|
|
77
|
+
border-radius: 50%;
|
|
78
|
+
animation: orbitSpin 1s linear infinite;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@keyframes orbitSpin {
|
|
82
|
+
to { transform: rotate(360deg); }
|
|
83
|
+
}
|
|
@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef, forwardRef, useImperativeHand
|
|
|
2
2
|
import { readProp, promptSchema } from './widgetProps.js'
|
|
3
3
|
import { CopilotIcon, SquareFillIcon, CheckCircleIcon, XIcon } from '@primer/octicons-react'
|
|
4
4
|
import ResizeHandle from './ResizeHandle.jsx'
|
|
5
|
+
import { useWebGLSlot, Priority } from '../WebGLContextPool.jsx'
|
|
5
6
|
import styles from './PromptWidget.module.css'
|
|
6
7
|
|
|
7
8
|
function getBase() {
|
|
@@ -92,6 +93,18 @@ const PromptWidget = forwardRef(function PromptWidget({ id, props, onUpdate, res
|
|
|
92
93
|
const termDisposedRef = useRef(false)
|
|
93
94
|
const textareaRef = useRef(null)
|
|
94
95
|
|
|
96
|
+
// ── WebGL context pool integration ──
|
|
97
|
+
// Only request a live slot when the output terminal is actually shown
|
|
98
|
+
const { isLive, setPriority } = useWebGLSlot(id)
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (showOutput && execStatus !== 'idle') {
|
|
102
|
+
setPriority(Priority.VISIBLE)
|
|
103
|
+
} else {
|
|
104
|
+
setPriority(Priority.OFFSCREEN)
|
|
105
|
+
}
|
|
106
|
+
}, [showOutput, execStatus, setPriority])
|
|
107
|
+
|
|
95
108
|
const onUpdateRef = useRef(onUpdate)
|
|
96
109
|
useEffect(() => { onUpdateRef.current = onUpdate }, [onUpdate])
|
|
97
110
|
|
|
@@ -206,8 +219,9 @@ const PromptWidget = forwardRef(function PromptWidget({ id, props, onUpdate, res
|
|
|
206
219
|
}
|
|
207
220
|
}, [onUpdate])
|
|
208
221
|
|
|
209
|
-
// Embedded read-only terminal
|
|
222
|
+
// Embedded read-only terminal (only created when pool grants a live slot)
|
|
210
223
|
useEffect(() => {
|
|
224
|
+
if (!isLive) return
|
|
211
225
|
if (!showOutput || execStatus === 'idle') return
|
|
212
226
|
if (!termContainerRef.current) return
|
|
213
227
|
|
|
@@ -279,7 +293,7 @@ const PromptWidget = forwardRef(function PromptWidget({ id, props, onUpdate, res
|
|
|
279
293
|
termRef.current = null
|
|
280
294
|
wsRef.current = null
|
|
281
295
|
}
|
|
282
|
-
}, [showOutput, execStatus, id, width, height])
|
|
296
|
+
}, [isLive, showOutput, execStatus, id, width, height])
|
|
283
297
|
|
|
284
298
|
const isPending = execStatus === 'pending'
|
|
285
299
|
const isDone = execStatus === 'done'
|