@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
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StorySetWidget — renders all exports from a story in a single iframe grid.
|
|
3
|
+
*
|
|
4
|
+
* Instead of N iframes (one per export), this widget loads one iframe pointing
|
|
5
|
+
* to the isolate-set endpoint. 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
|
+
* User-facing label: "Component Set"
|
|
10
|
+
*
|
|
11
|
+
* Props: { storyId, layout, selected, width, height }
|
|
12
|
+
*/
|
|
13
|
+
import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
|
|
14
|
+
import { getStoryData } from '@dfosco/storyboard-core'
|
|
15
|
+
import Icon from '../../Icon.jsx'
|
|
16
|
+
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
17
|
+
import ResizeHandle from './ResizeHandle.jsx'
|
|
18
|
+
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
19
|
+
import styles from './StorySetWidget.module.css'
|
|
20
|
+
import overlayStyles from './embedOverlay.module.css'
|
|
21
|
+
|
|
22
|
+
function GridIcon({ size = 16 }) {
|
|
23
|
+
return <Icon name="iconoir/view-grid" size={size} />
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveStorySetUrl(storyId, layout, selected) {
|
|
27
|
+
const story = getStoryData(storyId)
|
|
28
|
+
if (!story?._storyModule) return ''
|
|
29
|
+
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
30
|
+
const params = new URLSearchParams()
|
|
31
|
+
params.set('module', story._storyModule)
|
|
32
|
+
if (layout) params.set('layout', layout)
|
|
33
|
+
if (selected) params.set('selected', selected)
|
|
34
|
+
return `${base}/_storyboard/canvas/isolate-set?${params}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default forwardRef(function StorySetWidget({ 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 grid
|
|
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
|
+
() => resolveStorySetUrl(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: 'StorySetWidget',
|
|
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
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/* StorySetWidget — canvas widget chrome */
|
|
2
|
+
|
|
3
|
+
.container {
|
|
4
|
+
position: relative;
|
|
5
|
+
overflow: hidden;
|
|
6
|
+
min-width: 200px;
|
|
7
|
+
min-height: 120px;
|
|
8
|
+
background: var(--bgColor-default, #ffffff);
|
|
9
|
+
border: 3px solid var(--borderColor-default, #d0d7de);
|
|
10
|
+
border-radius: 12px;
|
|
11
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
|
12
|
+
width: 100%;
|
|
13
|
+
height: 100%;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.header {
|
|
17
|
+
display: flex;
|
|
18
|
+
align-items: center;
|
|
19
|
+
gap: 6px;
|
|
20
|
+
padding: 10px 10px;
|
|
21
|
+
font-size: 12px;
|
|
22
|
+
font-weight: 500;
|
|
23
|
+
color: var(--fgColor-muted, #656d76);
|
|
24
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
25
|
+
border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
|
|
26
|
+
white-space: nowrap;
|
|
27
|
+
overflow: hidden;
|
|
28
|
+
text-overflow: ellipsis;
|
|
29
|
+
user-select: none;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.headerIcon {
|
|
33
|
+
display: inline-flex;
|
|
34
|
+
flex-shrink: 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.headerTitle {
|
|
38
|
+
overflow: hidden;
|
|
39
|
+
text-overflow: ellipsis;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.headerSelected {
|
|
43
|
+
color: var(--fgColor-accent, #0969da);
|
|
44
|
+
font-weight: 600;
|
|
45
|
+
flex-shrink: 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.headerLayout {
|
|
49
|
+
margin-left: auto;
|
|
50
|
+
font-size: 14px;
|
|
51
|
+
opacity: 0.5;
|
|
52
|
+
flex-shrink: 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.content {
|
|
56
|
+
position: relative;
|
|
57
|
+
width: 100%;
|
|
58
|
+
height: calc(100% - 37px);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.iframe {
|
|
62
|
+
position: absolute;
|
|
63
|
+
inset: 0;
|
|
64
|
+
display: block;
|
|
65
|
+
width: 100%;
|
|
66
|
+
height: 100%;
|
|
67
|
+
border: none;
|
|
68
|
+
z-index: 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.error {
|
|
72
|
+
display: flex;
|
|
73
|
+
align-items: center;
|
|
74
|
+
gap: 8px;
|
|
75
|
+
padding: 16px;
|
|
76
|
+
color: var(--fgColor-danger, #cf222e);
|
|
77
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
78
|
+
font-size: 13px;
|
|
79
|
+
line-height: 1.5;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.errorIcon {
|
|
83
|
+
font-size: 20px;
|
|
84
|
+
flex-shrink: 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.errorText {
|
|
88
|
+
word-break: break-word;
|
|
89
|
+
}
|
|
@@ -26,13 +26,12 @@ function ComponentIcon({ size = 36 }) {
|
|
|
26
26
|
|
|
27
27
|
function resolveStoryUrl(storyId, exportName) {
|
|
28
28
|
const story = getStoryData(storyId)
|
|
29
|
-
if (!story?.
|
|
29
|
+
if (!story?._storyModule) return ''
|
|
30
30
|
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
31
31
|
const params = new URLSearchParams()
|
|
32
|
+
params.set('module', story._storyModule)
|
|
32
33
|
if (exportName) params.set('export', exportName)
|
|
33
|
-
params
|
|
34
|
-
params.set('_sb_hide_branch_bar', '')
|
|
35
|
-
return `${base}${story._route}?${params}`
|
|
34
|
+
return `${base}/_storyboard/canvas/isolate?${params}`
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
const _storySourcesCache = {}
|
|
@@ -5,6 +5,8 @@ import { getTerminalConfig, getTerminalDimensions } from '@dfosco/storyboard-cor
|
|
|
5
5
|
import { useOverride } from '../../hooks/useOverride.js'
|
|
6
6
|
import { getSplitPaneLabel, findAllConnectedSplitTargets, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
|
|
7
7
|
import ExpandedPane from './ExpandedPane.jsx'
|
|
8
|
+
import { useWebGLSlot, Priority } from '../WebGLContextPool.jsx'
|
|
9
|
+
import FrozenTerminalOverlay from './FrozenTerminalOverlay.jsx'
|
|
8
10
|
import styles from './TerminalWidget.module.css'
|
|
9
11
|
import overlayStyles from './embedOverlay.module.css'
|
|
10
12
|
|
|
@@ -106,17 +108,16 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
|
|
|
106
108
|
const cfg = getTerminalConfig()
|
|
107
109
|
const fontSize = cfg.fontSize ?? 13
|
|
108
110
|
const agentId = props?.agentId || null
|
|
111
|
+
// Config dimensions are authoritative — always use them as the base
|
|
109
112
|
const dims = getTerminalDimensions(agentId, {
|
|
110
113
|
width: readProp(props, 'width', terminalSchema),
|
|
111
114
|
height: readProp(props, 'height', terminalSchema),
|
|
112
115
|
})
|
|
113
|
-
const
|
|
114
|
-
const
|
|
116
|
+
const width = dims.width
|
|
117
|
+
const height = dims.height
|
|
115
118
|
const prettyName = props?.prettyName || null
|
|
116
119
|
const startupCommand = props?.startupCommand || null
|
|
117
120
|
|
|
118
|
-
const width = rawWidth
|
|
119
|
-
const height = rawHeight
|
|
120
121
|
// Snapped dimensions computed from ghostty's actual cell metrics (set after open)
|
|
121
122
|
const [snappedHeight, setSnappedHeight] = useState(null)
|
|
122
123
|
const [snappedWidth, setSnappedWidth] = useState(null)
|
|
@@ -145,6 +146,25 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
|
|
|
145
146
|
const expandContainerRef = useRef(null)
|
|
146
147
|
const dragHintTimer = useRef(null)
|
|
147
148
|
|
|
149
|
+
// ── WebGL context pool integration ──
|
|
150
|
+
// webglReady: PINNED (bypass cap, guaranteed live — no frozen flash)
|
|
151
|
+
// All others: VISIBLE (auto-requests a live slot — no manual click needed)
|
|
152
|
+
const initialPriority = props?.webglReady ? Priority.PINNED : Priority.VISIBLE
|
|
153
|
+
const { isLive, generation, setPriority } = useWebGLSlot(id, initialPriority)
|
|
154
|
+
|
|
155
|
+
// Update pool priority based on widget state
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (expanded || interactive) {
|
|
158
|
+
setPriority(Priority.PINNED)
|
|
159
|
+
}
|
|
160
|
+
// Priority for VISIBLE/NEAR/OFFSCREEN is set by CanvasPage via usePoolVisibilityUpdater
|
|
161
|
+
}, [expanded, interactive, setPriority])
|
|
162
|
+
|
|
163
|
+
// Request activation when user clicks a frozen terminal
|
|
164
|
+
const handleFrozenActivate = useCallback(() => {
|
|
165
|
+
setPriority(Priority.PINNED)
|
|
166
|
+
}, [setPriority])
|
|
167
|
+
|
|
148
168
|
useImperativeHandle(ref, () => ({
|
|
149
169
|
handleAction(actionId) {
|
|
150
170
|
if (actionId === 'expand') { setExpanded('single'); return true }
|
|
@@ -179,8 +199,9 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
|
|
|
179
199
|
if (multiSelected && interactive) setInteractive(false)
|
|
180
200
|
}, [multiSelected])
|
|
181
201
|
|
|
182
|
-
// Connect terminal + WebSocket
|
|
202
|
+
// Connect terminal + WebSocket (only when pool grants a live slot)
|
|
183
203
|
useEffect(() => {
|
|
204
|
+
if (!isLive) return
|
|
184
205
|
if (!containerRef.current) return
|
|
185
206
|
|
|
186
207
|
let disposed = false
|
|
@@ -308,8 +329,10 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
|
|
|
308
329
|
if (term) term.dispose()
|
|
309
330
|
termRef.current = null
|
|
310
331
|
wsRef.current = null
|
|
332
|
+
setReady(false)
|
|
333
|
+
setRevealed(false)
|
|
311
334
|
}
|
|
312
|
-
}, [id, connectAttempt])
|
|
335
|
+
}, [id, isLive, generation, connectAttempt])
|
|
313
336
|
|
|
314
337
|
// Resize terminal on dimension changes
|
|
315
338
|
useEffect(() => {
|
|
@@ -472,112 +495,135 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
|
|
|
472
495
|
ref={terminalRef}
|
|
473
496
|
className={styles.terminal}
|
|
474
497
|
style={{
|
|
475
|
-
...(typeof (snappedWidth ?? width) === 'number'
|
|
476
|
-
|
|
498
|
+
...(typeof (isLive ? (snappedWidth ?? width) : width) === 'number'
|
|
499
|
+
? { width: `${isLive ? (snappedWidth ?? width) : width}px` }
|
|
500
|
+
: undefined),
|
|
501
|
+
...(typeof (isLive ? (snappedHeight ?? height) : height) === 'number'
|
|
502
|
+
? { height: `${isLive ? (snappedHeight ?? height) : height}px` }
|
|
503
|
+
: undefined),
|
|
477
504
|
}}
|
|
478
505
|
onClick={handleClick}
|
|
479
506
|
onPointerDown={handleTerminalPointerDown}
|
|
480
507
|
onKeyDown={interactive ? (e) => e.stopPropagation() : undefined}
|
|
481
508
|
>
|
|
482
|
-
{
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
<div className={styles.error}>
|
|
489
|
-
<span>⚠ {error}</span>
|
|
490
|
-
</div>
|
|
491
|
-
)}
|
|
492
|
-
<div ref={containerRef} className={styles.xtermContainer} style={{ opacity: revealed ? 1 : 0 }} />
|
|
493
|
-
|
|
494
|
-
{/* Live but not interactive */}
|
|
495
|
-
{revealed && !interactive && !sessionEnded && (
|
|
496
|
-
<div
|
|
497
|
-
className={overlayStyles.interactOverlay}
|
|
498
|
-
style={{ backgroundColor: 'transparent' }}
|
|
499
|
-
onClick={(e) => {
|
|
500
|
-
if (multiSelected || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
501
|
-
setInteractive(true)
|
|
502
|
-
termRef.current?.focus({ preventScroll: true })
|
|
503
|
-
}}
|
|
504
|
-
role="button"
|
|
505
|
-
tabIndex={0}
|
|
506
|
-
onKeyDown={(e) => { if (!multiSelected && e.key === 'Enter') { setInteractive(true); termRef.current?.focus({ preventScroll: true }) } }}
|
|
507
|
-
aria-label="Click to interact"
|
|
508
|
-
>
|
|
509
|
-
{!multiSelected && <span className={overlayStyles.interactHint}>Click to interact</span>}
|
|
510
|
-
</div>
|
|
509
|
+
{/* ── Frozen state: WebGL context released, show snapshot ── */}
|
|
510
|
+
{!isLive && (
|
|
511
|
+
<FrozenTerminalOverlay
|
|
512
|
+
widgetId={id}
|
|
513
|
+
onActivate={handleFrozenActivate}
|
|
514
|
+
/>
|
|
511
515
|
)}
|
|
512
516
|
|
|
513
|
-
{/*
|
|
514
|
-
{
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
517
|
+
{/* ── Live state: ghostty WebGL terminal ── */}
|
|
518
|
+
{isLive && (
|
|
519
|
+
<>
|
|
520
|
+
{showDragHint && (
|
|
521
|
+
<div className={styles.dragHint}>
|
|
522
|
+
<span className={styles.dragHintArrow}>←</span> Drag here to move widget
|
|
523
|
+
</div>
|
|
524
|
+
)}
|
|
525
|
+
{error && !sessionEnded && (
|
|
526
|
+
<div className={styles.error}>
|
|
527
|
+
<span>⚠ {error}</span>
|
|
528
|
+
</div>
|
|
529
|
+
)}
|
|
530
|
+
<div ref={containerRef} className={styles.xtermContainer} style={{ opacity: revealed ? 1 : 0 }} />
|
|
531
|
+
|
|
532
|
+
{/* Live but not interactive */}
|
|
533
|
+
{revealed && !interactive && !sessionEnded && (
|
|
534
|
+
<div
|
|
535
|
+
className={overlayStyles.interactOverlay}
|
|
536
|
+
style={{ backgroundColor: 'transparent' }}
|
|
537
|
+
onClick={(e) => {
|
|
538
|
+
if (multiSelected || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
539
|
+
setInteractive(true)
|
|
540
|
+
termRef.current?.focus({ preventScroll: true })
|
|
541
|
+
}}
|
|
542
|
+
role="button"
|
|
543
|
+
tabIndex={0}
|
|
544
|
+
onKeyDown={(e) => { if (!multiSelected && e.key === 'Enter') { setInteractive(true); termRef.current?.focus({ preventScroll: true }) } }}
|
|
545
|
+
aria-label="Click to interact"
|
|
546
|
+
>
|
|
547
|
+
{!multiSelected && <span className={overlayStyles.interactHint}>Click to interact</span>}
|
|
548
|
+
</div>
|
|
549
|
+
)}
|
|
550
|
+
|
|
551
|
+
{/* Session ended — resource limited */}
|
|
552
|
+
{sessionEnded && resourceLimited && (
|
|
553
|
+
<div
|
|
554
|
+
className={overlayStyles.interactOverlay}
|
|
555
|
+
style={{ backgroundColor: 'var(--term-bg, #0d1117)', flexDirection: 'column', gap: '8px', padding: '24px' }}
|
|
556
|
+
>
|
|
557
|
+
<span className={styles.resourceIcon}>⚠</span>
|
|
558
|
+
<span className={styles.resourceTitle}>No terminal devices available</span>
|
|
559
|
+
<span className={styles.resourceMessage}>
|
|
560
|
+
Too many terminal sessions are open.
|
|
561
|
+
{resourceLimited.counts && (
|
|
562
|
+
<span className={styles.resourceCounts}>
|
|
563
|
+
{resourceLimited.counts.live} live · {resourceLimited.counts.background} background · {resourceLimited.counts.archived} archived
|
|
564
|
+
</span>
|
|
565
|
+
)}
|
|
543
566
|
</span>
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
567
|
+
<div className={styles.resourceActions}>
|
|
568
|
+
{!resourceLimited.cleanupResult && (resourceLimited.counts?.background > 0 || resourceLimited.counts?.archived > 0) && (
|
|
569
|
+
<button className={styles.resourceBtn} onClick={handleCleanupAndRetry}>
|
|
570
|
+
Close background sessions
|
|
571
|
+
</button>
|
|
572
|
+
)}
|
|
573
|
+
{resourceLimited.cleanupResult === 'nothing-to-clean' && (
|
|
574
|
+
<span className={styles.resourceMuted}>
|
|
575
|
+
All background sessions already cleaned. Close some live terminals to free resources.
|
|
576
|
+
</span>
|
|
577
|
+
)}
|
|
578
|
+
{resourceLimited.cleanupResult === 'failed' && (
|
|
579
|
+
<span className={styles.resourceMuted}>
|
|
580
|
+
Cleanup failed — could not reach dev server.
|
|
581
|
+
</span>
|
|
582
|
+
)}
|
|
583
|
+
<button className={styles.resourceBtnSecondary} onClick={handleStartSession}>
|
|
584
|
+
Retry
|
|
585
|
+
</button>
|
|
586
|
+
</div>
|
|
587
|
+
</div>
|
|
588
|
+
)}
|
|
551
589
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
590
|
+
{/* Session ended — normal */}
|
|
591
|
+
{sessionEnded && !resourceLimited && (
|
|
592
|
+
<div
|
|
593
|
+
className={overlayStyles.interactOverlay}
|
|
594
|
+
style={{ backgroundColor: 'var(--term-bg, #0d1117)', flexDirection: 'column', gap: 0 }}
|
|
595
|
+
onClick={handleStartSession}
|
|
596
|
+
role="button"
|
|
597
|
+
tabIndex={0}
|
|
598
|
+
aria-label="Start terminal session"
|
|
599
|
+
onKeyDown={(e) => { if (e.key === 'Enter') handleStartSession() }}
|
|
600
|
+
>
|
|
601
|
+
{!waking && (
|
|
602
|
+
<>
|
|
603
|
+
<div className={styles.buddyZzz}>
|
|
604
|
+
<span className={styles.z1}>z</span>
|
|
605
|
+
<span className={styles.z2}>z</span>
|
|
606
|
+
<span className={styles.z3}>z</span>
|
|
607
|
+
</div>
|
|
608
|
+
<span className={styles.sessionEndedBadge}>Session ended</span>
|
|
609
|
+
<span className={styles.sessionEndedAction}>Click to start</span>
|
|
610
|
+
</>
|
|
611
|
+
)}
|
|
612
|
+
{waking && (
|
|
613
|
+
<span className={overlayStyles.interactHint} style={{ opacity: 1 }}>
|
|
614
|
+
Waking up...
|
|
615
|
+
</span>
|
|
616
|
+
)}
|
|
568
617
|
</div>
|
|
569
618
|
)}
|
|
570
|
-
<span className={overlayStyles.interactHint}>
|
|
571
|
-
{waking ? 'Waking up...' : connectAttempt > 0 ? 'Continue terminal session' : 'Start terminal session'}
|
|
572
|
-
</span>
|
|
573
|
-
</div>
|
|
574
|
-
)}
|
|
575
619
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
620
|
+
{/* Connecting / reveal mask */}
|
|
621
|
+
{!revealed && !error && !sessionEnded && (
|
|
622
|
+
<div className={styles.loading}>
|
|
623
|
+
<div className={styles.spinner} />
|
|
624
|
+
</div>
|
|
625
|
+
)}
|
|
626
|
+
</>
|
|
581
627
|
)}
|
|
582
628
|
</div>
|
|
583
629
|
</div>
|
|
@@ -204,6 +204,29 @@
|
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
/* ── Session-ended badge + action ── */
|
|
208
|
+
|
|
209
|
+
.sessionEndedBadge {
|
|
210
|
+
display: inline-flex;
|
|
211
|
+
align-items: center;
|
|
212
|
+
background: rgba(110, 118, 129, 0.25);
|
|
213
|
+
color: #8b949e;
|
|
214
|
+
font-size: 12px;
|
|
215
|
+
padding: 5px 14px;
|
|
216
|
+
border-radius: 999px;
|
|
217
|
+
letter-spacing: 0.01em;
|
|
218
|
+
pointer-events: none;
|
|
219
|
+
margin-bottom: 12px;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.sessionEndedAction {
|
|
223
|
+
color: #e6edf3;
|
|
224
|
+
font-size: 14px;
|
|
225
|
+
font-weight: 600;
|
|
226
|
+
opacity: 0.7;
|
|
227
|
+
pointer-events: none;
|
|
228
|
+
}
|
|
229
|
+
|
|
207
230
|
/* ── Resource-limited overlay ── */
|
|
208
231
|
|
|
209
232
|
.resourceIcon {
|